Files
xinlong-shop-taro/src/pages/index/index.tsx
赵忠林 501e79eed7 fix(index): 修复首页轮播图显示与高度自适应问题
- 解决首页轮播图仅显示第一张图片的问题,支持广告位多图展开显示
- 通过 flatMap 展开广告位所有图片为独立轮播项,兼容单图老数据
- 轮播图点击跳转逻辑按广告位路径保持不变
- 获取轮播容器实际宽度,计算轮播高度自适应,替代固定 160px 高度
- 根据后台广告位宽高比计算轮播图展示高度,无效数据时回退到默认高度
- 轮播容器新增类名便于 DOM 选择和尺寸测量
- 轮播图及占位区高度均采用动态计算结果,适配不同设备屏幕宽度
2026-07-15 22:30:02 +08:00

414 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, useCallback, useRef, useMemo } from 'react'
import { View, Text, ScrollView, Swiper, SwiperItem, Image } from '@tarojs/components'
import Taro, { useDidShow } from '@tarojs/taro'
import { useUser } from '@/hooks/useUser'
import { useShare } from '@/hooks/useShare'
import { useScrollHeight } from '@/hooks/useScrollHeight'
import { useNewOrderDetector } from '@/hooks/useNewOrderDetector'
import { useVipStatus } from '@/hooks/useVipStatus'
import { getMyClerk } from '@/api/shop/shopStoreUser'
import { pageShopOrder } from '@/api/shop/shopOrder'
import { listCmsAd } from '@/api/cms/cmsAd'
import { listCmsArticle } from '@/api/cms/cmsArticle'
import { pageShopGoods } from '@/api/shop/shopGoods'
import type { CmsAd } from '@/api/cms/cmsAd/model'
import type { CmsArticle } from '@/api/cms/cmsArticle/model'
import type { ShopGoods } from '@/api/shop/shopGoods/model'
import Price from '@/components/common/Price'
import { getCompressedImageUrl } from '@/utils/image'
definePageConfig({
navigationBarTitleText: '首页',
enableShareAppMessage: true,
enableShareTimeline: true,
})
const IndexPage: React.FC = () => {
const { user, isLoggedIn } = useUser()
const [banners, setBanners] = useState<CmsAd[]>([])
const [bannerWrapWidth, setBannerWrapWidth] = useState(0)
const [announcements, setAnnouncements] = useState<CmsArticle[]>([])
const [hotProducts, setHotProducts] = useState<ShopGoods[]>([])
const [loading, setLoading] = useState(true)
const scrollHeight = useScrollHeight(44)
// VIP 状态异步校验并更新缓存isVip 变化时触发重渲染
const { isVip } = useVipStatus()
// 首页分享:好友 + 朋友圈 + 复制链接(右上角三个按钮全亮)
useShare({
title: '鑫龙家电 - 精选好物,实惠到家,等你来逛~',
path: '/pages/index/index',
enableTimeline: true,
enableCopyUrl: true,
})
// ─── 门店店员身份检测 ──────────────────────────────────────────
const [isClerk, setIsClerk] = useState(false)
const isClerkRef = useRef(false)
// 登录后检查是否为门店店员
useEffect(() => {
if (!isLoggedIn) {
setIsClerk(false)
isClerkRef.current = false
return
}
getMyClerk()
.then(data => {
const clerk = !!data
setIsClerk(clerk)
isClerkRef.current = clerk
})
.catch(() => {
setIsClerk(false)
isClerkRef.current = false
})
}, [isLoggedIn])
// 页面重新显示时也刷新店员身份(可能从其他页面回来时状态变了)
useDidShow(() => {
if (isLoggedIn) {
getMyClerk()
.then(data => {
const clerk = !!data
setIsClerk(clerk)
isClerkRef.current = clerk
})
.catch(() => {
isClerkRef.current = false
})
}
})
// ─── 新订单轮询检测(仅店员生效)────────────────────────────────
const { newOrderCount, clearUnread } = useNewOrderDetector({
interval: 30000, // 30 秒轮询一次
fetchLatestOrders: useCallback(() => {
// 非店员不请求返回空数组baseline 不会建立,无副作用)
if (!isClerkRef.current) return Promise.resolve([])
return pageShopOrder({ page: 1, limit: 5 }).then(r => r?.list || [])
}, []),
onNewOrders: useCallback((count) => {
// 非门店店员不弹新订单提醒
if (!isClerkRef.current) return
Taro.vibrateShort({ type: 'medium' })
Taro.showToast({
title: `您有 ${count} 条新订单`,
icon: 'none',
duration: 2500,
})
}, []),
})
const categories = ['全部', '球拍', '球鞋', '服装', '配件']
// 功能入口8个
const featureEntries = [
{ icon: '🎁', label: '全部商品', url: '/pages/shop/index' },
{ icon: '🤝', label: '收货地址', url: '/pages/user/address-list' },
{ icon: '🎫', label: '领券中心', url: '/pages/index/coupon-center/index' },
{ icon: '⭐', label: '我的收藏', url: '/pages/user/favorite-list/index' },
{ icon: '👑', label: '关于我们', url: '/pages/user/about/index' },
{ icon: '💰', label: '积分商城', url: '/pages/points/index' },
{ icon: '🏪', label: '门店信息', url: '/pages/store/list/index' },
{ icon: '❓', label: '帮助中心', url: '/pages/user/help-center/index' },
]
// 获取轮播图
useEffect(() => {
const fetchBanners = async () => {
try {
const data = await listCmsAd({ adType: 'banner', status: 0 })
setBanners(data || [])
} catch (e) {
console.error('获取轮播图失败:', e)
}
}
const fetchAnnouncements = async () => {
try {
const data = await listCmsArticle({ category: 'notice', status: 1 })
setAnnouncements(data || [])
} catch (e) {
console.error('获取公告失败:', e)
} finally {
setLoading(false)
}
}
fetchBanners()
fetchAnnouncements()
fetchHotProducts()
}, [])
// 获取热销商品
const fetchHotProducts = async () => {
try {
const res = await pageShopGoods({ page: 1, limit: 4, status: 0, recommend: 1 })
if (res?.list) {
setHotProducts(res.list)
}
} catch (e) {
console.error('获取热销商品失败:', e)
}
}
const handleMessageClick = () => {
if (!isLoggedIn) {
Taro.navigateTo({ url: '/passport/login' })
return
}
// 店员 → 跳转门店订单管理页,有新订单时清除未读计数
if (isClerk) {
if (newOrderCount > 0) clearUnread()
Taro.navigateTo({ url: '/pages/store/orders/index' })
return
}
// 普通用户 → 跳消息通知页
Taro.navigateTo({ url: '/pages/index/notification' })
}
/**
* VIP 会员显示 dealerPrice同时把 price 作为划线原价),非 VIP 维持原行为
*/
const getHotDisplayPrice = (item: ShopGoods): { price: string; original?: string } => {
if (isVip && item.dealerPrice) {
return { price: item.dealerPrice, original: item.price }
}
const original = item.salePrice && item.salePrice !== item.price ? item.salePrice : undefined
return { price: item.price || '0', original }
}
// tabBar 页面列表
const tabBarPages = ['/pages/index/index', '/pages/shop/index', '/pages/order/list', '/pages/user/user']
const handleFeatureClick = (url: string) => {
if (tabBarPages.includes(url)) {
Taro.switchTab({ url })
} else {
Taro.navigateTo({ url })
}
}
// 获取轮播容器实际宽度,用于根据后台 width/height 计算比例高度
useEffect(() => {
if (bannerWrapWidth > 0) return
const timer = setTimeout(() => {
const query = Taro.createSelectorQuery()
query.select('.index-banner-wrap').boundingClientRect((rect) => {
if (rect && rect.width > 0) {
setBannerWrapWidth(rect.width)
}
}).exec()
}, 0)
return () => clearTimeout(timer)
}, [bannerWrapWidth])
// 根据广告位后台返回的 width/height 计算轮播区域高度(无值则 fallback 160px
const bannerHeight = useMemo(() => {
if (bannerWrapWidth <= 0) return 160
const firstAd = banners.find(b => b.width && b.height)
if (!firstAd) return 160
const w = parseInt(firstAd.width || '0', 10)
const h = parseInt(firstAd.height || '0', 10)
if (!w || !h || w <= 0 || h <= 0) return 160
return Math.round(bannerWrapWidth * (h / w))
}, [bannerWrapWidth, banners])
// 轮播图数据:每个广告位下的所有图片都作为独立轮播项
const bannerSlides = banners.flatMap(item =>
(item.imageList && item.imageList.length > 0)
? item.imageList.map((img, idx) => ({
key: `${item.adId}-${img.uid || idx}`,
src: img.url || '',
path: item.path,
}))
: item.image
? [{ key: `${item.adId}-0`, src: item.image, path: item.path }]
: []
)
return (
<View className='min-h-screen bg-gray-50'>
{/* 顶部搜索栏 */}
<View className='flex items-center px-3 py-2 bg-white'>
<View className='flex-1 mx-2'>
<View
className='flex items-center bg-gray-100 rounded-full px-3 py-2'
onClick={() => Taro.navigateTo({ url: '/pages/index/search' })}
>
<Text className='text-gray-400 text-sm mr-2'>🔍</Text>
<Text className='text-gray-400 text-sm flex-1'></Text>
</View>
</View>
<View onClick={handleMessageClick} className='relative'>
<Text className='text-xl'>🔔</Text>
{isClerk && newOrderCount > 0 && (
<View className='absolute -top-1 -right-1 min-w-[16px] h-4 px-1 bg-red-500 rounded-full flex items-center justify-center'>
<Text className='text-white text-[10px] leading-none font-medium'>
{newOrderCount > 99 ? '99+' : newOrderCount}
</Text>
</View>
)}
{isClerk && newOrderCount === 0 && (
<View className='absolute top-0 right-0 w-2 h-2 bg-red-500 rounded-full' />
)}
</View>
</View>
<ScrollView scrollY style={{ height: scrollHeight }}>
{/* 轮播图 */}
<View className='mx-3 mt-2 rounded-lg overflow-hidden index-banner-wrap'>
{bannerSlides.length > 0 ? (
<Swiper
autoplay
interval={3000}
className='rounded-lg'
style={{ height: `${bannerHeight}px` }}
>
{bannerSlides.map(slide => (
<SwiperItem key={slide.key}>
<Image
className='w-full h-full'
src={getCompressedImageUrl(slide.src, { width: 750, quality: 90 })}
mode='aspectFill'
onClick={() => {
if (slide.path) Taro.navigateTo({ url: slide.path })
}}
/>
</SwiperItem>
))}
</Swiper>
) : (
<View className='w-full bg-green-50 flex items-center justify-center' style={{ height: `${bannerHeight}px` }}>
<Text className='text-gray-400 text-sm'></Text>
</View>
)}
</View>
{/* 功能入口网格 */}
<View className='mx-3 mt-3 p-3 bg-white rounded-lg'>
<View className='grid grid-cols-4 gap-2'>
{featureEntries.map(item => (
<View
key={item.label}
className='flex flex-col items-center py-2'
onClick={() => handleFeatureClick(item.url)}
>
<Text className='text-2xl mb-1'>{item.icon}</Text>
<Text className='text-xs text-gray-600'>{item.label}</Text>
</View>
))}
</View>
</View>
{/* 公告栏 */}
{announcements.length > 0 && (
<View className='mx-3 mt-3 px-3 py-2 bg-yellow-50 rounded-lg flex items-center' style={{ display: 'none' }}>
<Text className='text-xs text-yellow-600 font-medium mr-2'></Text>
<Swiper
autoplay
direction='vertical'
interval={3000}
className='flex-1'
style={{ height: '20px' }}
>
{announcements.map(item => (
<SwiperItem key={item.articleId}>
<Text
className='text-xs text-gray-600 truncate'
onClick={() => Taro.navigateTo({ url: `/pages/index/article-detail?id=${item.articleId}` })}
>
{item.title}
</Text>
</SwiperItem>
))}
</Swiper>
</View>
)}
{/* 分类导航 */}
<View className='mx-3 mt-3 p-3 bg-white rounded-lg' style={{ display: 'none' }}>
<Text className='text-base font-medium text-gray-800 mb-3 block'></Text>
<View className='flex justify-around'>
{categories.map((item) => (
<View
key={item}
className='flex flex-col items-center'
onClick={() => Taro.navigateTo({ url: '/pages/shop/category' })}
>
<View className='w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center mb-1'>
<Text className='text-xs text-gray-500'>{item.slice(0, 1)}</Text>
</View>
<Text className='text-xs text-gray-600'>{item}</Text>
</View>
))}
</View>
</View>
{/* 热销推荐 */}
<View className='mx-3 mt-3 mb-4'>
<View className='flex justify-between items-center mb-3'>
<Text className='text-base font-medium text-gray-800 block'></Text>
<Text
className='text-xs text-gray-400 block'
onClick={() => Taro.switchTab({ url: '/pages/shop/index' })}
>
{'>'}
</Text>
</View>
<View className='grid grid-cols-2 gap-2'>
{hotProducts.length > 0 ? (
hotProducts.map((item) => (
<View
key={item.goodsId}
className='bg-white rounded-lg overflow-hidden'
onClick={() => Taro.navigateTo({ url: `/pages/shop/product-detail?id=${item.goodsId}` })}
>
<View className='w-full' style={{ paddingTop: '100%', position: 'relative' }}>
{item.image ? (
<Image
style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%' }}
src={getCompressedImageUrl(item.image)}
mode='aspectFit'
/>
) : (
<View style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%' }} className='bg-gray-100 flex items-center justify-center'>
<Text className='text-xs text-gray-300'></Text>
</View>
)}
</View>
<View className='p-2'>
<Text className='text-sm text-gray-700 truncate block'>{item.goodsName || item.name}</Text>
<View className='flex items-center mt-1'>
<Price
price={getHotDisplayPrice(item).price}
original={getHotDisplayPrice(item).original}
size='small'
loginMask
/>
{isVip && item.dealerPrice ? (
<Text className='text-xs text-amber-600 ml-1'>VIP</Text>
) : null}
</View>
{item.sales !== undefined && item.sales > 0 && (
<Text className='text-xs text-gray-400 mt-1'> {item.sales}</Text>
)}
</View>
</View>
))
) : (
<View className='col-span-2 py-8 text-center'>
<Text className='text-sm text-gray-400'></Text>
</View>
)}
</View>
</View>
</ScrollView>
</View>
)
}
export default IndexPage