fix(ui): restore tab bar and detail navigation

This commit is contained in:
lai_hong
2026-09-14 18:38:14 +08:00
parent 82b0cc485e
commit 99bcf4a1d3
13 changed files with 264 additions and 142 deletions
+5 -1
View File
@@ -4,7 +4,10 @@
right: 0;
bottom: 0;
height: 160rpx;
pointer-events: none;
// 部分微信基础库对 pointer-events:none 的父节点命中测试不一致,
// 可能导致子节点虽然声明 auto 仍收不到点击。根容器直接参与命中,
// 背景渐变单独关闭事件,标签区域保持可点击。
pointer-events: auto;
z-index: 999;
}
@@ -14,6 +17,7 @@
right: 0;
bottom: 0;
height: 100%;
pointer-events: none;
}
.tab-bar-root.theme-light .tab-bar-gradient {
+12 -1
View File
@@ -101,6 +101,12 @@ export default function CustomTabBar() {
Taro.eventCenter.trigger('tabBarChange', url)
}
const tapTab = (url: string) => {
// 点击标签时直接切换;拖动手势由 onTouchEnd 统一处理,避免一次手势触发两次跳转。
if (isDraggingRef.current || url === activeTab) return
switchTab(url)
}
const startDrag = (e: any) => {
const touch = e.touches && e.touches[0]
if (!touch) return
@@ -181,7 +187,12 @@ export default function CustomTabBar() {
{TABS.map((tab) => {
const isActive = tab.pagePath === activeTab
return (
<View key={tab.pagePath} className={`tab-item ${isActive ? 'active' : ''}`}>
<View
key={tab.pagePath}
className={`tab-item ${isActive ? 'active' : ''}`}
onTap={() => tapTab(tab.pagePath)}
onClick={() => tapTab(tab.pagePath)}
>
<Image className={`tab-icon-img ${isActive ? 'active' : ''}`} src={isActive ? tab.iconActive : tab.icon} mode='aspectFit' />
<Text className='tab-label'>{tab.label}</Text>
</View>
+109
View File
@@ -47,6 +47,7 @@
.search-input {
flex: 1;
min-width: 0;
height: 88rpx;
font-size: 28rpx;
color: var(--text-primary);
@@ -75,6 +76,114 @@
display: block;
}
.search-clear,
.search-reset {
margin: 0;
padding: 0 16rpx;
min-width: 80rpx;
font-size: 26rpx;
line-height: 72rpx;
color: var(--accent-primary);
background: transparent;
&::after { border: none; }
}
.search-clear { flex-shrink: 0; }
.search-reset {
margin-top: 24rpx;
border-radius: 999rpx;
background: var(--bg-card);
}
.section-heading {
margin-bottom: 24rpx;
.home-section-title { margin: 0; }
}
.section-subtitle {
display: block;
margin-top: 8rpx;
font-size: 24rpx;
line-height: 1.5;
color: var(--text-secondary);
}
.category-section {
margin-top: 32rpx;
padding: 28rpx 24rpx;
border-radius: 24rpx;
background: var(--bg-card);
border: 1rpx solid var(--border);
}
.category-rail {
width: calc(100% + 48rpx);
margin: 0 -24rpx -28rpx;
white-space: nowrap;
}
.category-tile {
position: relative;
display: inline-flex;
width: 252rpx;
height: 168rpx;
margin: 0 0 28rpx 24rpx;
overflow: hidden;
border-radius: 20rpx;
vertical-align: top;
background: var(--bg-muted);
box-shadow: var(--shadow-card);
}
.category-tile:last-child { margin-right: 24rpx; }
.category-tile-image {
width: 100%;
height: 100%;
}
.category-tile-shade {
position: absolute;
right: 0;
bottom: 0;
left: 0;
display: flex;
flex-direction: column;
justify-content: flex-end;
min-height: 84rpx;
padding: 14rpx 16rpx;
box-sizing: border-box;
background: linear-gradient(transparent, rgba(20, 18, 16, 0.78));
}
.category-tile-name {
overflow: hidden;
color: #fff;
font-size: 27rpx;
font-weight: 700;
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.category-tile-action {
margin-top: 5rpx;
color: rgba(255, 255, 255, 0.8);
font-size: 20rpx;
line-height: 1.2;
}
.recommend-section {
margin-top: 40rpx;
padding-top: 32rpx;
border-top: 2rpx solid var(--border);
}
.search-results-section { margin-top: 32rpx; }
/* 区块标题 */
.home-section-title {
display: block;
+50 -54
View File
@@ -1,4 +1,4 @@
import { View, Text, Image, Input, Button, Swiper, SwiperItem } from '@tarojs/components'
import { View, Text, Image, Input, Button, Swiper, SwiperItem, ScrollView } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { useState, useEffect } from 'react'
import './index.scss'
@@ -70,7 +70,7 @@ const PRODUCT_IMG_MAP: Record<string, string> = {
}
export default function Index() {
const { theme, resolvedTheme } = useThemeContext()
const { resolvedTheme } = useThemeContext()
const safe = useSafeArea()
useStatusBar(resolvedTheme)
const [searchKey, setSearchKey] = useState('')
@@ -81,20 +81,11 @@ export default function Index() {
Taro.navigateTo({ url: `/pages/product/index?id=${categoryId}` })
}
const navigateFromShowcase = (item: { categoryId?: string; title: string }) => {
if (item.categoryId) {
navigateToProduct(item.categoryId)
return
}
Taro.showToast({
title: `${item.title}”定制通道即将开放`,
icon: 'none'
})
}
const filteredCategories = searchKey.trim()
const keyword = searchKey.trim().toLocaleLowerCase()
const isSearching = keyword.length > 0
const filteredCategories = isSearching
? products.filter(
(c) => c.name.includes(searchKey) || c.desc.includes(searchKey)
(c) => [c.name, c.desc, c.description].some(value => value?.toLocaleLowerCase().includes(keyword))
)
: products
@@ -125,19 +116,22 @@ export default function Index() {
onInput={handleSearch}
confirmType='search'
/>
{searchKey.length > 0 && (
<Button className='search-clear' onClick={() => setSearchKey('')}></Button>
)}
</View>
{searchKey.trim() && (
{isSearching && (
<View className='search-result-hint'>
<Text className='hint-text'>
{filteredCategories.length > 0
? `找到 ${filteredCategories.length} 相关品`
: '没有找到相关品,试试看其他关键词'}
? `找到 ${filteredCategories.length} 相关`
: '没有找到相关品,试试看其他关键词'}
</Text>
</View>
)}
{/* 定制成品展示 */}
<View className='showcase-section'>
{!isSearching && <View className='showcase-section'>
<Text className='home-section-title'></Text>
<Swiper
className='showcase-swiper'
@@ -164,11 +158,40 @@ export default function Index() {
</SwiperItem>
))}
</Swiper>
</View>
</View>}
{/* 热门品类 */}
<View className='category-section'>
<Text className='home-section-title'></Text>
{/* 品类是快捷入口,与下方商品推荐采用不同布局。 */}
{!isSearching && (
<View className='category-section'>
<View className='section-heading'>
<Text className='home-section-title'></Text>
<Text className='section-subtitle'></Text>
</View>
<ScrollView className='category-rail' scrollX enhanced showScrollbar={false}>
{products.map(cat => (
<View className='category-tile' key={cat.id} onTap={() => navigateToProduct(cat.id)}>
<ProductImage
className='category-tile-image'
src={cat.images[0] || PRODUCT_IMG_MAP[cat.id] || assetUrl('/icon/四角星.svg')}
fallback={getProductIconImg(cat)}
mode='aspectFill'
/>
<View className='category-tile-shade'>
<Text className='category-tile-name'>{cat.name}</Text>
<Text className='category-tile-action'></Text>
</View>
</View>
))}
</ScrollView>
</View>
)}
{/* 搜索与推荐互斥,共用后端商品卡片。 */}
<View className={isSearching ? 'search-results-section' : 'recommend-section'}>
<View className='section-heading'>
<Text className='home-section-title'>{isSearching ? '搜索结果' : '为你推荐'}</Text>
{!isSearching && <Text className='section-subtitle'></Text>}
</View>
<View className='home-grid'>
{filteredCategories.map((cat) => (
<View
@@ -179,7 +202,7 @@ export default function Index() {
<View className='media-card-image'>
<ProductImage
className='media-card-img'
src={PRODUCT_IMG_MAP[cat.id] || assetUrl('/icon/四角星.svg')}
src={cat.images[0] || PRODUCT_IMG_MAP[cat.id] || assetUrl('/icon/四角星.svg')}
fallback={getProductIconImg(cat)}
mode='aspectFill'
/>
@@ -195,40 +218,13 @@ export default function Index() {
{filteredCategories.length === 0 && (
<View className='empty-category'>
<Image className='empty-icon-img' src={assetUrl('/icon/搜索.svg')} mode='aspectFit' />
<Text className='empty-text'></Text>
<Text className='empty-sub'></Text>
<Text className='empty-text'>{isSearching ? '未找到匹配的商品' : '暂无在售商品'}</Text>
{isSearching && <Text className='empty-sub'></Text>}
{isSearching && <Button className='search-reset' onClick={() => setSearchKey('')}></Button>}
</View>
)}
</View>
{/* 为你推荐 */}
<View className='recommend-section'>
<Text className='home-section-title'></Text>
<View className='home-grid'>
{SHOWCASE_LIST.slice(0, 4).map((item, idx) => (
<View
key={idx}
className='media-card'
onTap={() => navigateFromShowcase(item)}
>
<View className='media-card-image'>
<ProductImage
className='media-card-img'
src={item.image}
fallback={getProductIconImg(CATEGORIES.find(category => category.id === item.categoryId))}
mode='aspectFill'
/>
</View>
<View className='media-card-body'>
<Text className='media-card-title'>{item.title}</Text>
<Text className='media-card-desc'>{item.desc}</Text>
<Text className='media-card-price'>{item.priceText}</Text>
</View>
</View>
))}
</View>
</View>
</View>
</View>
)
+2 -1
View File
@@ -282,7 +282,7 @@
position: fixed;
top: calc(env(safe-area-inset-top, 20px) + 20rpx);
left: 30rpx;
z-index: 50;
z-index: 1000;
width: 80rpx;
height: 80rpx;
border-radius: 50%;
@@ -294,6 +294,7 @@
display: flex;
align-items: center;
justify-content: center;
pointer-events: auto;
transition: opacity 0.2s ease, transform 0.15s ease;
}
+79 -77
View File
@@ -1,6 +1,6 @@
import { View, Text, ScrollView } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { useState, useEffect, useCallback, useMemo } from 'react'
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import './index.scss'
import { PRODUCTS } from '../../../utils/productConfig'
import { fetchProduct } from '../../../utils/api/product'
@@ -9,7 +9,6 @@ import { useSafeArea } from '../../../hooks/useSafeArea'
import { useStatusBar } from '../../../hooks/useStatusBar'
import { useThemeContext } from '../../../context/ThemeContext'
import ThemedPageMeta from '../../../components/ThemedPageMeta'
import ScrollTopMask from '../../../components/ScrollTopMask'
import BottomActionBar from '../../../components/BottomActionBar'
import ProductImage from '../../../components/ProductImage'
@@ -21,6 +20,8 @@ export default function ShopDetailPage() {
const { resolvedTheme } = useThemeContext()
const [winHeight, setWinHeight] = useState(667)
const [progress, setProgress] = useState(0)
const scrollProgressRef = useRef(0)
const scrollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const safe = useSafeArea()
// 顶部为沉浸式商品图,状态栏始终使用白字。
useStatusBar(resolvedTheme, { mode: 'light' })
@@ -28,12 +29,29 @@ export default function ShopDetailPage() {
const routerParams = Taro.getCurrentInstance().router
const routerParamsValues = routerParams ? routerParams.params : undefined
const productId = routerParamsValues ? routerParamsValues.id : ''
const [product, setProduct] = useState<ProductCategory | undefined>(PRODUCTS.find(p => p.id === productId))
const localProduct = useMemo(() => PRODUCTS.find(p => p.id === productId), [productId])
const [product, setProduct] = useState<ProductCategory | undefined>(localProduct)
useEffect(() => {
if (!productId) return
fetchProduct(productId).then(setProduct).catch(() => { /* 保留本地兜底 */ })
}, [productId])
fetchProduct(productId).then(remote => {
// 服务端优先,但介绍文案、规格和图片缺失时保留本地完整资料。
if (!localProduct) {
setProduct(remote)
return
}
setProduct({
...localProduct,
...remote,
description: remote.description || localProduct.description,
story: remote.story || localProduct.story,
scene: remote.scene || localProduct.scene,
tags: remote.tags?.length ? remote.tags : localProduct.tags,
specs: remote.specs?.length ? remote.specs : localProduct.specs,
images: remote.images?.length ? remote.images : localProduct.images,
})
}).catch(() => setProduct(localProduct))
}, [localProduct, productId])
useEffect(() => {
try {
@@ -44,19 +62,25 @@ export default function ShopDetailPage() {
}
}, [])
// 恢复原有的沉浸式收束动画;每帧只合并一次滚动状态,避免连续 setState 堆积。
const handleScroll = useCallback((e: any) => {
const st = (e.detail && e.detail.scrollTop) || 0
const total = winHeight * 1.2
const p = clamp(st / total, 0, 1)
setProgress(p)
const scrollTop = (e.detail && e.detail.scrollTop) || 0
scrollProgressRef.current = clamp(scrollTop / (winHeight * 1.2), 0, 1)
if (scrollTimerRef.current !== null) return
scrollTimerRef.current = setTimeout(() => {
scrollTimerRef.current = null
setProgress(current => Math.abs(current - scrollProgressRef.current) < 0.002 ? current : scrollProgressRef.current)
}, 16)
}, [winHeight])
/* Scroll-progress → multi-stage hero & content animations */
useEffect(() => () => {
if (scrollTimerRef.current !== null) clearTimeout(scrollTimerRef.current)
}, [])
const heroStyles = useMemo(() => {
const p = progress
// 1. Image crop insets (vh %)
let topCrop = 0, bottomCrop = 0
let topCrop = 0
let bottomCrop = 0
if (p <= 0.4) {
const t = easeOut(p / 0.4)
topCrop = lerp(0, 32, t)
@@ -66,60 +90,36 @@ export default function ShopDetailPage() {
topCrop = lerp(32, 34, t)
bottomCrop = lerp(38, 41, t)
} else {
topCrop = 34; bottomCrop = 41
topCrop = 34
bottomCrop = 41
}
// 2. Hero translateY
let translateY = 0
if (p <= 0.4) translateY = 0
else if (p <= 0.7) translateY = lerp(0, -75, (p - 0.4) / 0.3)
else translateY = -75
// 3. Hero bottom info opacity (always visible on open, fades after scroll)
let heroInfoOpacity = 1
if (p <= 0.35) heroInfoOpacity = 1
else if (p <= 0.48) heroInfoOpacity = clamp(1 - (p - 0.35) / 0.13, 0, 1)
else heroInfoOpacity = 0
// 4. Transition band opacity
let bandOpacity = 0
if (p <= 0.55) bandOpacity = 0
else if (p <= 0.7) bandOpacity = (p - 0.55) / 0.15
else bandOpacity = 1
// 5. Content reveal
let contentOpacity = 1, contentOffset = 0
if (p <= 0.05) { contentOpacity = 0; contentOffset = 60 }
else if (p <= 0.22) {
const t = (p - 0.05) / 0.17
contentOpacity = easeOut(t)
contentOffset = lerp(60, 0, easeOut(t))
}
// 6. Back button (fade out as hero scrolls)
let backBtnOpacity = 1
if (p <= 0.15) backBtnOpacity = clamp(1 - p / 0.15, 0, 1)
return {
topCrop,
bottomCrop,
translateY,
heroInfoOpacity,
bandOpacity,
contentOpacity,
contentOffset,
backBtnOpacity,
visibleHeight: 100 - topCrop - bottomCrop
}
const translateY = p <= 0.4 ? 0 : p <= 0.7 ? lerp(0, -75, (p - 0.4) / 0.3) : -75
const heroInfoOpacity = p <= 0.35 ? 1 : p <= 0.48 ? clamp(1 - (p - 0.35) / 0.13, 0, 1) : 0
const bandOpacity = p <= 0.55 ? 0 : p <= 0.7 ? (p - 0.55) / 0.15 : 1
const contentOpacity = p <= 0.05 ? 0 : p <= 0.22 ? easeOut((p - 0.05) / 0.17) : 1
const contentOffset = p <= 0.05 ? 60 : p <= 0.22 ? lerp(60, 0, easeOut((p - 0.05) / 0.17)) : 0
return { topCrop, bottomCrop, translateY, heroInfoOpacity, bandOpacity, contentOpacity, contentOffset, visibleHeight: 100 - topCrop - bottomCrop }
}, [progress])
const tone = useMemo(() => (product ? product.tone : [28, 22, 18]), [product])
const productDescription = product?.description || product?.subtitle || product?.desc || ''
const leavingRef = useRef(false)
const returnToShop = () => {
// 详情页的稳定出口是商品 Tab,避免开发者工具直达页面或异常页面栈导致返回失效。
if (leavingRef.current) return
leavingRef.current = true
const target = { url: '/pages/shop/index' }
Taro.switchTab(target).catch(() => Taro.reLaunch(target)).catch(() => {
leavingRef.current = false
})
}
if (!product) {
return (
<View className="shop-detail-page theme-dark">
<ThemedPageMeta darkTop />
<ScrollTopMask title="商品详情" targetSelector=".shop-back-btn" showBack />
<Text style={{ color: '#fff', padding: '40rpx' }}></Text>
</View>
)
@@ -128,7 +128,6 @@ export default function ShopDetailPage() {
return (
<View className="shop-detail-page theme-dark">
<ThemedPageMeta darkTop />
<ScrollTopMask title="商品详情" targetSelector=".shop-back-btn" showBack />
{/* Fixed Hero */}
<View className="shop-hero-fixed">
<View
@@ -162,10 +161,7 @@ export default function ShopDetailPage() {
}}
/>
<View
className="shop-hero-info"
style={{ opacity: heroStyles.heroInfoOpacity }}
>
<View className="shop-hero-info" style={{ opacity: heroStyles.heroInfoOpacity }}>
<Text className="shop-hero-title">{product.name}</Text>
<Text className="shop-hero-subtitle">{product.subtitle || product.desc}</Text>
<View className="shop-hero-price-row">
@@ -193,7 +189,7 @@ export default function ShopDetailPage() {
className="shop-content"
style={{ backgroundColor: `rgb(${tone[0]}, ${tone[1]}, ${tone[2]})` }}
>
{/* Transition band — appears as hero scrolls away */}
{/* 原有的主图收束后,浮动信息条自然接替标题和价格。 */}
<View
className="shop-band"
style={{
@@ -217,20 +213,25 @@ export default function ShopDetailPage() {
{/* Main copy blocks */}
<View
className="shop-detail-body"
style={{
opacity: heroStyles.contentOpacity,
transform: `translateY(${heroStyles.contentOffset}px)`
}}
style={{ opacity: heroStyles.contentOpacity, transform: `translateY(${heroStyles.contentOffset}px)` }}
>
<View className="shop-content-card">
<View className="shop-section">
<Text className="shop-section-title"></Text>
<Text className="shop-section-text">{productDescription}</Text>
</View>
{product.story && (
<View className="shop-section">
<Text className="shop-section-title"></Text>
<Text className="shop-section-text">{product.story}</Text>
</View>
<>
<View className="shop-divider" />
<View className="shop-section">
<Text className="shop-section-title"></Text>
<Text className="shop-section-text">{product.story}</Text>
</View>
</>
)}
{(product.story && product.scene) && (
{product.scene && (
<View className="shop-divider" />
)}
@@ -253,11 +254,11 @@ export default function ShopDetailPage() {
</View>
)}
{((product.story || product.scene) && product.tags && product.tags.length > 0) && (
{product.tags && product.tags.length > 0 && (
<View className="shop-divider" />
)}
{((product.story || product.scene || (product.tags && product.tags.length > 0)) && product.specs && product.specs.length > 0) && (
{product.specs && product.specs.length > 0 && (
<View className="shop-divider" />
)}
@@ -284,8 +285,9 @@ export default function ShopDetailPage() {
{/* Back button — glass morphism circle */}
<View
className="shop-back-btn"
onTap={() => Taro.navigateBack()}
style={{ top: `${safe.menuButtonTop}px`, opacity: heroStyles.backBtnOpacity }}
onTap={returnToShop}
onClick={returnToShop}
style={{ top: `${safe.menuButtonTop}px` }}
>
<Text className="shop-back-arrow"></Text>
</View>
@@ -299,7 +301,7 @@ export default function ShopDetailPage() {
<Text className="summary-original">¥{product.originalPrice}</Text>
)}
</View>
<View className="btn-secondary" onTap={() => Taro.navigateBack()}>
<View className="btn-secondary" onTap={returnToShop} onClick={returnToShop}>
<Text></Text>
</View>
<View className="btn-primary" onTap={() => Taro.navigateTo({ url: `/pages/product/index?id=${product.id}` })}>