Files
xinlong-shop-taro/src/pages/shop/product-detail.tsx
赵忠林 44b3cb10be refactor(shop): 简化商品详情页客服功能
- 用户反馈客服功能复杂,改为使用微信原生 open-type="contact" 按钮
- 删除 handleContactService 函数及页面跳转逻辑
- 替换客服图标组件为 Button,使用 openType="contact" 触发联系客服
- 保留客服页面但不再从商品详情页跳转
- 提示 open-type="contact" 需真机预览测试,开发者工具无反应
2026-07-14 21:03:26 +08:00

509 lines
18 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, { useState, useEffect, useRef } from 'react'
import { View, Text, Image, ScrollView, Swiper, SwiperItem, RichText, Button } from '@tarojs/components'
import { Tag } from '@nutui/nutui-react-taro'
import Taro, { useRouter, useDidShow } 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'
import { useShare } from '@/hooks/useShare'
import SharePoster, { SharePosterHandle } from '@/components/SharePoster'
import { isGuest } from '@/utils/auth'
import { requireLogin } from '@/utils/login-guard'
import { isVipMember } from '@/utils/vip'
definePageConfig({
navigationBarTitleText: '商品详情',
enableShareAppMessage: true,
enableShareTimeline: true,
})
const ProductDetailPage: React.FC = () => {
const router = useRouter()
const id = Number(router.params.id || router.params.goodsId)
const [product, setProduct] = useState<ShopGoods | null>(null)
const [swiperHeight, setSwiperHeight] = useState(375)
const [skuVisible, setSkuVisible] = useState(false)
const [skuMode, setSkuMode] = useState<'cart' | 'buy'>('cart')
const [isFavorite, setIsFavorite] = useState(false)
const { addItem, totalCount, refresh } = useCartContext()
const { isLoggedIn } = useUserContext()
// 底部操作栏高度约 90px含按钮 + 安全区),动态计算 ScrollView 可用高度
const scrollHeight = useScrollHeight(44)
// 分享 / 朋友圈
const posterRef = useRef<SharePosterHandle>(null)
const [posterPath, setPosterPath] = useState('')
useShare({
title: product?.name || product?.goodsName || '优质商品推荐',
path: `/pages/shop/product-detail?id=${id}`,
query: `id=${id}`,
imageUrl: posterPath || product?.image || undefined,
})
useEffect(() => {
if (id) {
loadProduct()
if (isLoggedIn) {
checkFavoriteStatus()
}
}
}, [id, isLoggedIn])
// 页面显示时刷新购物车数量,确保底部购物车角标准确
useDidShow(() => {
if (isLoggedIn) refresh()
})
// 监听 product 变化后再添加到历史记录,并异步生成分享海报
useEffect(() => {
if (product) {
addToHistory()
posterRef.current
?.generate({
cover: product.image,
title: product.name || product.goodsName || '优质商品推荐',
price:
product.dealerPrice && isVipMember() ? product.dealerPrice : product.price,
page: 'pages/shop/product-detail',
})
.then(setPosterPath)
.catch(() => {})
}
}, [product])
const loadProduct = async () => {
try {
const data = await getShopGoods(id)
setProduct(data)
} catch { /* ignore */ }
}
// VIP 会员显示 dealerPrice普通用户显示 price
const getDisplayPrice = (): string => {
if (isVipMember() && product?.dealerPrice) {
return product.dealerPrice
}
return product?.price || '0'
}
const checkFavoriteStatus = async () => {
try {
const status = await getShopGoodsFavoriteStatus({ goodsId: id })
setIsFavorite(status)
} catch { /* ignore */ }
}
const toggleFavorite = async () => {
if (!requireLogin({ action: 'favorite', redirect: `/pages/shop/product-detail?id=${id}` })) 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 (e: any) {
Taro.showToast({ title: e?.message || '操作失败', 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 (!requireLogin({ action: 'addToCart', redirect: `/pages/shop/product-detail?id=${id}` })) return
setSkuMode('cart')
setSkuVisible(true)
}
const handleBuyNow = () => {
if (!requireLogin({ action: 'buyNow', redirect: `/pages/shop/product-detail?id=${id}` })) 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 {
// VIP 会员使用 dealerPrice 作为结算价
const vipPrice = isVipMember() && product.dealerPrice ? product.dealerPrice : undefined
const buyNowData = [{
goodsId: product.goodsId!,
skuId: sku?.id || 0,
quantity: quantity,
num: quantity,
product: product,
sku: sku,
skuPrice: vipPrice,
checked: true,
}]
Taro.setStorageSync('buy_now', JSON.stringify(buyNowData))
Taro.navigateTo({ url: '/pages/shop/checkout?from=buyNow' })
}
}
const handleGoCart = () => {
Taro.navigateTo({ url: '/pages/shop/cart' })
}
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 ? '限自提' : '送上门'
/**
* 将商品详情内容中的 Markdown 图片语法转换为 RichText 可识别的 HTML
* 支持格式:![alt](url) 和 ![alt](url?params)
*/
const parseContent = (content: string): string => {
if (!content) return ''
let html = content
// 将 ![...](url) 转换为 <img src="url" style="max-width:100%"/>
html = html.replace(
/!\[([^\]]*)\]\(([^)]+)\)/g,
'<img src="$2" mode="widthFix" style="max-width:100%;display:block;" />'
)
// 兜底:如果内容里直接包含 http(s) 图片链接(非 img 标签包裹的),也尝试转成图片
if (html.includes('http') && !html.includes('<img')) {
html = html.replace(
/(https?:\/\/[^\s\)]+\.(jpg|jpeg|png|gif|webp)(\?[^\s\)]*)?)/gi,
'<img src="$1" mode="widthFix" style="max-width:100%;display:block;" />'
)
}
return html
}
return (
<View className='flex flex-col bg-gray-50' style={{ height: '100vh' }}>
<ScrollView scrollY style={{ height: scrollHeight }}>
{/* 图片轮播 */}
<Swiper
className='w-full'
style={{ height: `${swiperHeight}px` }}
indicatorDots
indicatorColor='#e5e7eb'
indicatorActiveColor='#0e932e'
autoplay
circular
>
{images.map((img, idx) => (
<SwiperItem key={idx}>
<Image
className='w-full'
style={{ display: 'block' }}
src={img}
mode='widthFix'
onLoad={(e: any) => {
// 根据第一张图片的实际宽高比动态设置 Swiper 高度
if (idx === 0) {
const { width, height } = e.detail
if (width > 0) {
const screenWidth = Taro.getSystemInfoSync().windowWidth
setSwiperHeight(Math.round((height / width) * screenWidth))
}
}
}}
onClick={() => Taro.previewImage({ current: img, urls: images })}
/>
</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={getDisplayPrice()} size='large' color='#ee0a24' loginMask />
<Tag></Tag>
{/* VIP 会员价 */}
{!isGuest() && isVipMember() && Number(product.dealerPrice) > 0 ? (
<View className='ml-auto inline-flex items-center gap-1 bg-amber-50 rounded px-2 py-1'>
<Text className='text-xs text-amber-600 font-medium'>VIP专享</Text>
<Text className='text-sm text-amber-700 font-bold'>¥{product.dealerPrice}</Text>
</View>
) : (
!isGuest() && Number(product.salePrice) > 0 && product.salePrice !== product.price && (
<Text className='text-xs text-gray-400 ml-2 line-through'>¥{product.salePrice}</Text>
)
)}
</View>
{/* 会员价 */}
{!isGuest() && !isVipMember() && Number(product.memberStorePrice) > 0 && 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>
)}
{/* 赚取积分 */}
{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'>
{Number(product.sales) > 0 && (
<Text className='text-xs text-gray-400'>: {product.sales}</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>
{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={parseContent(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}>
<View className='relative inline-flex items-center justify-center'>
<Text className='text-lg'>🛒</Text>
{totalCount > 0 && (
<View
className='flex items-center justify-center'
style={{
position: 'absolute',
top: '-6px',
right: '-10px',
minWidth: '16px',
height: '16px',
borderRadius: '8px',
backgroundColor: '#ef4444',
borderWidth: '1.5px',
borderStyle: 'solid',
borderColor: '#ffffff',
paddingHorizontal: '3px',
}}
>
<Text className='text-white text-[10px] font-bold leading-none'>
{totalCount > 99 ? '99+' : totalCount}
</Text>
</View>
)}
</View>
<Text className='text-xs text-gray-500 mt-1 whitespace-nowrap'></Text>
</View>
<Button
openType="contact"
sessionFrom="product_detail"
className='flex-1 flex flex-col items-center'
style={{
background: 'transparent',
border: 'none',
padding: '0',
margin: '0',
lineHeight: 'normal',
fontSize: 'inherit',
}}
>
<Text className='text-lg'>💬</Text>
<Text className='text-xs text-gray-500 mt-1'></Text>
</Button>
<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}
/>
{/* 分享海报画布(离屏,用于生成带小程序码的海报图) */}
<SharePoster ref={posterRef} />
</View>
)
}
export default ProductDetailPage