fix(api): 替换所有接口请求的域名为 guilixu-api.websoft.top

- 将获取 STS Token 的接口地址由 paopao-api 改为 guilixu-api
- 更新图片上传接口地址为新的 guilixu-api 域名
- 修改用户推广页面中邀请码链接和二维码接口的域名
- 更改注册页微信登录接口请求的域名为 guilixu-api
This commit is contained in:
2026-06-16 17:15:59 +08:00
commit f3886664f7
617 changed files with 77059 additions and 0 deletions

231
src/pages/shop/cart.tsx Normal file
View 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

View 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
View 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

View File

@@ -0,0 +1,3 @@
export default {
navigationBarTitleText: '拼团详情',
}

View 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

View 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

View File

@@ -0,0 +1,3 @@
export default {
navigationBarTitleText: '购物商城',
}

113
src/pages/shop/index.tsx Normal file
View 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

View 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'>&#10095;</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'>&#10095;</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

View File

@@ -0,0 +1,3 @@
export default {
navigationBarTitleText: '秒杀详情',
}

View 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

View File

@@ -0,0 +1,3 @@
export default {
navigationBarTitleText: '限时秒杀',
}

View 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