Files
xinlong-shop-taro/src/pages/shop/group-buy-detail/index.tsx
赵忠林 d6d6b1f5a7 feat(user): 使用微信原生 Picker 替换所在地选择弹层
- 删除 NutUI Address 相关导入、状态及地区数据转换逻辑
- 新增派生 regionValue 用于绑定微信原生 Picker 的 value
- 将 handleRegionChange 调整为处理微信 Picker 的事件
- 将所在地选择 UI 修改为微信原生 Picker 包裹的视图结构
- 地图定位失败使用弹窗提示,引导用户手动选择或重试
- 移除手动打开地区选择器的函数及 NutUI 组件
- 保留 RegionData 用于文本智能识别解析
- 编译通过,提升在无地图或无权限环境下的地区选择稳定性
2026-07-13 08:16:17 +08:00

356 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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