Files
xinlong-shop-taro/src/pages/shop/product-detail.tsx
赵忠林 44ca5bbbfc feat(shop): 添加商品详情页轮播图点击放大预览功能
- 在 src/pages/shop/product-detail.tsx 的轮播图 Image 上添加 onClick 事件
- 使用 Taro.previewImage 实现图片全屏预览和左右滑动浏览
- 同样在 src/pages/points/product-detail.tsx 商品图片添加点击预览功能
- 新增完整的 shop-api 秒杀模块,包含实体、参数、mapper、service、controller 和定时任务
- 实现防重复提交、活动校验、限购校验和原子扣库存的秒杀核心下单逻辑
- 提供管理端 CRUD 与用户端秒杀订单相关接口
- 引入定时任务 SeckillStatusTask 定时更新秒杀活动状态
- 添加秒杀模块相关数据库建表脚本和接口路径对齐前端设计
2026-06-26 22:25:07 +08:00

421 lines
15 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 [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 {
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 (!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 = () => {
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 ? '限自提' : '送上门'
/**
* 将商品详情内容中的 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: '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'
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