- 删除 NutUI Address 相关导入、状态及地区数据转换逻辑 - 新增派生 regionValue 用于绑定微信原生 Picker 的 value - 将 handleRegionChange 调整为处理微信 Picker 的事件 - 将所在地选择 UI 修改为微信原生 Picker 包裹的视图结构 - 地图定位失败使用弹窗提示,引导用户手动选择或重试 - 移除手动打开地区选择器的函数及 NutUI 组件 - 保留 RegionData 用于文本智能识别解析 - 编译通过,提升在无地图或无权限环境下的地区选择稳定性
356 lines
12 KiB
TypeScript
356 lines
12 KiB
TypeScript
import React, { useEffect, useState, useRef } 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'
|
||
import { getCompressedImageUrl } from '@/utils/image'
|
||
import { useShare } from '@/hooks/useShare'
|
||
import SharePoster, { SharePosterHandle } from '@/components/SharePoster'
|
||
|
||
definePageConfig({
|
||
navigationBarTitleText: '拼团详情',
|
||
enableShareAppMessage: true,
|
||
enableShareTimeline: true,
|
||
})
|
||
|
||
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)
|
||
|
||
// 分享 / 朋友圈
|
||
const posterRef = useRef<SharePosterHandle>(null)
|
||
const [posterPath, setPosterPath] = useState('')
|
||
useShare({
|
||
title: groupBuy?.goodsName || groupBuy?.product?.name || '拼团商品推荐',
|
||
path: `/pages/shop/group-buy-detail?groupBuyId=${groupBuyId}`,
|
||
query: `groupBuyId=${groupBuyId}`,
|
||
imageUrl: posterPath || groupBuy?.goodsImage || groupBuy?.product?.image || undefined,
|
||
})
|
||
|
||
// 数据加载完成后异步生成分享海报(带小程序码)
|
||
useEffect(() => {
|
||
if (groupBuy) {
|
||
posterRef.current
|
||
?.generate({
|
||
cover: groupBuy.goodsImage || groupBuy.product?.image,
|
||
title: groupBuy.goodsName || groupBuy.product?.name || '拼团商品',
|
||
price: groupBuy.groupPrice,
|
||
page: 'pages/shop/group-buy-detail',
|
||
})
|
||
.then(setPosterPath)
|
||
.catch(() => {})
|
||
}
|
||
}, [groupBuy])
|
||
|
||
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={getCompressedImageUrl(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}
|
||
/>
|
||
{/* 分享海报画布(离屏,用于生成带小程序码的海报图) */}
|
||
<SharePoster ref={posterRef} />
|
||
</View>
|
||
)
|
||
}
|
||
|
||
export default GroupBuyDetailPage
|