fix(api): 替换所有接口请求的域名为 guilixu-api.websoft.top
- 将获取 STS Token 的接口地址由 paopao-api 改为 guilixu-api - 更新图片上传接口地址为新的 guilixu-api 域名 - 修改用户推广页面中邀请码链接和二维码接口的域名 - 更改注册页微信登录接口请求的域名为 guilixu-api
This commit is contained in:
231
src/pages/shop/cart.tsx
Normal file
231
src/pages/shop/cart.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { View, Text, Image, ScrollView } from '@tarojs/components'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { useCartContext } from '@/contexts/CartContext'
|
||||
import { useUserContext } from '@/contexts/UserContext'
|
||||
import { pageShopGoods } from '@/api/shop/shopGoods'
|
||||
import type { ShopGoods } from '@/api/shop/shopGoods/model'
|
||||
import EmptyState from '@/components/common/EmptyState'
|
||||
import Loading from '@/components/common/Loading'
|
||||
|
||||
definePageConfig({
|
||||
navigationBarTitleText: '购物车',
|
||||
})
|
||||
|
||||
const CartPage: React.FC = () => {
|
||||
const { items, selectedCount, selectedPrice, updateQuantity, toggleSelect, selectAll, removeItem, refresh, loading, removeSelected, addItem } = useCartContext()
|
||||
const { isLoggedIn } = useUserContext()
|
||||
const [recommendPage, setRecommendPage] = useState(1)
|
||||
const [refreshingRecommend, setRefreshingRecommend] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoggedIn) {
|
||||
refresh()
|
||||
fetchRecommendGoods(1)
|
||||
}
|
||||
}, [isLoggedIn])
|
||||
|
||||
const fetchRecommendGoods = async (page?: number) => {
|
||||
const targetPage = page || recommendPage
|
||||
try {
|
||||
setRefreshingRecommend(true)
|
||||
const res = await pageShopGoods({ page: targetPage, limit: 6 })
|
||||
if (res && res.list && res.list.length > 0) {
|
||||
setRecommendGoods(res.list || [])
|
||||
setRecommendPage(targetPage + 1)
|
||||
} else {
|
||||
// 没有更多数据时回到第一页重新请求
|
||||
if (targetPage > 1) {
|
||||
const firstRes = await pageShopGoods({ page: 1, limit: 6 })
|
||||
setRecommendGoods(firstRes?.list || [])
|
||||
setRecommendPage(2)
|
||||
} else {
|
||||
setRecommendGoods([])
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取推荐商品失败', err)
|
||||
} finally {
|
||||
setRefreshingRecommend(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 换一批
|
||||
const handleRefreshRecommend = () => {
|
||||
if (!refreshingRecommend) {
|
||||
fetchRecommendGoods()
|
||||
}
|
||||
}
|
||||
|
||||
const handleCheckout = () => {
|
||||
if (selectedCount === 0) {
|
||||
Taro.showToast({ title: '请选择商品', icon: 'none' })
|
||||
return
|
||||
}
|
||||
Taro.navigateTo({ url: '/pages/shop/checkout' })
|
||||
}
|
||||
|
||||
// 未登录状态
|
||||
if (!isLoggedIn) {
|
||||
return (
|
||||
<View className="min-h-screen bg-gray-50 flex flex-col">
|
||||
<View className="flex-1 flex items-center justify-center">
|
||||
<EmptyState
|
||||
text="请先登录"
|
||||
actionText="去登录"
|
||||
onAction={() => Taro.navigateTo({ url: '/pages/passport/login' })}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="flex flex-col min-h-screen">
|
||||
{/* 主要内容区域 - 使用ScrollView实现滚动 */}
|
||||
<ScrollView scrollY className="flex-1 bg-gray-50">
|
||||
{items.length === 0 && !loading ? (
|
||||
<View className="flex items-center justify-center py-20">
|
||||
<EmptyState text="购物车是空的" actionText="去逛逛" onAction={() => Taro.switchTab({ url: '/pages/index/index' })} />
|
||||
</View>
|
||||
) : (
|
||||
<View className="p-3">
|
||||
{/* 全选 */}
|
||||
<View className="flex items-center justify-between mb-3">
|
||||
<View className="flex items-center gap-2">
|
||||
<View
|
||||
className="w-5 h-5 rounded-full border border-gray-300 flex items-center justify-center"
|
||||
onClick={() => selectAll(!items.every(i => i.checked))}
|
||||
style={{ backgroundColor: items.length > 0 && items.every(i => i.checked) ? '#0e932e' : 'transparent' }}
|
||||
>
|
||||
{items.length > 0 && items.every(i => i.checked) && <Text className="text-white text-xs">✓</Text>}
|
||||
</View>
|
||||
<Text className="text-sm text-gray-600">全选</Text>
|
||||
</View>
|
||||
{selectedCount > 0 && (
|
||||
<View onClick={removeSelected}>
|
||||
<Text className="text-xs text-gray-400">删除选中</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 商品列表 */}
|
||||
{loading ? (
|
||||
<Loading />
|
||||
) : (
|
||||
items.map((item) => (
|
||||
<View key={`${item.goodsId}-${item.skuId}`} className="bg-white rounded-lg p-3 mb-2 flex gap-3">
|
||||
<View
|
||||
className="w-5 h-5 rounded-full border border-gray-300 flex items-center justify-center mt-2 flex-shrink-0"
|
||||
onClick={() => toggleSelect(item.goodsId, item.skuId)}
|
||||
style={{ backgroundColor: item.checked ? '#0e932e' : 'transparent' }}
|
||||
>
|
||||
{item.checked && <Text className="text-white text-xs">✓</Text>}
|
||||
</View>
|
||||
<Image
|
||||
className="w-20 h-20 rounded-md bg-gray-100 flex-shrink-0"
|
||||
src={item.goodsImage || item.product?.image || ''}
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<View className="flex-1 flex flex-col justify-between">
|
||||
<Text className="text-sm text-gray-700 flex-1 line-clamp-2">
|
||||
{item.goodsName || item.product?.name || item.product?.goodsName || '商品名称'}
|
||||
</Text>
|
||||
{item.skuSpec && (
|
||||
<Text className="text-xs text-gray-400 mb-1">{item.skuSpec}</Text>
|
||||
)}
|
||||
<View className="flex justify-between items-center">
|
||||
<Text className="text-sm font-bold text-red-500">
|
||||
¥{item.skuPrice || item.sku?.price || item.product?.salePrice || item.product?.price || '0'}
|
||||
</Text>
|
||||
<View className="flex items-center gap-3">
|
||||
<View className="w-6 h-6 rounded bg-gray-100 flex items-center justify-center" onClick={() => updateQuantity(item.goodsId, item.skuId, item.quantity - 1)}>
|
||||
<Text className="text-gray-500 text-sm">-</Text>
|
||||
</View>
|
||||
<Text className="text-sm w-6 text-center">{item.quantity}</Text>
|
||||
<View className="w-6 h-6 rounded bg-gray-100 flex items-center justify-center" onClick={() => updateQuantity(item.goodsId, item.skuId, item.quantity + 1)}>
|
||||
<Text className="text-gray-500 text-sm">+</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<View className="flex items-center ml-1 flex-shrink-0" onClick={() => removeItem(item.goodsId, item.skuId)}>
|
||||
<Text className="text-gray-300 text-sm">✕</Text>
|
||||
</View>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
|
||||
{/* 推荐商品 */}
|
||||
{recommendGoods.length > 0 && (
|
||||
<View className="mt-3">
|
||||
<View className="flex items-center justify-between mb-2">
|
||||
<Text className="text-sm font-medium text-gray-700">猜你喜欢</Text>
|
||||
<Text className="text-xs text-gray-400" onClick={handleRefreshRecommend}>
|
||||
{refreshingRecommend ? '加载中...' : '换一批'}
|
||||
</Text>
|
||||
</View>
|
||||
<ScrollView scrollX className="whitespace-nowrap" style={{ width: '100%', height: '180px' }}>
|
||||
<View className="flex gap-2" style={{ display: 'inline-flex' }}>
|
||||
{recommendGoods.map(goods => (
|
||||
<View
|
||||
key={goods.id}
|
||||
className="bg-white rounded-lg p-2 inline-block"
|
||||
style={{ width: '110px', flexShrink: 0 }}
|
||||
onClick={() => Taro.navigateTo({ url: `/pages/shop/product-detail?id=${goods.goodsId}` })}
|
||||
>
|
||||
<Image
|
||||
className="w-full rounded-md bg-gray-100"
|
||||
src={goods.image || ''}
|
||||
mode="aspectFill"
|
||||
style={{ height: '90px' }}
|
||||
/>
|
||||
<Text className="text-xs text-gray-700 mt-1 block" style={{ width: '100px' }} ellipsizeMode="tail" numberOfLines={1}>
|
||||
{goods.name || goods.title || '商品名称'}
|
||||
</Text>
|
||||
<View className="flex items-center justify-between mt-1">
|
||||
<Text className="text-xs font-bold text-red-500">
|
||||
¥{goods.salePrice || goods.price || '0'}
|
||||
</Text>
|
||||
<View
|
||||
className="w-5 h-5 rounded-full flex items-center justify-center"
|
||||
style={{ backgroundColor: '#0e932e' }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
addItem(goods, undefined, 1)
|
||||
}}
|
||||
>
|
||||
<Text className="text-white text-xs">+</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
{/* 底部结算栏 */}
|
||||
{items.length > 0 && (
|
||||
<View className="flex items-center justify-between bg-white border-t border-gray-100 px-3 py-3">
|
||||
<View className="flex items-center gap-2">
|
||||
<Text className="text-sm text-gray-500">合计:</Text>
|
||||
<Text className="text-lg font-bold text-red-500">¥{selectedPrice}</Text>
|
||||
</View>
|
||||
<View
|
||||
className="px-6 py-2 rounded-full text-white text-sm font-medium"
|
||||
style={{ backgroundColor: selectedCount > 0 ? '#0e932e' : '#ccc' }}
|
||||
onClick={handleCheckout}
|
||||
>
|
||||
<Text>结算({selectedCount})</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default CartPage
|
||||
92
src/pages/shop/category.tsx
Normal file
92
src/pages/shop/category.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { View, Text, Image, ScrollView } from '@tarojs/components'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { listShopGoodsCategory } from '@/api/shop/shopGoodsCategory'
|
||||
import type { ShopGoodsCategory } from '@/api/shop/shopGoodsCategory/model'
|
||||
import EmptyState from '@/components/common/EmptyState'
|
||||
|
||||
definePageConfig({
|
||||
navigationBarTitleText: '商品分类',
|
||||
})
|
||||
|
||||
const CategoryPage: React.FC = () => {
|
||||
const [categories, setCategories] = useState<ShopGoodsCategory[]>([])
|
||||
const [activeId, setActiveId] = useState<number | undefined>()
|
||||
|
||||
useEffect(() => {
|
||||
loadCategories()
|
||||
}, [])
|
||||
|
||||
const loadCategories = async () => {
|
||||
try {
|
||||
const list = await listShopGoodsCategory()
|
||||
if (list) {
|
||||
// 过滤:只保留 status=1 且有子分类的分类,避免空壳分类导致审核被拒
|
||||
const filtered = list
|
||||
.map(cat => ({
|
||||
...cat,
|
||||
children: (cat.children || []).filter(child => child.status === 1),
|
||||
}))
|
||||
.filter(cat => cat.status === 1 && cat.children.length > 0)
|
||||
setCategories(filtered)
|
||||
if (filtered.length > 0) setActiveId(filtered[0].categoryId)
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const activeCategory = categories.find(c => c.categoryId === activeId)
|
||||
const children = activeCategory?.children || []
|
||||
|
||||
return (
|
||||
<View className='flex min-h-screen bg-gray-50'>
|
||||
{/* 左侧一级分类 */}
|
||||
<ScrollView scrollY className='w-24 bg-gray-100 h-screen'>
|
||||
{categories.map(cat => (
|
||||
<View
|
||||
key={cat.categoryId}
|
||||
className={`py-3 px-3 text-center text-sm ${
|
||||
cat.categoryId === activeId
|
||||
? 'bg-white text-green-600 font-medium border-l-2 border-green-500'
|
||||
: 'text-gray-600'
|
||||
}`}
|
||||
onClick={() => setActiveId(cat.categoryId)}
|
||||
>
|
||||
<Text>{cat.title}</Text>
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
|
||||
{/* 右侧二级分类 */}
|
||||
<ScrollView scrollY className='flex-1 h-screen'>
|
||||
{children.length > 0 ? (
|
||||
<View className='p-3'>
|
||||
<View className='grid grid-cols-3 gap-3'>
|
||||
{children.map(child => (
|
||||
<View
|
||||
key={child.categoryId}
|
||||
className='flex flex-col items-center py-2'
|
||||
onClick={() => Taro.switchTab({ url: '/pages/shop/index' })}
|
||||
>
|
||||
<View className='w-14 h-14 rounded-lg bg-gray-100 overflow-hidden mb-1'>
|
||||
{child.image ? (
|
||||
<Image className='w-full h-full' src={child.image} mode='aspectFill' />
|
||||
) : (
|
||||
<View className='w-full h-full flex items-center justify-center'>
|
||||
<Text className='text-xs text-gray-300'>{(child.title || '')[0]}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Text className='text-xs text-gray-600 text-center'>{child.title}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<EmptyState text='暂无分类' />
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default CategoryPage
|
||||
694
src/pages/shop/checkout.tsx
Normal file
694
src/pages/shop/checkout.tsx
Normal file
@@ -0,0 +1,694 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react'
|
||||
import { View, Text, Image, ScrollView, Input } from '@tarojs/components'
|
||||
import Taro, { useRouter, useDidShow } from '@tarojs/taro'
|
||||
import { useCartContext } from '@/contexts/CartContext'
|
||||
import { useAddress } from '@/hooks/useAddress'
|
||||
import { useCoupon } from '@/hooks/useCoupon'
|
||||
import { useScrollHeight } from '@/hooks/useScrollHeight'
|
||||
import { getMyAvailableCoupons } from '@/api/shop/shopUserCoupon'
|
||||
import AddressCard from '@/components/business/AddressCard'
|
||||
import CouponCard from '@/components/business/CouponCard'
|
||||
import { createOrder, repairOrder, type WxPayResult } from '@/api/shop/shopOrder'
|
||||
import type { ShopOrder } from '@/api/shop/shopOrder/model'
|
||||
import { payByBalance } from '@/api/system/payment'
|
||||
import { getUserBalance } from '@/api/system/user'
|
||||
import { listShopGoods } from '@/api/shop/shopGoods'
|
||||
import type { ShopGoods } from '@/api/shop/shopGoods/model'
|
||||
import type { ShopUserCoupon } from '@/api/shop/shopUserCoupon/model'
|
||||
import type { OrderGoodsItem, OrderCreateRequest } from '@/api/shop/shopOrder/model'
|
||||
|
||||
// 满减门槛配置
|
||||
const THRESHOLDS = [
|
||||
{ target: 99, discount: 10 },
|
||||
{ target: 199, discount: 20 },
|
||||
{ target: 299, discount: 30 },
|
||||
{ target: 499, discount: 50 },
|
||||
]
|
||||
|
||||
// 解析可能为JSON字符串的规格值
|
||||
const parseSpecValue = (value: string | undefined): string => {
|
||||
if (!value) return ''
|
||||
const trimmed = value.trim()
|
||||
if (trimmed.startsWith('"') && trimmed.endsWith('"') && trimmed.length >= 2) {
|
||||
try {
|
||||
return JSON.parse(trimmed)
|
||||
} catch {
|
||||
return trimmed.slice(1, -1)
|
||||
}
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
// 格式化规格信息,展示规格名称和值
|
||||
const formatSpecInfo = (item: any): { specList: Array<{ name: string; value: string }>; specText: string } => {
|
||||
const specList: Array<{ name: string; value: string }> = []
|
||||
|
||||
// 防御性检查:确保 goodsSpecs 是数组
|
||||
const goodsSpecs = item.product?.goodsSpecs
|
||||
if (Array.isArray(goodsSpecs) && goodsSpecs.length > 0) {
|
||||
const specGroups = new Map<number, { name: string; values: string[] }>()
|
||||
|
||||
goodsSpecs.forEach((spec: any) => {
|
||||
const specId = spec?.specId || 0
|
||||
if (!specGroups.has(specId)) {
|
||||
specGroups.set(specId, {
|
||||
name: spec?.specName || `规格${specGroups.size + 1}`,
|
||||
values: []
|
||||
})
|
||||
}
|
||||
const group = specGroups.get(specId)
|
||||
if (group) {
|
||||
group.values.push(parseSpecValue(spec?.specValue))
|
||||
}
|
||||
})
|
||||
|
||||
// 2. 如果选择了 SKU,根据 sku.sku 匹配规格值
|
||||
const skuStr = item.sku?.sku
|
||||
if (typeof skuStr === 'string' && skuStr) {
|
||||
const selectedValues = skuStr.split('|').map((v: string) => parseSpecValue(v.trim())).sort()
|
||||
|
||||
specGroups.forEach((group) => {
|
||||
// 防御性检查:确保 values 是数组
|
||||
if (!Array.isArray(group.values)) return
|
||||
// 查找匹配的规格值
|
||||
const matchedValue = group.values.find(v => selectedValues.includes(v))
|
||||
if (matchedValue) {
|
||||
specList.push({ name: group.name, value: matchedValue })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 如果没有 SKU 但有 skuSpec 字符串
|
||||
if (specList.length === 0 && item.skuSpec) {
|
||||
const values = String(item.skuSpec).split(',').map(v => v.trim()).filter(Boolean)
|
||||
let idx = 0
|
||||
specGroups.forEach((group) => {
|
||||
if (values[idx]) {
|
||||
specList.push({ name: group.name, value: values[idx] })
|
||||
}
|
||||
idx++
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 如果没有完整的规格定义,但有 skuSpec 字符串
|
||||
if (specList.length === 0 && item.skuSpec) {
|
||||
const parts = String(item.skuSpec).split(/[,,]/).map(v => v.trim()).filter(Boolean)
|
||||
if (parts.length > 0) {
|
||||
specList.push({ name: '规格', value: parts.join(', ') })
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
specList,
|
||||
specText: specList.map(s => `${s.name}: ${s.value}`).join(' | ') || item.skuSpec || ''
|
||||
}
|
||||
}
|
||||
|
||||
definePageConfig({
|
||||
navigationBarTitleText: '确认订单',
|
||||
})
|
||||
|
||||
const CheckoutPage: React.FC = () => {
|
||||
const router = useRouter()
|
||||
const fromBuyNow = router.params.from === 'buyNow'
|
||||
|
||||
const { selectedItems, refresh: refreshCart, removeSelected } = useCartContext()
|
||||
const { defaultAddress, loadAddresses } = useAddress()
|
||||
const maxPopupHeight = useScrollHeight(0)
|
||||
// 底部提交栏高度约 60px,动态计算 ScrollView 可用高度
|
||||
const scrollHeight = useScrollHeight(44)
|
||||
const [coupons, setCoupons] = useState<ShopUserCoupon[]>([])
|
||||
const [selectedCoupon, setSelectedCoupon] = useState<ShopUserCoupon | null>(null)
|
||||
const [couponVisible, setCouponVisible] = useState(false)
|
||||
const [remarks, setRemarks] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [rushBuyProducts, setRushBuyProducts] = useState<ShopGoods[]>([])
|
||||
|
||||
// 支付方式相关状态
|
||||
const [payType, setPayType] = useState<number>(1) // 1: 微信支付, 0: 余额支付
|
||||
const [userBalance, setUserBalance] = useState<string>('0.00')
|
||||
const [loadingBalance, setLoadingBalance] = useState(false)
|
||||
|
||||
// 从本地存储获取立即购买的数据
|
||||
const buyNowItems = useMemo(() => {
|
||||
if (fromBuyNow) {
|
||||
const data = Taro.getStorageSync('buy_now')
|
||||
if (data) {
|
||||
try {
|
||||
const parsed = JSON.parse(data)
|
||||
return Array.isArray(parsed) ? parsed : []
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}, [fromBuyNow])
|
||||
|
||||
// 使用的商品列表
|
||||
const items = buyNowItems || selectedItems
|
||||
|
||||
// 计算金额
|
||||
const goodsPrice = useMemo(() => {
|
||||
if (!items || items.length === 0) return 0
|
||||
return items.reduce((sum, item) => {
|
||||
return sum + Number(item.skuPrice || item.sku?.price || item.product?.salePrice || item.product?.price || 0) * (item.quantity || item.num || 1)
|
||||
}, 0)
|
||||
}, [buyNowItems, selectedItems])
|
||||
|
||||
const couponDiscount = selectedCoupon?.reducePrice ? Number(selectedCoupon.reducePrice) : 0
|
||||
const totalPrice = Math.max(0, goodsPrice - couponDiscount).toFixed(2)
|
||||
|
||||
// 计算凑单信息
|
||||
const rushBuyInfo = useMemo(() => {
|
||||
if (goodsPrice === 0) return null
|
||||
|
||||
// 找出最近的满减门槛
|
||||
for (const threshold of THRESHOLDS) {
|
||||
if (goodsPrice < threshold.target) {
|
||||
const needAmount = threshold.target - goodsPrice
|
||||
// 只在需要凑的金额在 5-80 元之间时显示推荐
|
||||
if (needAmount <= 80) {
|
||||
return {
|
||||
target: threshold.target,
|
||||
discount: threshold.discount,
|
||||
needAmount: needAmount.toFixed(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}, [goodsPrice])
|
||||
|
||||
// 加载优惠券和凑单商品
|
||||
useEffect(() => {
|
||||
loadCoupons()
|
||||
loadRushBuyProducts()
|
||||
loadUserBalance()
|
||||
}, [])
|
||||
|
||||
// 每次页面显示时刷新地址(从地址列表选择后返回)
|
||||
useDidShow(() => {
|
||||
loadAddresses()
|
||||
})
|
||||
|
||||
const loadCoupons = async () => {
|
||||
try {
|
||||
const data = await getMyAvailableCoupons()
|
||||
setCoupons(data || [])
|
||||
} catch (e) {
|
||||
console.error('加载优惠券失败:', e)
|
||||
// 不设置任何内容,让页面继续显示
|
||||
}
|
||||
}
|
||||
|
||||
// 加载凑单推荐商品
|
||||
const loadRushBuyProducts = async () => {
|
||||
try {
|
||||
// 获取热销/推荐商品
|
||||
const data = await listShopGoods({ page: 1, limit: 10, isShow: 1 })
|
||||
// 防御性检查:确保返回的是数组
|
||||
const safeData = Array.isArray(data) ? data : []
|
||||
if (safeData.length > 0) {
|
||||
// 过滤掉已在购物车的商品
|
||||
const currentItems = buyNowItems || selectedItems
|
||||
const cartGoodsIds = Array.isArray(currentItems) ? currentItems.map(item => item.goodsId) : []
|
||||
const filtered = safeData.filter(p => !cartGoodsIds.includes(p.goodsId))
|
||||
setRushBuyProducts(filtered.slice(0, 4))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载凑单商品失败:', e)
|
||||
// 不设置任何内容,让页面继续显示
|
||||
}
|
||||
}
|
||||
|
||||
// 加载用户余额
|
||||
const loadUserBalance = async () => {
|
||||
setLoadingBalance(true)
|
||||
try {
|
||||
const data = await getUserBalance()
|
||||
setUserBalance(data?.balance || '0.00')
|
||||
} catch (e) {
|
||||
console.error('加载余额失败:', e)
|
||||
setUserBalance('0.00')
|
||||
} finally {
|
||||
setLoadingBalance(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 选择地址
|
||||
const handleSelectAddress = () => {
|
||||
Taro.navigateTo({
|
||||
url: '/pages/user/address-list?from=checkout',
|
||||
events: {
|
||||
refresh: () => loadAddresses()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 选择优惠券
|
||||
const handleSelectCoupon = () => {
|
||||
setCouponVisible(true)
|
||||
}
|
||||
|
||||
const handleCouponConfirm = (coupon: ShopUserCoupon | null) => {
|
||||
setSelectedCoupon(coupon)
|
||||
setCouponVisible(false)
|
||||
}
|
||||
|
||||
// 提交订单
|
||||
const handleSubmit = async () => {
|
||||
if (!defaultAddress) {
|
||||
Taro.showToast({ title: '请选择收货地址', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
Taro.showToast({ title: '请选择商品', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
// 构建商品列表
|
||||
const goodsItems: OrderGoodsItem[] = items.map(item => ({
|
||||
goodsId: item.goodsId,
|
||||
skuId: item.skuId && item.skuId > 0 ? item.skuId : undefined,
|
||||
quantity: item.quantity || item.num || 1,
|
||||
specInfo: item.skuSpec || item.sku?.sku || item.product?.specName,
|
||||
}))
|
||||
|
||||
// 检查余额是否足够(如果选择余额支付)
|
||||
if (payType === 0 && parseFloat(userBalance) < parseFloat(totalPrice)) {
|
||||
Taro.showModal({
|
||||
title: '余额不足',
|
||||
content: `当前余额 ¥${parseFloat(userBalance).toFixed(2)},需要 ¥${totalPrice},是否前往充值?`,
|
||||
confirmText: '去充值',
|
||||
cancelText: '取消',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
Taro.navigateTo({ url: '/pages/user/recharge' })
|
||||
}
|
||||
}
|
||||
})
|
||||
setSubmitting(false)
|
||||
return
|
||||
}
|
||||
|
||||
// 创建订单
|
||||
const orderParams: OrderCreateRequest = {
|
||||
goodsItems,
|
||||
addressId: defaultAddress.id,
|
||||
payType,
|
||||
couponId: selectedCoupon?.id ? Number(selectedCoupon.id) : undefined,
|
||||
comments: remarks,
|
||||
deliveryType: 0, // 快递配送
|
||||
}
|
||||
|
||||
const res = await createOrder(orderParams)
|
||||
|
||||
// 根据支付方式处理
|
||||
if (payType === 0) {
|
||||
// 余额支付 - res 包含 { orderId, orderNo, payType, payPrice }
|
||||
const balanceRes = res as { orderId?: number; orderNo?: string }
|
||||
await handleBalancePay(Number(balanceRes.orderId))
|
||||
} else {
|
||||
// 微信支付 - res 是 WxPayResult
|
||||
await handleWxPay(res as WxPayResult)
|
||||
}
|
||||
|
||||
// 清理数据
|
||||
if (fromBuyNow) {
|
||||
Taro.removeStorageSync('buy_now')
|
||||
} else {
|
||||
await removeSelected()
|
||||
}
|
||||
|
||||
Taro.showToast({ title: '支付成功', icon: 'success' })
|
||||
setTimeout(() => {
|
||||
Taro.redirectTo({ url: '/pages/order/list' })
|
||||
}, 1500)
|
||||
} catch (err: any) {
|
||||
Taro.showToast({ title: err.message || '提交失败', icon: 'none' })
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 微信支付
|
||||
const handleWxPay = async (payData: WxPayResult & { paid?: string }) => {
|
||||
// 后端返回已支付(回调丢失自动修复场景)
|
||||
if (payData.paid === 'true') {
|
||||
return // 直接视为支付成功
|
||||
}
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
Taro.requestPayment({
|
||||
timeStamp: payData.timeStamp,
|
||||
nonceStr: payData.nonceStr,
|
||||
package: payData.package,
|
||||
signType: payData.signType,
|
||||
paySign: payData.paySign,
|
||||
success: () => resolve(),
|
||||
fail: (err) => reject(new Error('支付取消'))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 余额支付
|
||||
const handleBalancePay = async (orderId: number | string) => {
|
||||
try {
|
||||
await payByBalance({ orderId: Number(orderId) })
|
||||
// 余额支付成功后,修复订单支付状态
|
||||
try {
|
||||
await repairOrder({ orderId: Number(orderId), payStatus: true } as Partial<ShopOrder>)
|
||||
} catch {
|
||||
// 修复失败不影响支付流程
|
||||
}
|
||||
// 刷新余额
|
||||
loadUserBalance()
|
||||
} catch (err: any) {
|
||||
throw new Error(err.message || '余额支付失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 安全的数字格式化
|
||||
const formatPrice = (price: number | string): string => {
|
||||
const num = typeof price === 'string' ? parseFloat(price) : price
|
||||
if (isNaN(num)) return '0.00'
|
||||
return num.toFixed(2)
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='bg-gray-50 flex flex-col' style={{ height: '100vh' }}>
|
||||
<ScrollView scrollY style={{ height: scrollHeight }}>
|
||||
<View className='p-3'>
|
||||
{/* 地址 */}
|
||||
{defaultAddress ? (
|
||||
<AddressCard
|
||||
address={defaultAddress}
|
||||
onClick={handleSelectAddress}
|
||||
/>
|
||||
) : (
|
||||
<View className='bg-white rounded-lg p-3' onClick={handleSelectAddress}>
|
||||
<View className='flex items-center justify-between'>
|
||||
<View className='flex items-center gap-2'>
|
||||
<Text className='text-gray-400 text-sm'>📍</Text>
|
||||
<View>
|
||||
<Text className='text-sm font-medium text-gray-800 block'>请选择收货地址</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text className='text-gray-400 text-sm'>›</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 商品列表 */}
|
||||
<View className='bg-white rounded-lg mt-3 p-3'>
|
||||
<Text className='text-sm font-medium text-gray-800 mb-3 block'>
|
||||
商品 ({(items || []).length})
|
||||
</Text>
|
||||
{items && items.length > 0 ? (
|
||||
items.map((item, idx) => {
|
||||
const { specList } = formatSpecInfo(item)
|
||||
return (
|
||||
<View key={`${item.goodsId}-${item.skuId}-${idx}`} className='flex gap-3 py-3 border-b border-gray-50'>
|
||||
<Image
|
||||
className='w-16 h-16 rounded-md bg-gray-100'
|
||||
src={item.goodsImage || item.product?.image || item.sku?.image || ''}
|
||||
mode='aspectFill'
|
||||
/>
|
||||
<View className='flex-1'>
|
||||
<Text className='text-sm text-gray-700 block line-clamp-2'>
|
||||
{item.goodsName || item.product?.name || item.product?.goodsName}
|
||||
</Text>
|
||||
{/* 多规格属性展示 */}
|
||||
{specList.length > 0 ? (
|
||||
<View className='mt-1'>
|
||||
{specList.map((spec, specIdx) => (
|
||||
<View key={specIdx} className='flex items-center text-xs'>
|
||||
<Text className='text-gray-400'>{spec.name}:</Text>
|
||||
<Text className='text-gray-600 ml-1'>{spec.value}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : item.skuSpec ? (
|
||||
<Text className='text-xs text-gray-400 mt-1'>{item.skuSpec}</Text>
|
||||
) : null}
|
||||
<View className='flex justify-between items-center mt-1'>
|
||||
<Text className='text-xs text-gray-500'>x{item.quantity || item.num || 1}</Text>
|
||||
<Text className='text-sm font-medium text-gray-800'>
|
||||
¥{item.skuPrice || item.sku?.salePrice || item.sku?.price || item.product?.salePrice || item.product?.price || '0'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<View className='py-10 text-center'>
|
||||
<Text className='text-gray-400 text-sm'>购物车为空,请先添加商品</Text>
|
||||
<View
|
||||
className='mt-3 px-6 py-2 rounded-full text-white text-sm inline-block'
|
||||
style={{ backgroundColor: '#0e932e' }}
|
||||
onClick={() => Taro.switchTab({ url: '/pages/shop/index' })}
|
||||
>
|
||||
去购物
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 优惠券 */}
|
||||
<View className='bg-white rounded-lg mt-3 p-3' onClick={handleSelectCoupon}>
|
||||
<View className='flex justify-between items-center'>
|
||||
<Text className='text-sm text-gray-700'>优惠券</Text>
|
||||
<View className='flex items-center gap-2'>
|
||||
{selectedCoupon ? (
|
||||
<Text className='text-sm text-red-500'>-{selectedCoupon.reducePrice}元</Text>
|
||||
) : coupons.length > 0 ? (
|
||||
<Text className='text-sm text-gray-400'>有{coupons.length}张可用</Text>
|
||||
) : (
|
||||
<Text className='text-sm text-gray-400'>暂无可用</Text>
|
||||
)}
|
||||
<Text className='text-gray-400'>›</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 备注 */}
|
||||
<View className='bg-white rounded-lg mt-3 p-3'>
|
||||
<View className='flex justify-between items-center'>
|
||||
<Text className='text-sm text-gray-700'>订单备注</Text>
|
||||
<Text className='text-xs text-gray-400'>选填</Text>
|
||||
</View>
|
||||
<View className='mt-2'>
|
||||
<Input
|
||||
className='bg-gray-50 rounded-lg px-3 py-2 text-sm'
|
||||
placeholder='点击添加备注...'
|
||||
value={remarks}
|
||||
onInput={(e: any) => setRemarks(e.detail.value)}
|
||||
maxlength={200}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 金额明细 */}
|
||||
<View className='bg-white rounded-lg mt-3 p-3'>
|
||||
<View className='flex justify-between mb-2'>
|
||||
<Text className='text-sm text-gray-500'>商品合计</Text>
|
||||
<Text className='text-sm text-gray-700'>¥{formatPrice(goodsPrice)}</Text>
|
||||
</View>
|
||||
<View className='flex justify-between mb-2'>
|
||||
<Text className='text-sm text-gray-500'>运费</Text>
|
||||
<Text className='text-sm text-green-600'>免运费</Text>
|
||||
</View>
|
||||
{couponDiscount > 0 && (
|
||||
<View className='flex justify-between mb-2'>
|
||||
<Text className='text-sm text-gray-500'>优惠券</Text>
|
||||
<Text className='text-sm text-red-500'>-¥{formatPrice(couponDiscount)}</Text>
|
||||
</View>
|
||||
)}
|
||||
<View className='flex justify-between pt-2 border-t border-gray-100'>
|
||||
<Text className='text-sm font-medium'>实付金额</Text>
|
||||
<Text className='text-lg font-bold text-red-500'>¥{totalPrice}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 支付方式选择 */}
|
||||
<View className='bg-white rounded-lg mt-3 p-3'>
|
||||
<Text className='text-sm font-medium text-gray-800 mb-3 block'>支付方式</Text>
|
||||
|
||||
{/* 微信支付 */}
|
||||
<View
|
||||
className='flex items-center py-3 px-2 rounded-lg mb-2'
|
||||
style={{ backgroundColor: payType === 1 ? '#f0fdf4' : '#f9fafb' }}
|
||||
onClick={() => setPayType(1)}
|
||||
>
|
||||
<View className='w-8 h-8 rounded-full bg-green-50 flex items-center justify-center mr-3'>
|
||||
<Text className='text-sm'>微</Text>
|
||||
</View>
|
||||
<View className='flex-1'>
|
||||
<Text className='text-sm text-gray-800 block'>微信支付</Text>
|
||||
<Text className='text-xs text-gray-400 block'>推荐使用</Text>
|
||||
</View>
|
||||
<View
|
||||
className='w-5 h-5 rounded-full border-2 flex items-center justify-center'
|
||||
style={{ borderColor: payType === 1 ? '#22c55e' : '#d1d5db' }}
|
||||
>
|
||||
{payType === 1 && (
|
||||
<View className='w-2 h-2 rounded-full' style={{ backgroundColor: '#22c55e' }} />
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 余额支付 */}
|
||||
<View
|
||||
className='flex items-center py-3 px-2 rounded-lg'
|
||||
style={{ backgroundColor: payType === 0 ? '#f0fdf4' : '#f9fafb' }}
|
||||
onClick={() => setPayType(0)}
|
||||
>
|
||||
<View className='w-8 h-8 rounded-full bg-amber-50 flex items-center justify-center mr-3'>
|
||||
<Text className='text-sm'>余</Text>
|
||||
</View>
|
||||
<View className='flex-1'>
|
||||
<Text className='text-sm text-gray-800 block'>余额支付</Text>
|
||||
<Text className='text-xs text-gray-400 block'>
|
||||
{loadingBalance ? '加载中...' : `可用: ¥${parseFloat(userBalance).toFixed(2)}`}
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
className='w-5 h-5 rounded-full border-2 flex items-center justify-center'
|
||||
style={{ borderColor: payType === 0 ? '#22c55e' : '#d1d5db' }}
|
||||
>
|
||||
{payType === 0 && (
|
||||
<View className='w-2 h-2 rounded-full' style={{ backgroundColor: '#22c55e' }} />
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 余额不足提示 */}
|
||||
{payType === 0 && parseFloat(userBalance) < parseFloat(totalPrice) && (
|
||||
<View className='mt-2 px-2'>
|
||||
<Text className='text-xs text-red-500'>
|
||||
余额不足,还差 ¥{(parseFloat(totalPrice) - parseFloat(userBalance)).toFixed(2)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 凑单推荐 */}
|
||||
{rushBuyInfo && rushBuyProducts.length > 0 && (
|
||||
<View className='rounded-lg mt-3 p-3 border border-red-100' style={{ background: 'linear-gradient(to right, #fef2f2, #fff7ed)', display: 'none' }}>
|
||||
<View className='flex items-center justify-between mb-3'>
|
||||
<View className='flex items-center gap-2'>
|
||||
<Text className='text-red-500 text-lg'>🎯</Text>
|
||||
<Text className='text-sm font-medium text-red-600'>
|
||||
再买 ¥{rushBuyInfo.needAmount} 减 ¥{rushBuyInfo.discount}
|
||||
</Text>
|
||||
</View>
|
||||
<Text className='text-xs text-gray-400'>凑单更优惠</Text>
|
||||
</View>
|
||||
<ScrollView scrollX className='flex-row'>
|
||||
<View className='flex flex-row gap-2'>
|
||||
{rushBuyProducts.map(product => (
|
||||
<View
|
||||
key={product.goodsId}
|
||||
className='w-24 bg-white rounded-lg p-2'
|
||||
onClick={() => {
|
||||
Taro.navigateTo({
|
||||
url: `/pages/shop/product-detail?id=${product.goodsId}&from=rushbuy`
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
className='w-full h-20 rounded bg-gray-100'
|
||||
src={product.image || ''}
|
||||
mode='aspectFill'
|
||||
/>
|
||||
<Text className='text-xs text-gray-700 block line-clamp-1 mt-1'>
|
||||
{product.name}
|
||||
</Text>
|
||||
<View className='flex items-center justify-between mt-1'>
|
||||
<Text className='text-xs font-medium text-red-500'>
|
||||
¥{product.salePrice || product.price}
|
||||
</Text>
|
||||
<Text className='text-xs text-gray-400'>+加购</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
{/* 底部留白(固定栏高度 + 安全区) */}
|
||||
<View style={{ height: '80px' }} />
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{/* 底部提交栏 - 固定吸底 */}
|
||||
<View
|
||||
className='bg-white border-t border-gray-100 px-3'
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
paddingBottom: 'env(safe-area-inset-bottom)',
|
||||
zIndex: 100,
|
||||
}}
|
||||
>
|
||||
<View className='flex items-center justify-between py-3'>
|
||||
<View className='flex items-center gap-2'>
|
||||
<Text className='text-sm text-gray-500'>合计:</Text>
|
||||
<Text className='text-lg font-bold text-red-500'>¥{totalPrice}</Text>
|
||||
</View>
|
||||
<View
|
||||
className='px-8 py-2 rounded-full text-white text-sm font-medium'
|
||||
style={{ backgroundColor: submitting ? '#ccc' : '#0e932e' }}
|
||||
onClick={submitting ? undefined : handleSubmit}
|
||||
>
|
||||
<Text>{submitting ? '提交中...' : '提交订单'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 优惠券选择弹窗 */}
|
||||
{couponVisible && (
|
||||
<View className='absolute flex items-end' style={{ top: 0, right: 0, bottom: 0, left: 0, backgroundColor: 'rgba(0,0,0,0.5)', zIndex: 110 }}>
|
||||
<View className='w-full bg-white rounded-t-xl overflow-hidden' style={{ maxHeight: maxPopupHeight }}>
|
||||
<View className='p-4 border-b border-gray-100 flex justify-between items-center'>
|
||||
<Text className='text-base font-medium'>选择优惠券</Text>
|
||||
<Text className='text-gray-400' onClick={() => setCouponVisible(false)}>✕</Text>
|
||||
</View>
|
||||
<ScrollView scrollY className='flex-1'>
|
||||
<View className='p-3'>
|
||||
{/* 不使用优惠券 */}
|
||||
<View
|
||||
className='bg-white rounded-lg p-3 mb-3 border border-gray-200'
|
||||
onClick={() => handleCouponConfirm(null)}
|
||||
>
|
||||
<Text className='text-sm text-gray-600'>不使用优惠券</Text>
|
||||
</View>
|
||||
{/* 可用优惠券列表 */}
|
||||
{coupons.map(coupon => (
|
||||
<CouponCard
|
||||
key={coupon.id}
|
||||
coupon={coupon}
|
||||
selected={selectedCoupon?.id === coupon.id}
|
||||
onClick={() => handleCouponConfirm(coupon)}
|
||||
/>
|
||||
))}
|
||||
{coupons.length === 0 && (
|
||||
<View className='py-10 text-center'>
|
||||
<Text className='text-gray-400 text-sm'>暂无可用优惠券</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default CheckoutPage
|
||||
3
src/pages/shop/group-buy-detail/index.config.ts
Normal file
3
src/pages/shop/group-buy-detail/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
navigationBarTitleText: '拼团详情',
|
||||
}
|
||||
323
src/pages/shop/group-buy-detail/index.tsx
Normal file
323
src/pages/shop/group-buy-detail/index.tsx
Normal file
@@ -0,0 +1,323 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { View, Text, Image, ScrollView } from '@tarojs/components'
|
||||
import Taro, { useRouter } from '@tarojs/taro'
|
||||
import { getShopGroupBuy, listShopGroupBuyRecords, joinGroupBuy, createGroupBuy } from '@/api/shop/shopGroupBuy'
|
||||
import { getShopGoods } from '@/api/shop/shopGoods'
|
||||
import type { ShopGroupBuy, ShopGroupBuyRecord } from '@/api/shop/shopGroupBuy/model'
|
||||
import type { ShopGoods } from '@/api/shop/shopGoods/model'
|
||||
import type { ShopGoodsSku } from '@/api/shop/shopGoodsSku/model'
|
||||
import SkuSelector from '@/components/business/SkuSelector'
|
||||
|
||||
definePageConfig({
|
||||
navigationBarTitleText: '拼团详情',
|
||||
})
|
||||
|
||||
const GroupBuyDetailPage: React.FC = () => {
|
||||
const router = useRouter()
|
||||
const groupBuyId = Number(router.params.groupBuyId || 0)
|
||||
|
||||
const [groupBuy, setGroupBuy] = useState<ShopGroupBuy | null>(null)
|
||||
const [product, setProduct] = useState<ShopGoods | null>(null)
|
||||
const [records, setRecords] = useState<ShopGroupBuyRecord[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [skuVisible, setSkuVisible] = useState(false)
|
||||
const [pendingRecordId, setPendingRecordId] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (groupBuyId) {
|
||||
fetchData()
|
||||
}
|
||||
}, [groupBuyId])
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const [res1, res2] = await Promise.all([
|
||||
getShopGroupBuy(groupBuyId),
|
||||
listShopGroupBuyRecords(groupBuyId),
|
||||
])
|
||||
|
||||
if (res1.code === 0 && res1.data) {
|
||||
const data = res1.data
|
||||
setGroupBuy(data)
|
||||
|
||||
// 加载商品完整信息(含规格/SKU)
|
||||
if (data.goodsId) {
|
||||
fetchGoodsDetail(data.goodsId)
|
||||
}
|
||||
}
|
||||
|
||||
if (res2.code === 0 && res2.data) {
|
||||
setRecords(res2.data)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取拼团详情失败', err)
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchGoodsDetail = async (goodsId: number) => {
|
||||
try {
|
||||
const res = await getShopGoods(goodsId)
|
||||
if (res.code === 0 && res.data) {
|
||||
setProduct(res.data)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取商品详情失败', err)
|
||||
}
|
||||
}
|
||||
|
||||
// 准备商品规格并显示选择器(开团)
|
||||
const handleCreateGroup = () => {
|
||||
if (!groupBuy) return
|
||||
setPendingRecordId(null) // 标记为开团
|
||||
|
||||
if (!product) {
|
||||
Taro.showLoading({ title: '加载规格...' })
|
||||
getShopGoods(groupBuy.goodsId)
|
||||
.then(res => {
|
||||
Taro.hideLoading()
|
||||
if (res.code === 0 && res.data) {
|
||||
setProduct(res.data)
|
||||
setSkuVisible(true)
|
||||
} else {
|
||||
Taro.showToast({ title: '商品信息加载失败', icon: 'none' })
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
Taro.hideLoading()
|
||||
Taro.showToast({ title: '商品信息加载失败', icon: 'none' })
|
||||
})
|
||||
} else {
|
||||
setSkuVisible(true)
|
||||
}
|
||||
}
|
||||
|
||||
// 去凑单(参团)
|
||||
const handleJoinGroup = (recordId: number) => {
|
||||
if (!groupBuy) return
|
||||
setPendingRecordId(recordId)
|
||||
|
||||
if (!product) {
|
||||
Taro.showLoading({ title: '加载规格...' })
|
||||
getShopGoods(groupBuy.goodsId)
|
||||
.then(res => {
|
||||
Taro.hideLoading()
|
||||
if (res.code === 0 && res.data) {
|
||||
setProduct(res.data)
|
||||
setSkuVisible(true)
|
||||
} else {
|
||||
Taro.showToast({ title: '商品信息加载失败', icon: 'none' })
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
Taro.hideLoading()
|
||||
Taro.showToast({ title: '商品信息加载失败', icon: 'none' })
|
||||
})
|
||||
} else {
|
||||
setSkuVisible(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSkuConfirm = async (sku: ShopGoodsSku, quantity: number) => {
|
||||
if (!groupBuy) return
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
let res: any
|
||||
if (pendingRecordId && pendingRecordId > 0) {
|
||||
// 参团
|
||||
res = await joinGroupBuy({
|
||||
recordId: pendingRecordId,
|
||||
goodsId: groupBuy.goodsId,
|
||||
skuId: sku.id || 0,
|
||||
quantity,
|
||||
})
|
||||
Taro.showToast({ title: '参团成功', icon: 'success' })
|
||||
} else {
|
||||
// 开团
|
||||
res = await createGroupBuy({
|
||||
groupBuyId: groupBuy.id!,
|
||||
goodsId: groupBuy.goodsId,
|
||||
skuId: sku.id || 0,
|
||||
quantity,
|
||||
})
|
||||
Taro.showToast({ title: '开团成功', icon: 'success' })
|
||||
}
|
||||
|
||||
if (res.code === 0) {
|
||||
fetchData() // 刷新数据
|
||||
}
|
||||
} catch (err: any) {
|
||||
Taro.showToast({ title: err?.message || '操作失败', icon: 'none' })
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<Text className="text-gray-400 text-sm">加载中...</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
if (!groupBuy) {
|
||||
return (
|
||||
<View className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<Text className="text-gray-400 text-sm">拼团活动不存在</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const remaining = groupBuy.groupSize - groupBuy.currentSize
|
||||
const isActive = groupBuy.status === 1
|
||||
|
||||
return (
|
||||
<View className="min-h-screen bg-gray-50 pb-20">
|
||||
<ScrollView scrollY className="h-screen">
|
||||
{/* 商品信息 */}
|
||||
<View className="bg-white p-4 flex gap-3">
|
||||
<Image
|
||||
className="w-24 h-24 rounded-md bg-gray-100"
|
||||
src={groupBuy.goodsImage || groupBuy.product?.image || ''}
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<View className="flex-1 flex flex-col justify-between">
|
||||
<Text className="text-sm text-gray-700 font-medium line-clamp-2">
|
||||
{groupBuy.goodsName || groupBuy.product?.name}
|
||||
</Text>
|
||||
<View className="flex items-baseline gap-2">
|
||||
<Text className="text-2xl font-bold text-red-500">
|
||||
¥{groupBuy.groupPrice}
|
||||
</Text>
|
||||
<Text className="text-xs text-gray-400 line-through">
|
||||
¥{groupBuy.product?.price || 0}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex items-center gap-2 mt-1">
|
||||
{isActive && remaining > 0 && (
|
||||
<Text className="text-xs text-red-500 font-medium">拼团进行中</Text>
|
||||
)}
|
||||
{remaining === 0 && (
|
||||
<Text className="text-xs text-green-500 font-medium">已成团</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 拼团进度 */}
|
||||
<View className="bg-white mt-3 p-4">
|
||||
<Text className="text-base font-medium text-gray-800 mb-3 block">拼团进度</Text>
|
||||
<View className="flex items-center justify-between">
|
||||
<Text className="text-sm text-gray-600">成团人数</Text>
|
||||
<Text className="text-sm font-medium text-gray-800">{groupBuy.groupSize}人</Text>
|
||||
</View>
|
||||
<View className="flex items-center justify-between mt-2">
|
||||
<Text className="text-sm text-gray-600">已参团</Text>
|
||||
<Text className="text-sm font-medium text-red-500">{groupBuy.currentSize}人</Text>
|
||||
</View>
|
||||
<View className="mt-3 bg-gray-100 rounded-full h-2 overflow-hidden">
|
||||
<View
|
||||
className="h-full rounded-full"
|
||||
style={{
|
||||
width: `${Math.min((groupBuy.currentSize / groupBuy.groupSize) * 100, 100)}%`,
|
||||
backgroundColor: '#ef4444',
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
{remaining > 0 && (
|
||||
<Text className="text-xs text-gray-400 mt-2 block">
|
||||
还差{remaining}人成团,快来参与吧!
|
||||
</Text>
|
||||
)}
|
||||
{remaining === 0 && (
|
||||
<Text className="text-xs text-green-500 mt-2 block">拼团已成功,人数已满</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 正在拼团的列表 */}
|
||||
{records.length > 0 && (
|
||||
<View className="bg-white mt-3 p-4">
|
||||
<Text className="text-base font-medium text-gray-800 mb-3 block">
|
||||
以下小伙伴正在拼团,可直接参与
|
||||
</Text>
|
||||
{records.map(record => (
|
||||
<View key={record.id} className="flex items-center justify-between py-3 border-b border-gray-50">
|
||||
<View className="flex items-center gap-3">
|
||||
<View className="w-10 h-10 rounded-full flex items-center justify-center" style={{ backgroundColor: '#fef2f2' }}>
|
||||
<Text className="text-xs text-red-500">
|
||||
{record.isLeader ? '团长' : '成员'}
|
||||
</Text>
|
||||
</View>
|
||||
<View>
|
||||
<Text className="text-sm text-gray-700">
|
||||
{record.isLeader ? '团长' : `团员${record.id}`}
|
||||
</Text>
|
||||
<Text className="text-xs text-gray-400">
|
||||
{record.groupSize - record.memberCount > 0
|
||||
? `还差${record.groupSize - record.memberCount}人`
|
||||
: '已成团'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
{isActive && record.status === 1 && (
|
||||
<View
|
||||
className="px-4 py-1 rounded-full text-white text-xs"
|
||||
style={{ backgroundColor: '#ef4444' }}
|
||||
onClick={() => handleJoinGroup(record.id)}
|
||||
>
|
||||
<Text className="text-white text-xs">去凑单</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 拼团规则 */}
|
||||
<View className="bg-white mt-3 p-4 mb-4">
|
||||
<Text className="text-base font-medium text-gray-800 mb-3 block">拼团规则</Text>
|
||||
<View className="flex flex-col" style={{ gap: '8px' }}>
|
||||
<Text className="text-sm text-gray-500">• 支付开团或参加拼团后,需在24小时内凑齐人数</Text>
|
||||
<Text className="text-sm text-gray-500">• 拼团失败,系统会自动退款</Text>
|
||||
<Text className="text-sm text-gray-500">• 拼团商品享受拼团价格,不支持7天无理由退货</Text>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{/* 底部操作栏 */}
|
||||
<View className="bg-white border-t border-gray-100 px-4 py-3 flex items-center justify-between">
|
||||
<View
|
||||
className="flex-1 mr-2 py-3 rounded-full text-center text-sm font-medium border-2"
|
||||
style={{ borderColor: '#ef4444', color: '#ef4444' }}
|
||||
onClick={handleCreateGroup}
|
||||
>
|
||||
<Text>单独购买</Text>
|
||||
</View>
|
||||
<View
|
||||
className="flex-1 py-3 rounded-full text-white text-sm font-medium text-center"
|
||||
style={{ backgroundColor: isActive ? '#ef4444' : '#d1d5db' }}
|
||||
onClick={isActive ? handleCreateGroup : undefined}
|
||||
>
|
||||
<Text>{submitting ? '处理中...' : '立即拼团'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* SKU 选择器 */}
|
||||
<SkuSelector
|
||||
visible={skuVisible}
|
||||
product={product}
|
||||
mode="buy"
|
||||
onClose={() => setSkuVisible(false)}
|
||||
onConfirm={handleSkuConfirm}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default GroupBuyDetailPage
|
||||
119
src/pages/shop/group-buy-list/index.tsx
Normal file
119
src/pages/shop/group-buy-list/index.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { View, Text, Image, ScrollView } from '@tarojs/components'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { pageShopGroupBuy } from '@/api/shop/shopGroupBuy'
|
||||
import type { ShopGroupBuy } from '@/api/shop/shopGroupBuy/model'
|
||||
import EmptyState from '@/components/common/EmptyState'
|
||||
|
||||
definePageConfig({
|
||||
navigationBarTitleText: '拼团活动',
|
||||
})
|
||||
|
||||
const GroupBuyListPage: React.FC = () => {
|
||||
const [groupBuyList, setGroupBuyList] = useState<ShopGroupBuy[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
fetchGroupBuyList()
|
||||
}, [])
|
||||
|
||||
const fetchGroupBuyList = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const res = await pageShopGroupBuy({ page: 1, pageSize: 20, status: 1 })
|
||||
if (res.code === 0 && res.data) {
|
||||
setGroupBuyList(res.data.items || [])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取拼团列表失败', err)
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleGroupBuyClick = (item: ShopGroupBuy) => {
|
||||
Taro.navigateTo({
|
||||
url: `/pages/shop/group-buy-detail/index?groupBuyId=${item.id}`
|
||||
})
|
||||
}
|
||||
|
||||
const getStatusText = (item: ShopGroupBuy) => {
|
||||
const remaining = item.groupSize - item.currentSize
|
||||
if (remaining <= 0) return '已满员'
|
||||
return `还差${remaining}人成团`
|
||||
}
|
||||
|
||||
const getStatusColor = (item: ShopGroupBuy) => {
|
||||
const remaining = item.groupSize - item.currentSize
|
||||
if (remaining <= 0) return '#999'
|
||||
return '#ff4d4f'
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='min-h-screen bg-gray-50'>
|
||||
<ScrollView scrollY className='h-screen'>
|
||||
<View className='p-3'>
|
||||
{loading ? (
|
||||
<View className='flex items-center justify-center py-20'>
|
||||
<Text className='text-gray-400 text-sm'>加载中...</Text>
|
||||
</View>
|
||||
) : groupBuyList.length === 0 ? (
|
||||
<View className='pt-20'>
|
||||
<EmptyState text='暂无拼团活动' />
|
||||
</View>
|
||||
) : (
|
||||
<View className="flex flex-col" style={{ gap: '12px' }}>
|
||||
{groupBuyList.map(item => (
|
||||
<View
|
||||
key={item.id}
|
||||
className='bg-white rounded-lg p-3 flex gap-3'
|
||||
onClick={() => handleGroupBuyClick(item)}
|
||||
>
|
||||
<Image
|
||||
className='w-24 h-24 rounded-md bg-gray-100 flex-shrink-0'
|
||||
src={item.goodsImage || item.product?.image || ''}
|
||||
mode='aspectFill'
|
||||
/>
|
||||
<View className='flex-1 flex flex-col justify-between'>
|
||||
<Text className='text-sm text-gray-700 font-medium line-clamp-2'>
|
||||
{item.goodsName || item.product?.name || '商品名称'}
|
||||
</Text>
|
||||
|
||||
<View className='flex items-center gap-2 mt-1'>
|
||||
<View className='bg-red-50 px-2 py-1 rounded'>
|
||||
<Text className='text-xs text-red-500'>{item.groupSize}人团</Text>
|
||||
</View>
|
||||
<Text className='text-xs' style={{ color: getStatusColor(item) }}>
|
||||
{getStatusText(item)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className='flex items-center justify-between mt-1'>
|
||||
<View className='flex items-baseline gap-1'>
|
||||
<Text className='text-lg font-bold text-red-500'>
|
||||
{'\u00A5'}{item.groupPrice}
|
||||
</Text>
|
||||
<Text className='text-xs text-gray-400 line-through'>
|
||||
{'\u00A5'}{item.product?.price || 0}
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
className='px-3 py-1 rounded-full text-white text-xs'
|
||||
style={{ backgroundColor: '#ff4d4f' }}
|
||||
>
|
||||
<Text>去拼团</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default GroupBuyListPage
|
||||
3
src/pages/shop/index.config.ts
Normal file
3
src/pages/shop/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
navigationBarTitleText: '购物商城',
|
||||
}
|
||||
113
src/pages/shop/index.tsx
Normal file
113
src/pages/shop/index.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { View, Text, ScrollView, Image } from '@tarojs/components'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { listShopGoodsCategory } from '@/api/shop/shopGoodsCategory'
|
||||
import { pageShopGoods } from '@/api/shop/shopGoods'
|
||||
import type { ShopGoodsCategory, ShopGoods } from '@/api/shop/shopGoodsCategory/model'
|
||||
import type { ShopGoodsParam } from '@/api/shop/shopGoods/model'
|
||||
import ProductCard from '@/components/common/ProductCard'
|
||||
import EmptyState from '@/components/common/EmptyState'
|
||||
import LoadMore from '@/components/common/LoadMore'
|
||||
|
||||
const categories = [
|
||||
{ id: 0, title: '全部' },
|
||||
{ id: -1, title: '推荐' },
|
||||
]
|
||||
|
||||
const ShopPage: React.FC = () => {
|
||||
const [categoryList, setCategoryList] = useState<ShopGoodsCategory[]>(categories)
|
||||
const [activeCategory, setActiveCategory] = useState(0)
|
||||
const [goodsList, setGoodsList] = useState<ShopGoods[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [page, setPage] = useState(1)
|
||||
const [finished, setFinished] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadCategories()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1)
|
||||
setFinished(false)
|
||||
setGoodsList([])
|
||||
loadGoods(1, activeCategory)
|
||||
}, [activeCategory])
|
||||
|
||||
const loadCategories = async () => {
|
||||
try {
|
||||
const list = await listShopGoodsCategory()
|
||||
if (list && list.length > 0) {
|
||||
setCategoryList([...categories, ...list])
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const loadGoods = async (p: number, categoryId?: number) => {
|
||||
if (loading) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const params: ShopGoodsParam = { page: p, limit: 10, status: 0 }
|
||||
if (categoryId && categoryId > 0) params.categoryId = categoryId
|
||||
if (categoryId === -1) params.recommend = 1
|
||||
const res = await pageShopGoods(params)
|
||||
const newList = res?.list || []
|
||||
if (p === 1) {
|
||||
setGoodsList(newList)
|
||||
} else {
|
||||
setGoodsList(prev => [...prev, ...newList])
|
||||
}
|
||||
setFinished(newList.length < 10)
|
||||
setPage(p)
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
const handleLoadMore = () => {
|
||||
if (!finished && !loading) {
|
||||
loadGoods(page + 1, activeCategory)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='flex min-h-screen bg-gray-50'>
|
||||
{/* 左侧分类栏 */}
|
||||
<ScrollView scrollY className='w-20 bg-gray-100 h-screen'>
|
||||
{categoryList.map(cat => (
|
||||
<View
|
||||
key={cat.categoryId || cat.id}
|
||||
className={`py-3 px-2 text-center text-xs ${
|
||||
(cat.categoryId || cat.id) === activeCategory
|
||||
? 'bg-white text-green-600 font-medium border-l-2 border-green-500'
|
||||
: 'text-gray-600'
|
||||
}`}
|
||||
onClick={() => setActiveCategory(cat.categoryId || cat.id)}
|
||||
>
|
||||
<Text>{cat.title}</Text>
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
|
||||
{/* 右侧商品列表 */}
|
||||
<ScrollView
|
||||
scrollY
|
||||
className='flex-1 h-screen'
|
||||
onScrollToLower={handleLoadMore}
|
||||
lowerThreshold={100}
|
||||
>
|
||||
<View className='p-2'>
|
||||
<View className='grid grid-cols-2 gap-2'>
|
||||
{goodsList.map(item => (
|
||||
<ProductCard key={item.goodsId} product={item} />
|
||||
))}
|
||||
</View>
|
||||
{goodsList.length === 0 && !loading && (
|
||||
<EmptyState text='暂无商品' />
|
||||
)}
|
||||
<LoadMore loading={loading} finished={finished} />
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default ShopPage
|
||||
408
src/pages/shop/product-detail.tsx
Normal file
408
src/pages/shop/product-detail.tsx
Normal file
@@ -0,0 +1,408 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { View, Text, Image, ScrollView, Swiper, SwiperItem, RichText } from '@tarojs/components'
|
||||
import { Tag } from '@nutui/nutui-react-taro'
|
||||
import Taro, { useRouter, useShareAppMessage } from '@tarojs/taro'
|
||||
import { getShopGoods } from '@/api/shop/shopGoods'
|
||||
import {
|
||||
addShopGoodsFavorite,
|
||||
removeShopGoodsFavorite,
|
||||
getShopGoodsFavoriteStatus
|
||||
} from '@/api/shop/shopGoodsFavorite'
|
||||
import type { ShopGoods, ShopGoodsSku } from '@/api/shop/shopGoods/model'
|
||||
import Price from '@/components/common/Price'
|
||||
import SkuSelector from '@/components/business/SkuSelector'
|
||||
import { useCartContext } from '@/contexts/CartContext'
|
||||
import { useUserContext } from '@/contexts/UserContext'
|
||||
import { useScrollHeight } from '@/hooks/useScrollHeight'
|
||||
|
||||
definePageConfig({
|
||||
navigationBarTitleText: '商品详情',
|
||||
})
|
||||
|
||||
const ProductDetailPage: React.FC = () => {
|
||||
const router = useRouter()
|
||||
const id = Number(router.params.id || router.params.goodsId)
|
||||
const [product, setProduct] = useState<ShopGoods | null>(null)
|
||||
const [skuVisible, setSkuVisible] = useState(false)
|
||||
const [skuMode, setSkuMode] = useState<'cart' | 'buy'>('cart')
|
||||
const [isFavorite, setIsFavorite] = useState(false)
|
||||
const { addItem } = useCartContext()
|
||||
const { isLoggedIn } = useUserContext()
|
||||
// 底部操作栏高度约 90px(含按钮 + 安全区),动态计算 ScrollView 可用高度
|
||||
const scrollHeight = useScrollHeight(44)
|
||||
|
||||
// 微信分享配置
|
||||
useShareAppMessage(() => {
|
||||
return {
|
||||
title: product?.name || product?.goodsName || '优质商品推荐',
|
||||
path: `/pages/shop/product-detail?id=${id}`,
|
||||
imageUrl: product?.image || ''
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
loadProduct()
|
||||
if (isLoggedIn) {
|
||||
checkFavoriteStatus()
|
||||
}
|
||||
}
|
||||
}, [id, isLoggedIn])
|
||||
|
||||
// 监听 product 变化后再添加到历史记录
|
||||
useEffect(() => {
|
||||
if (product) {
|
||||
addToHistory()
|
||||
}
|
||||
}, [product])
|
||||
|
||||
const loadProduct = async () => {
|
||||
try {
|
||||
const data = await getShopGoods(id)
|
||||
setProduct(data)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const checkFavoriteStatus = async () => {
|
||||
try {
|
||||
const status = await getShopGoodsFavoriteStatus({ goodsId: id })
|
||||
setIsFavorite(status)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const toggleFavorite = async () => {
|
||||
if (!isLoggedIn) {
|
||||
Taro.showToast({ title: '请先登录', icon: 'none' })
|
||||
setTimeout(() => {
|
||||
Taro.navigateTo({ url: '/pages/passport/login' })
|
||||
}, 1500)
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (isFavorite) {
|
||||
await removeShopGoodsFavorite({ goodsId: id })
|
||||
setIsFavorite(false)
|
||||
Taro.showToast({ title: '已取消收藏', icon: 'success' })
|
||||
} else {
|
||||
await addShopGoodsFavorite({ goodsId: id })
|
||||
setIsFavorite(true)
|
||||
Taro.showToast({ title: '收藏成功', icon: 'success' })
|
||||
}
|
||||
} catch {
|
||||
Taro.showToast({ title: '操作失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
const addToHistory = () => {
|
||||
try {
|
||||
const history = Taro.getStorageSync('browse_history') || []
|
||||
const newItem = {
|
||||
goodsId: id,
|
||||
name: product?.name || product?.goodsName,
|
||||
image: product?.image,
|
||||
price: product?.price,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
// 去重 + LRU 淘汰(最多50条,单条约200B,总计 < 10MB 安全范围)
|
||||
const MAX_HISTORY = 50
|
||||
const filtered = history.filter((item: { goodsId: number }) => item.goodsId !== id)
|
||||
filtered.unshift(newItem)
|
||||
const limited = filtered.slice(0, MAX_HISTORY)
|
||||
|
||||
// 安全写入(捕获存储溢出)
|
||||
try {
|
||||
Taro.setStorageSync('browse_history', limited)
|
||||
} catch (storageErr) {
|
||||
// 存储空间不足时,缩减到一半再试
|
||||
const reduced = limited.slice(0, Math.floor(MAX_HISTORY / 2))
|
||||
Taro.setStorageSync('browse_history', reduced)
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const handleAddCart = () => {
|
||||
if (!isLoggedIn) {
|
||||
Taro.showToast({ title: '请先登录', icon: 'none' })
|
||||
setTimeout(() => {
|
||||
Taro.navigateTo({ url: '/pages/passport/login' })
|
||||
}, 1500)
|
||||
return
|
||||
}
|
||||
setSkuMode('cart')
|
||||
setSkuVisible(true)
|
||||
}
|
||||
|
||||
const handleBuyNow = () => {
|
||||
if (!isLoggedIn) {
|
||||
Taro.showToast({ title: '请先登录', icon: 'none' })
|
||||
setTimeout(() => {
|
||||
Taro.navigateTo({ url: '/pages/passport/login' })
|
||||
}, 1500)
|
||||
return
|
||||
}
|
||||
setSkuMode('buy')
|
||||
setSkuVisible(true)
|
||||
}
|
||||
|
||||
const handleSkuConfirm = async (sku: ShopGoodsSku | null, quantity: number) => {
|
||||
if (!product) {
|
||||
Taro.showToast({ title: '商品信息异常', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
if (skuMode === 'cart') {
|
||||
try {
|
||||
await addItem(product, sku, quantity)
|
||||
} catch (err) {
|
||||
console.error('[ProductDetail] 加入购物车失败:', err)
|
||||
}
|
||||
} else {
|
||||
const buyNowData = [{
|
||||
goodsId: product.goodsId!,
|
||||
skuId: sku?.id || 0,
|
||||
quantity: quantity,
|
||||
num: quantity,
|
||||
product: product,
|
||||
sku: sku,
|
||||
checked: true,
|
||||
}]
|
||||
|
||||
Taro.setStorageSync('buy_now', JSON.stringify(buyNowData))
|
||||
Taro.navigateTo({ url: '/pages/shop/checkout?from=buyNow' })
|
||||
}
|
||||
}
|
||||
|
||||
const handleGoCart = () => {
|
||||
Taro.switchTab({ url: '/pages/shop/cart/index' })
|
||||
}
|
||||
|
||||
const handleContactService = () => {
|
||||
Taro.showToast({ title: '客服功能开发中', icon: 'none' })
|
||||
}
|
||||
|
||||
if (!product) {
|
||||
return (
|
||||
<View className='min-h-screen bg-white flex items-center justify-center'>
|
||||
<Text className='text-gray-400 text-sm'>加载中...</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
let images: string[] = []
|
||||
try {
|
||||
if (product.files) {
|
||||
const parsed = JSON.parse(product.files)
|
||||
if (Array.isArray(parsed)) {
|
||||
images = parsed.map((f: any) => f.url || f).filter(Boolean)
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
if (images.length === 0 && product.image) {
|
||||
images = [product.image]
|
||||
}
|
||||
|
||||
// 解析服务保障标签
|
||||
const ensureTags = product.ensureTag ? product.ensureTag.split(/[,,、]/).filter(Boolean) : []
|
||||
|
||||
// 配送方式文案
|
||||
const deliveryText = product.deliveryMode === 1 ? '限自提' : '送上门'
|
||||
|
||||
return (
|
||||
<View className='flex flex-col bg-gray-50' style={{ height: '100vh' }}>
|
||||
<ScrollView scrollY style={{ height: scrollHeight }}>
|
||||
|
||||
{/* 图片轮播 */}
|
||||
<Swiper
|
||||
className='w-full'
|
||||
style={{ height: '375px' }}
|
||||
indicatorDots
|
||||
indicatorColor='#e5e7eb'
|
||||
indicatorActiveColor='#0e932e'
|
||||
autoplay
|
||||
circular
|
||||
>
|
||||
{images.map((img, idx) => (
|
||||
<SwiperItem key={idx}>
|
||||
<Image className='w-full h-full' src={img} mode='aspectFill' />
|
||||
</SwiperItem>
|
||||
))}
|
||||
{images.length === 0 && (
|
||||
<SwiperItem>
|
||||
<View className='w-full h-full bg-gray-100 flex items-center justify-center'>
|
||||
<Text className='text-gray-300 text-sm'>暂无图片</Text>
|
||||
</View>
|
||||
</SwiperItem>
|
||||
)}
|
||||
</Swiper>
|
||||
|
||||
{/* 价格区域 */}
|
||||
<View className='bg-white p-4'>
|
||||
<View className='flex items-baseline gap-2'>
|
||||
<Price price={product.price || '0'} size='large' color='#ee0a24' />
|
||||
<Tag>到手价</Tag>
|
||||
{product.salePrice && product.salePrice !== product.price && (
|
||||
<Text className='text-xs text-gray-400 ml-2'>¥{product.salePrice}</Text>
|
||||
)}
|
||||
</View>
|
||||
{/* 会员价 */}
|
||||
{product.memberStorePrice && product.memberStorePrice !== product.price && (
|
||||
<View className='mt-2 inline-block bg-orange-50 rounded px-2 py-1'>
|
||||
<Text className='text-xs text-orange-500'>会员价: ¥{product.memberStorePrice}</Text>
|
||||
</View>
|
||||
)}
|
||||
{/* 赚取积分 */}
|
||||
{product.gainIntegral && Number(product.gainIntegral) > 0 && (
|
||||
<View className='mt-1'>
|
||||
<Text className='text-xs text-orange-500'>购买可得 {product.gainIntegral} 积分</Text>
|
||||
</View>
|
||||
)}
|
||||
<View className='flex gap-2 mt-2'>
|
||||
<Text className='text-xs text-gray-400'>销量: {product.sales || 0}</Text>
|
||||
<Text className='text-xs text-gray-400'>库存: {product.stock || 0}</Text>
|
||||
{product.unitName && (
|
||||
<Text className='text-xs text-gray-400'>单位: {product.unitName}</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 商品名称 */}
|
||||
<View className='bg-white px-4 pb-3'>
|
||||
<Text className='text-base font-medium text-gray-800 block leading-6'>
|
||||
{product.name || product.goodsName}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* 分类面包屑 */}
|
||||
{(product.categoryParent || product.categoryName) && (
|
||||
<View className='bg-white px-4 pb-3'>
|
||||
<Text className='text-xs text-gray-400'>
|
||||
{product.categoryParent ? `${product.categoryParent}` : ''}
|
||||
{product.categoryParent && product.categoryName ? ' > ' : ''}
|
||||
{product.categoryName ? `${product.categoryName}` : ''}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* SKU 已选提示(多规格商品) */}
|
||||
{product.specs === 1 && (
|
||||
<View
|
||||
className='bg-white mt-2 px-4 py-3 flex items-center justify-between'
|
||||
onClick={() => {
|
||||
setSkuMode('cart')
|
||||
setSkuVisible(true)
|
||||
}}
|
||||
>
|
||||
<View className='flex items-center'>
|
||||
<Text className='text-xs text-gray-500 mr-2'>已选</Text>
|
||||
<Text className='text-sm text-gray-700'>{product.specName || '请选择规格'}</Text>
|
||||
</View>
|
||||
<Text className='text-gray-300 text-sm'>❯</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 配送信息 */}
|
||||
<View className='bg-white mt-2 px-4 py-3 flex items-center justify-between'>
|
||||
<View className='flex items-center'>
|
||||
<Text className='text-xs text-gray-500 mr-2'>配送</Text>
|
||||
<Text className='text-sm text-gray-700'>{deliveryText}</Text>
|
||||
{product.goodsWeight && Number(product.goodsWeight) > 0 && (
|
||||
<Text className='text-xs text-gray-400 ml-2'>重量: {product.goodsWeight}kg</Text>
|
||||
)}
|
||||
</View>
|
||||
<Text className='text-gray-300 text-sm'>❯</Text>
|
||||
</View>
|
||||
|
||||
{/* 服务保障 */}
|
||||
{ensureTags.length > 0 && (
|
||||
<View className='bg-white mt-2 px-4 py-3'>
|
||||
<View className='flex items-center'>
|
||||
<Text className='text-xs text-gray-500 mr-2'>服务</Text>
|
||||
<View className='flex flex-wrap gap-2'>
|
||||
{ensureTags.map((tag, idx) => (
|
||||
<View key={idx} className='flex items-center'>
|
||||
<Text className='text-xs text-gray-600'>{tag}</Text>
|
||||
{idx < ensureTags.length - 1 && (
|
||||
<Text className='text-xs text-gray-300 ml-2'>|</Text>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 商品详情 */}
|
||||
<View className='bg-white mt-2 p-4'>
|
||||
<Text className='text-base font-medium text-gray-800 mb-3 block'>商品详情</Text>
|
||||
<View className='text-sm text-gray-600 leading-6'>
|
||||
{product.content ? (
|
||||
<RichText nodes={product.content} />
|
||||
) : (
|
||||
<Text className='text-gray-400'>暂无详情</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 底部留白(与固定栏同高 + 安全区,确保内容不被遮挡) */}
|
||||
<View style={{ height: '100px' }} />
|
||||
</ScrollView>
|
||||
|
||||
{/* 底部操作栏 - 固定吸底 */}
|
||||
<View
|
||||
className='bg-white border-t border-gray-100 flex items-center'
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
paddingBottom: 'env(safe-area-inset-bottom)',
|
||||
zIndex: 100,
|
||||
}}
|
||||
>
|
||||
{/* 图标入口 */}
|
||||
<View className='flex items-center px-2 py-2' style={{ minWidth: '120px' }}>
|
||||
<View className='flex-1 flex flex-col items-center' onClick={handleGoCart}>
|
||||
<Text className='text-lg'>🛒</Text>
|
||||
<Text className='text-xs text-gray-500 mt-1 whitespace-nowrap'>购物车</Text>
|
||||
</View>
|
||||
<View className='flex-1 flex flex-col items-center' onClick={handleContactService}>
|
||||
<Text className='text-lg'>💬</Text>
|
||||
<Text className='text-xs text-gray-500 mt-1'>客服</Text>
|
||||
</View>
|
||||
<View className='flex-1 flex flex-col items-center' onClick={toggleFavorite}>
|
||||
<Text className='text-lg'>{isFavorite ? '❤️' : '🤍'}</Text>
|
||||
<Text className='text-xs text-gray-500 mt-1'>收藏</Text>
|
||||
</View>
|
||||
</View>
|
||||
{/* 操作按钮 */}
|
||||
<View className='flex-1 flex gap-2 pr-3 py-2'>
|
||||
<View
|
||||
className='flex-1 py-2 rounded-full text-center'
|
||||
style={{ backgroundColor: '#ff9800' }}
|
||||
onClick={handleAddCart}
|
||||
>
|
||||
<Text className='text-white text-sm font-medium'>加入购物车</Text>
|
||||
</View>
|
||||
<View
|
||||
className='flex-1 py-2 rounded-full text-center'
|
||||
style={{ backgroundColor: '#ee0a24' }}
|
||||
onClick={handleBuyNow}
|
||||
>
|
||||
<Text className='text-white text-sm font-medium'>立即购买</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* SKU 选择器 */}
|
||||
<SkuSelector
|
||||
visible={skuVisible}
|
||||
product={product}
|
||||
mode={skuMode}
|
||||
onClose={() => setSkuVisible(false)}
|
||||
onConfirm={handleSkuConfirm}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProductDetailPage
|
||||
3
src/pages/shop/seckill-detail/index.config.ts
Normal file
3
src/pages/shop/seckill-detail/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
navigationBarTitleText: '秒杀详情',
|
||||
}
|
||||
279
src/pages/shop/seckill-detail/index.tsx
Normal file
279
src/pages/shop/seckill-detail/index.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { View, Text, Image, ScrollView } from '@tarojs/components'
|
||||
import Taro, { useRouter } from '@tarojs/taro'
|
||||
import { getShopSeckill, createSeckillOrder } from '@/api/shop/shopSeckill'
|
||||
import { getShopGoods } from '@/api/shop/shopGoods'
|
||||
import type { ShopSeckill } from '@/api/shop/shopSeckill/model'
|
||||
import type { ShopGoods } from '@/api/shop/shopGoods/model'
|
||||
import type { ShopGoodsSku } from '@/api/shop/shopGoodsSku/model'
|
||||
import SkuSelector from '@/components/business/SkuSelector'
|
||||
|
||||
definePageConfig({
|
||||
navigationBarTitleText: '秒杀详情',
|
||||
})
|
||||
|
||||
const SeckillDetailPage: React.FC = () => {
|
||||
const router = useRouter()
|
||||
const seckillId = Number(router.params.seckillId || 0)
|
||||
|
||||
const [seckill, setSeckill] = useState<ShopSeckill | null>(null)
|
||||
const [product, setProduct] = useState<ShopGoods | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [currentTime, setCurrentTime] = useState(Date.now())
|
||||
const [countdown, setCountdown] = useState('')
|
||||
const [skuVisible, setSkuVisible] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (seckillId) {
|
||||
fetchData()
|
||||
}
|
||||
|
||||
const timer = setInterval(() => {
|
||||
setCurrentTime(Date.now())
|
||||
}, 1000)
|
||||
|
||||
return () => clearInterval(timer)
|
||||
}, [seckillId])
|
||||
|
||||
useEffect(() => {
|
||||
if (seckill) {
|
||||
updateCountdown()
|
||||
}
|
||||
}, [currentTime, seckill])
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const res = await getShopSeckill(seckillId)
|
||||
if (res.code === 0 && res.data) {
|
||||
const data = res.data
|
||||
setSeckill(data)
|
||||
|
||||
// 加载商品完整信息(含规格/SKU)
|
||||
if (data.goodsId) {
|
||||
fetchGoodsDetail(data.goodsId)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取秒杀详情失败', err)
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchGoodsDetail = async (goodsId: number) => {
|
||||
try {
|
||||
const res = await getShopGoods(goodsId)
|
||||
if (res.code === 0 && res.data) {
|
||||
setProduct(res.data)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取商品详情失败', err)
|
||||
}
|
||||
}
|
||||
|
||||
const updateCountdown = () => {
|
||||
if (!seckill?.endTime) return
|
||||
const end = new Date(seckill.endTime).getTime()
|
||||
const diff = end - currentTime
|
||||
|
||||
if (diff <= 0) {
|
||||
setCountdown('已结束')
|
||||
return
|
||||
}
|
||||
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60))
|
||||
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))
|
||||
const seconds = Math.floor((diff % (1000 * 60)) / 1000)
|
||||
|
||||
setCountdown(`${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`)
|
||||
}
|
||||
|
||||
const handleBuyNow = () => {
|
||||
if (!seckill) return
|
||||
|
||||
if (seckill.stock <= 0) {
|
||||
Taro.showToast({ title: '已抢光', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
// 先拉取商品规格(如果没有的话)
|
||||
if (!product && seckill.goodsId) {
|
||||
Taro.showLoading({ title: '加载规格...' })
|
||||
getShopGoods(seckill.goodsId)
|
||||
.then(res => {
|
||||
Taro.hideLoading()
|
||||
if (res.code === 0 && res.data) {
|
||||
setProduct(res.data)
|
||||
setSkuVisible(true)
|
||||
} else {
|
||||
// 没有规格,直接下单
|
||||
handleSeckillOrder(0, 1)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
Taro.hideLoading()
|
||||
handleSeckillOrder(0, 1)
|
||||
})
|
||||
} else {
|
||||
setSkuVisible(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSeckillOrder = async (skuId: number, quantity: number) => {
|
||||
if (!seckill) return
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const res = await createSeckillOrder({
|
||||
seckillId: seckill.id!,
|
||||
goodsId: seckill.goodsId,
|
||||
skuId,
|
||||
quantity,
|
||||
})
|
||||
|
||||
if (res.code === 0) {
|
||||
Taro.showToast({ title: '抢购成功', icon: 'success' })
|
||||
fetchData() // 刷新库存
|
||||
}
|
||||
} catch (err: any) {
|
||||
Taro.showToast({ title: err?.message || '抢购失败', icon: 'none' })
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSkuConfirm = (sku: ShopGoodsSku, quantity: number) => {
|
||||
handleSeckillOrder(sku.id || 0, quantity)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<Text className="text-gray-400 text-sm">加载中...</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
if (!seckill) {
|
||||
return (
|
||||
<View className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<Text className="text-gray-400 text-sm">秒杀活动不存在</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const progress = seckill.stock === 0 ? 100 : Math.round((seckill.soldCount / (seckill.soldCount + seckill.stock)) * 100)
|
||||
const isActive = seckill.status === 1 && seckill.stock > 0
|
||||
|
||||
return (
|
||||
<View className="min-h-screen bg-gray-50 pb-20">
|
||||
<ScrollView scrollY className="h-screen">
|
||||
{/* 商品图片 */}
|
||||
<View className="w-full h-72 bg-gray-100 flex items-center justify-center">
|
||||
{seckill.goodsImage || seckill.product?.image ? (
|
||||
<Image
|
||||
className="w-full h-full"
|
||||
src={seckill.goodsImage || seckill.product?.image}
|
||||
mode="aspectFill"
|
||||
/>
|
||||
) : (
|
||||
<Text className="text-gray-400">暂无图片</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 价格和倒计时 */}
|
||||
<View className="px-4 py-4" style={{ background: 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)' }}>
|
||||
<View className="flex items-baseline gap-2">
|
||||
<Text className="text-3xl font-bold text-white">
|
||||
¥{seckill.seckillPrice}
|
||||
</Text>
|
||||
<Text className="text-sm text-red-200 line-through">
|
||||
¥{seckill.product?.price || 0}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex items-center justify-between mt-3">
|
||||
<View className="bg-white rounded px-2 py-1">
|
||||
<Text className="text-red-500 text-sm font-bold">{countdown}</Text>
|
||||
</View>
|
||||
<Text className="text-white text-xs">距结束</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 商品信息 */}
|
||||
<View className="bg-white p-4">
|
||||
<Text className="text-base text-gray-800 font-medium block">
|
||||
{seckill.goodsName || seckill.product?.name || '商品名称'}
|
||||
</Text>
|
||||
<View className="flex items-center gap-2 mt-2">
|
||||
<View className="px-2 py-1 rounded text-xs" style={{ backgroundColor: '#fef2f2', color: '#ef4444' }}>
|
||||
限购{seckill.limitPerUser}件
|
||||
</View>
|
||||
<Text className="text-xs text-gray-400">
|
||||
已抢 {progress}%
|
||||
</Text>
|
||||
{isActive && <Text className="text-xs text-red-500 font-medium">进行中</Text>}
|
||||
{!isActive && <Text className="text-xs text-gray-400">{seckill.status === 0 ? '未开始' : '已结束'}</Text>}
|
||||
</View>
|
||||
<View className="mt-3 bg-gray-100 rounded-full h-2 overflow-hidden">
|
||||
<View
|
||||
className="h-full rounded-full"
|
||||
style={{
|
||||
width: `${progress}%`,
|
||||
backgroundColor: '#ef4444',
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
<View className="mt-2 flex justify-between">
|
||||
<Text className="text-xs text-gray-400">剩余 {seckill.stock} 件</Text>
|
||||
<Text className="text-xs text-gray-400">已售 {seckill.soldCount} 件</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 抢购提示 */}
|
||||
<View className="bg-white mt-3 p-4">
|
||||
<Text className="text-sm font-medium text-gray-800 mb-2 block">活动规则</Text>
|
||||
<View className="flex flex-col" style={{ gap: '6px' }}>
|
||||
<Text className="text-sm text-gray-500">• 秒杀商品限购{seckill.limitPerUser}件/人</Text>
|
||||
<Text className="text-sm text-gray-500">• 秒杀商品不支持退款</Text>
|
||||
<Text className="text-sm text-gray-500">• 请在15分钟内完成支付,逾期订单自动取消</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 商品详情 */}
|
||||
{seckill.product?.content && (
|
||||
<View className="bg-white mt-3 p-4">
|
||||
<Text className="text-sm font-medium text-gray-800 mb-3 block">商品详情</Text>
|
||||
<View dangerouslySetInnerHTML={{ __html: seckill.product.content }} />
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
{/* 底部抢购按钮 */}
|
||||
<View className="bg-white border-t border-gray-100 px-4 py-3">
|
||||
<View
|
||||
className="w-full py-3 rounded-full text-white text-center text-base font-medium"
|
||||
style={{ backgroundColor: isActive ? '#ef4444' : '#d1d5db' }}
|
||||
onClick={isActive && !submitting ? handleBuyNow : undefined}
|
||||
>
|
||||
<Text>
|
||||
{submitting ? '抢购中...' : isActive ? (seckill.stock > 0 ? '立即抢购' : '已抢光') : seckill.status === 0 ? '即将开始' : '已结束'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* SKU 选择器 */}
|
||||
<SkuSelector
|
||||
visible={skuVisible}
|
||||
product={product}
|
||||
mode="buy"
|
||||
onClose={() => setSkuVisible(false)}
|
||||
onConfirm={handleSkuConfirm}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default SeckillDetailPage
|
||||
3
src/pages/shop/seckill-list/index.config.ts
Normal file
3
src/pages/shop/seckill-list/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
navigationBarTitleText: '限时秒杀',
|
||||
}
|
||||
149
src/pages/shop/seckill-list/index.tsx
Normal file
149
src/pages/shop/seckill-list/index.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { View, Text, Image, ScrollView } from '@tarojs/components'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { pageShopSeckill } from '@/api/shop/shopSeckill'
|
||||
import type { ShopSeckill } from '@/api/shop/shopSeckill/model'
|
||||
import EmptyState from '@/components/common/EmptyState'
|
||||
|
||||
definePageConfig({
|
||||
navigationBarTitleText: '限时秒杀',
|
||||
})
|
||||
|
||||
const SeckillListPage: React.FC = () => {
|
||||
const [seckillList, setSeckillList] = useState<ShopSeckill[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [currentTime, setCurrentTime] = useState(Date.now())
|
||||
|
||||
useEffect(() => {
|
||||
fetchSeckillList()
|
||||
// 每秒更新当前时间(用于倒计时)
|
||||
const timer = setInterval(() => {
|
||||
setCurrentTime(Date.now())
|
||||
}, 1000)
|
||||
return () => clearInterval(timer)
|
||||
}, [])
|
||||
|
||||
const fetchSeckillList = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const res = await pageShopSeckill({ page: 1, pageSize: 20, status: 1 })
|
||||
if (res.code === 0 && res.data) {
|
||||
setSeckillList(res.data.items || [])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取秒杀列表失败', err)
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSeckillClick = (item: ShopSeckill) => {
|
||||
Taro.navigateTo({
|
||||
url: `/pages/shop/seckill-detail/index?seckillId=${item.id}`
|
||||
})
|
||||
}
|
||||
|
||||
const getCountdown = (endTime: string) => {
|
||||
const end = new Date(endTime).getTime()
|
||||
const diff = end - currentTime
|
||||
if (diff <= 0) return '已结束'
|
||||
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60))
|
||||
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))
|
||||
const seconds = Math.floor((diff % (1000 * 60)) / 1000)
|
||||
|
||||
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const getProgress = (item: ShopSeckill) => {
|
||||
if (item.stock === 0) return 100
|
||||
return Math.round((item.soldCount / (item.soldCount + item.stock)) * 100)
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='min-h-screen bg-gray-50'>
|
||||
{/* 顶部倒计时横幅 */}
|
||||
<View className='bg-red-500 px-3 py-2 flex items-center justify-between'>
|
||||
<Text className='text-white text-sm font-medium'>限时秒杀</Text>
|
||||
<View className='flex items-center gap-1'>
|
||||
<Text className='text-white text-xs'>距结束</Text>
|
||||
<View className='bg-white rounded px-1 py-0'>
|
||||
<Text className='text-red-500 text-xs font-bold'>--:--:--</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<ScrollView scrollY className='h-screen'>
|
||||
<View className='p-3'>
|
||||
{loading ? (
|
||||
<View className='flex items-center justify-center py-20'>
|
||||
<Text className='text-gray-400 text-sm'>加载中...</Text>
|
||||
</View>
|
||||
) : seckillList.length === 0 ? (
|
||||
<View className='pt-20'>
|
||||
<EmptyState text='暂无秒杀活动' />
|
||||
</View>
|
||||
) : (
|
||||
<View className="flex flex-col" style={{ gap: '12px' }}>
|
||||
{seckillList.map(item => (
|
||||
<View
|
||||
key={item.id}
|
||||
className='bg-white rounded-lg p-3 flex gap-3'
|
||||
onClick={() => handleSeckillClick(item)}
|
||||
>
|
||||
<Image
|
||||
className='w-24 h-24 rounded-md bg-gray-100 flex-shrink-0'
|
||||
src={item.goodsImage || item.product?.image || ''}
|
||||
mode='aspectFill'
|
||||
/>
|
||||
<View className='flex-1 flex flex-col justify-between'>
|
||||
<Text className='text-sm text-gray-700 font-medium line-clamp-2'>
|
||||
{item.goodsName || item.product?.name || '商品名称'}
|
||||
</Text>
|
||||
|
||||
{/* 进度条 */}
|
||||
<View className='mt-1'>
|
||||
<View className='bg-gray-100 rounded-full overflow-hidden' style={{ height: '6px' }}>
|
||||
<View
|
||||
className='rounded-full'
|
||||
style={{
|
||||
width: `${getProgress(item)}%`,
|
||||
height: '100%',
|
||||
backgroundColor: '#ff4d4f'
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
<Text className='text-xs text-gray-400 block' style={{ marginTop: '2px' }}>
|
||||
已售{getProgress(item)}%
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className='flex items-center justify-between mt-1'>
|
||||
<View className='flex items-baseline gap-1'>
|
||||
<Text className='text-lg font-bold text-red-500'>
|
||||
{'\u00A5'}{item.seckillPrice}
|
||||
</Text>
|
||||
<Text className='text-xs text-gray-400 line-through'>
|
||||
{'\u00A5'}{item.product?.price || 0}
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
className='px-3 py-1 rounded-full text-white text-xs'
|
||||
style={{ backgroundColor: item.stock > 0 ? '#ff4d4f' : '#999' }}
|
||||
>
|
||||
<Text>{item.stock > 0 ? '立即抢购' : '已抢光'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default SeckillListPage
|
||||
Reference in New Issue
Block a user