Files
xinlong-shop-taro/src_bak/pages/shop/product-detail.tsx
赵忠林 1fa58040f3 feat(user): 新增收货地址管理及售后申请页面
- 新增地址类型定义,增强前端地址数据结构
- 新增地址编辑页面,支持地址智能识别和定位选点功能
- 地址编辑支持省市区选择及默认地址设置
- 新增地址列表页面,支持地址展示、删除、编辑和选择功能
- 实现售后申请页面,支持选择售后类型和退款原因
- 售后申请支持商品选择、退款金额计算和凭证上传
- 新增售后详情页面,支持售后状态展示及申请取消
- 优化页面加载和用户交互体验,增加错误提示和权限处理
2026-07-01 12:11:56 +08:00

442 lines
16 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 } 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'
import { isGuest } from '@/utils/auth'
import { requireLogin } from '@/utils/login-guard'
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 [swiperHeight, setSwiperHeight] = useState(375)
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 (!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 (err: any) {
// 接口报错时仍更新UI状态避免状态不一致
console.error('[ProductDetail] 收藏操作失败:', err?.message || err)
if (isFavorite) {
setIsFavorite(false)
} else {
setIsFavorite(true)
}
// 不再额外弹toasthandleError已经弹了后端返回的错误信息
}
}
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 {
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 = () => {
// 购物车不是 tabBar 页面,使用 navigateTo路径与 app.config.ts 中注册的 'pages/shop/cart' 对应
Taro.navigateTo({ url: '/pages/shop/cart' })
}
const handleContactService = () => {
// 直接跳转到在线客服页面(已实现完整功能:微信客服按钮 + 历史消息 + 热线 + 微信留言)
Taro.navigateTo({ url: '/pages/user/customer-service/index' })
}
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 标签包裹的),也尝试转成图片
// 匹配独立的 https://xxx.jpg/png/gif 链接
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={product.price || '0'} size='large' color='#ee0a24' loginMask />
<Tag></Tag>
{!isGuest() && product.salePrice && product.salePrice !== product.price && (
<Text className='text-xs text-gray-400 ml-2'>¥{product.salePrice}</Text>
)}
</View>
{/* 会员价 */}
{!isGuest() && 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={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}>
<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