底部导航

This commit is contained in:
liu
2026-07-01 21:54:00 +08:00
parent 0ee14678b6
commit d909d376f9
17 changed files with 544 additions and 3 deletions
+26
View File
@@ -127,3 +127,29 @@ This project uses `@antmjs/vantui` v3.7. The official docs: https://antmjs.githu
- Use `@tarojs/components` for: `View`, `Text`, `ScrollView`, `Image` (native, simpler)
- Use `@antmjs/vantui` for: styled UI components (Search, Swiper, Tabs, Grid, Button, etc.)
- VantUI component props extend Taro's `ViewProps`, so `onClick`/`onTap` and style props are always available
## Custom TabBar (`src/custom-tab-bar/`)
This project uses Taro's **custom tabBar** mechanism (`tabBar.custom: true` in `app.config.ts`). Key rules:
### Component location
- The custom tabBar component MUST be at `src/custom-tab-bar/index.tsx` — this is a Taro convention, not configurable
- Taro automatically renders this component on every tab page; do NOT import `<CustomTabBar />` in individual pages
### Do NOT use CoverView
- `CoverView` / `CoverImage` have known bugs on real devices: invisible, click events don't bubble
- Use regular `<View>` with `position: fixed; bottom: 0; z-index: 999` instead
### Active tab state
- Taro creates a **new instance** of the tabBar component on each page, so `useState` resets on every tab switch
- Derive the active tab from `Taro.getCurrentPages()` at render time — NOT from component state
- Pattern: `const route = Taro.getCurrentPages()[last]?.route; const activeKey = TAB_LIST.find(t => route.includes(t.key))`
### Tab switching
- Use `Taro.switchTab({ url: pagePath })` — this matches native tabBar behavior
- The `pagePath` format must match the `tabBar.list[].pagePath` in `app.config.ts`
### Center "publish" button
- The publish button is a UI-only element not in `tabBar.list`
- It triggers a VantUI `<Popup>` rendered as a sibling of the tabBar, NOT nested inside it
- The popup state can use `useState` since it's ephemeral and not shared across tabs
+29 -3
View File
@@ -1,13 +1,39 @@
export default defineAppConfig({
pages: [
'pages/index/index',
'pages/tasks/index',
'pages/messages/index',
'pages/profile/index',
],
window: {
backgroundTextStyle: 'light',
navigationBarBackgroundColor: '#fff',
navigationBarTitleText: 'WeChat',
navigationBarTextStyle: 'black'
navigationBarTextStyle: 'black',
},
tabBar: {
custom: true,
color: '#969799',
selectedColor: '#1989fa',
backgroundColor: '#ffffff',
list: [
{
pagePath: 'pages/index/index',
text: '首页',
},
{
pagePath: 'pages/tasks/index',
text: '校园任务',
},
{
pagePath: 'pages/messages/index',
text: '消息',
},
{
pagePath: 'pages/profile/index',
text: '我的',
},
],
},
animation: false,
}
)
})
+82
View File
@@ -0,0 +1,82 @@
.publish-popup {
padding: 32px 24px 0;
background: #f7f8fa;
border-radius: 24px 24px 0 0;
.popup-header {
text-align: center;
margin-bottom: 32px;
.popup-title {
display: block;
font-size: 36px;
font-weight: 600;
color: #1a1a1a;
margin-bottom: 8px;
}
.popup-subtitle {
font-size: 26px;
color: #969799;
}
}
.popup-grid {
display: flex;
flex-direction: column;
gap: 16px;
margin-bottom: 24px;
}
.publish-card {
display: flex;
align-items: center;
background: #fff;
border-radius: 16px;
padding: 28px 24px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
transition: transform 0.2s;
&:active {
transform: scale(0.98);
background: #f7f8fa;
}
.card-icon {
font-size: 48px;
margin-right: 20px;
flex-shrink: 0;
}
.card-info {
flex: 1;
.card-label {
display: block;
font-size: 32px;
font-weight: 500;
color: #1a1a1a;
margin-bottom: 4px;
}
.card-desc {
font-size: 24px;
color: #969799;
}
}
}
.popup-cancel {
padding: 20px 0 40px;
text-align: center;
.cancel-text {
display: inline-block;
font-size: 32px;
color: #969799;
padding: 12px 48px;
background: #fff;
border-radius: 48px;
}
}
}
+64
View File
@@ -0,0 +1,64 @@
import { View, Text } from '@tarojs/components'
import { Popup } from '@antmjs/vantui'
import './index.less'
interface PublishOption {
id: string
label: string
icon: string
desc: string
}
const PUBLISH_OPTIONS: PublishOption[] = [
{ id: 'article', label: '发布文章', icon: '📝', desc: '分享知识与见解' },
{ id: 'goods', label: '发布二手', icon: '🛍️', desc: '闲置物品交易' },
{ id: 'express', label: '快递代取', icon: '📦', desc: '发布代取任务' },
{ id: 'activity', label: '校园活动', icon: '🎉', desc: '组织校园活动' },
{ id: 'help', label: '求助信息', icon: '🆘', desc: '发布求助需求' },
]
interface PublishPopupProps {
visible: boolean
onClose: () => void
onSelect: (option: PublishOption) => void
}
export default function PublishPopup({ visible, onClose, onSelect }: PublishPopupProps) {
return (
<Popup
visible={visible}
position='bottom'
round
onClose={onClose}
closeable
customStyle={{ background: 'transparent' }}
>
<View className='publish-popup'>
<View className='popup-header'>
<Text className='popup-title'></Text>
<Text className='popup-subtitle'></Text>
</View>
<View className='popup-grid'>
{PUBLISH_OPTIONS.map((option) => (
<View
key={option.id}
className='publish-card'
onClick={() => onSelect(option)}
>
<Text className='card-icon'>{option.icon}</Text>
<View className='card-info'>
<Text className='card-label'>{option.label}</Text>
<Text className='card-desc'>{option.desc}</Text>
</View>
</View>
))}
</View>
<View className='popup-cancel' onClick={onClose}>
<Text className='cancel-text'></Text>
</View>
</View>
</Popup>
)
}
export type { PublishOption }
+3
View File
@@ -0,0 +1,3 @@
export default {
"component": true
}
+71
View File
@@ -0,0 +1,71 @@
.custom-tab-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
align-items: flex-start;
justify-content: space-around;
height: 100px;
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-top: 1px solid rgba(0, 0, 0, 0.06);
z-index: 999;
box-sizing: border-box;
.tab-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding-top: 10px;
flex: 1;
position: relative;
.tab-icon {
font-size: 40px;
line-height: 1.2;
margin-bottom: 2px;
}
.tab-label {
font-size: 20px;
color: #969799;
line-height: 1.4;
}
&.active .tab-label {
color: #1989fa;
}
}
/* 中间发布按钮 */
.tab-publish {
justify-content: flex-start;
padding-top: 0;
.publish-btn {
width: 88px;
height: 88px;
border-radius: 50%;
background: linear-gradient(135deg, #1989fa 0%, #07c160 100%);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8px 24px rgba(25, 137, 250, 0.4);
margin-top: -30px;
.publish-icon {
font-size: 44px;
color: #fff;
font-weight: 300;
line-height: 1;
}
}
.publish-label {
margin-top: 4px;
}
}
}
+118
View File
@@ -0,0 +1,118 @@
import { useState, useCallback } from 'react'
import Taro from '@tarojs/taro'
import { View, Text } from '@tarojs/components'
import PublishPopup from '@/components/PublishPopup'
import type { PublishOption } from '@/components/PublishPopup'
import './index.less'
/** Tab 项配置(顺序与 app.config.ts tabBar.list 一致) */
interface TabItem {
key: string
label: string
icon: string
pagePath: string
}
const TAB_LIST: TabItem[] = [
{ key: 'index', label: '首页', icon: '🏠', pagePath: '/pages/index/index' },
{ key: 'tasks', label: '校园任务', icon: '📋', pagePath: '/pages/tasks/index' },
{ key: 'messages', label: '消息', icon: '💬', pagePath: '/pages/messages/index' },
{ key: 'profile', label: '我的', icon: '👤', pagePath: '/pages/profile/index' },
]
/**
* 根据当前页面路由推导激活的 tab。
* 不使用 useState —— Taro 在每次切换 tab 时都会重建 custom-tab-bar 实例,
* 因此直接从 getCurrentPages() 实时计算即可保证正确性。
*/
function useActiveKey(): string {
const pages = Taro.getCurrentPages()
if (pages.length > 0) {
const route = pages[pages.length - 1].route || ''
for (const tab of TAB_LIST) {
if (route.includes(tab.key)) return tab.key
}
}
return 'index'
}
/** 获取底部安全距离 */
function useSafeBottom(): number {
try {
const info = Taro.getSystemInfoSync()
if (info.safeArea) {
return info.safeArea.bottom
}
} catch { /* ignore */ }
return 0
}
export default function CustomTabBar() {
const activeKey = useActiveKey()
const safeBottom = useSafeBottom()
const [publishVisible, setPublishVisible] = useState(false)
/** 切换 Tab —— 使用 Taro.switchTab 与原生 tabBar 行为一致 */
const handleTabClick = useCallback((tab: TabItem) => {
if (tab.key === activeKey) return
Taro.switchTab({ url: tab.pagePath })
}, [activeKey])
/** 发布弹窗 */
const handlePublish = () => setPublishVisible(true)
const handlePublishClose = () => setPublishVisible(false)
const handlePublishSelect = (option: PublishOption) => {
setPublishVisible(false)
Taro.showToast({ title: `${option.label}即将上线`, icon: 'none' })
}
// 将 4 个 tab 拆分为 [0,1] + 发布按钮 + [2,3]
const leftTabs = TAB_LIST.slice(0, 2)
const rightTabs = TAB_LIST.slice(2)
return (
<>
<View
className='custom-tab-bar'
style={{ paddingBottom: `${safeBottom}px` }}
>
{leftTabs.map((tab) => (
<View
key={tab.key}
className={`tab-item ${activeKey === tab.key ? 'active' : ''}`}
onClick={() => handleTabClick(tab)}
>
<Text className='tab-icon'>{tab.icon}</Text>
<Text className='tab-label'>{tab.label}</Text>
</View>
))}
{/* 中间发布按钮 —— 不属于 tabBar list,纯 UI 元素 */}
<View className='tab-item tab-publish' onClick={handlePublish}>
<View className='publish-btn'>
<Text className='publish-icon'></Text>
</View>
<Text className='tab-label publish-label'></Text>
</View>
{rightTabs.map((tab) => (
<View
key={tab.key}
className={`tab-item ${activeKey === tab.key ? 'active' : ''}`}
onClick={() => handleTabClick(tab)}
>
<Text className='tab-icon'>{tab.icon}</Text>
<Text className='tab-label'>{tab.label}</Text>
</View>
))}
</View>
{/* 发布弹窗 —— 使用 VantUI Popup,独立于 tabBar 渲染 */}
<PublishPopup
visible={publishVisible}
onClose={handlePublishClose}
onSelect={handlePublishSelect}
/>
</>
)
}
+3
View File
@@ -5,6 +5,7 @@ import { Search, Swiper, SwiperItem, Grid, GridItem, Tabs, Tab, Image as VantIma
import ArticleCard from '@/components/ArticleCard'
import { banners, navItems, articles } from '@/mock/articles'
import type { ArticleCategory } from '@/types/article'
import CustomTabBar from "@/custom-tab-bar";
import './index.less'
/** 获取状态栏和导航栏高度 */
@@ -163,6 +164,8 @@ export default function Index() {
{/* 底部安全距离 */}
<View className='bottom-safe' />
</ScrollView>
<CustomTabBar />
</View>
)
}
+4
View File
@@ -0,0 +1,4 @@
export default definePageConfig({
navigationBarTitleText: '消息',
navigationStyle: 'custom',
})
+29
View File
@@ -0,0 +1,29 @@
.messages-page {
min-height: 100vh;
background: #f7f8fa;
.page-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding-top: 300px;
.placeholder-icon {
font-size: 96px;
margin-bottom: 24px;
}
.placeholder-text {
font-size: 36px;
font-weight: 500;
color: #323233;
margin-bottom: 12px;
}
.placeholder-desc {
font-size: 28px;
color: #969799;
}
}
}
+16
View File
@@ -0,0 +1,16 @@
import { View, Text } from '@tarojs/components'
import CustomTabBar from "@/custom-tab-bar"
import './index.less'
export default function Messages() {
return (
<View className='messages-page'>
<View className='page-placeholder'>
<Text className='placeholder-icon'>💬</Text>
<Text className='placeholder-text'></Text>
<Text className='placeholder-desc'>线</Text>
</View>
<CustomTabBar />
</View>
)
}
+4
View File
@@ -0,0 +1,4 @@
export default definePageConfig({
navigationBarTitleText: '我的',
navigationStyle: 'custom',
})
+29
View File
@@ -0,0 +1,29 @@
.profile-page {
min-height: 100vh;
background: #f7f8fa;
.page-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding-top: 300px;
.placeholder-icon {
font-size: 96px;
margin-bottom: 24px;
}
.placeholder-text {
font-size: 36px;
font-weight: 500;
color: #323233;
margin-bottom: 12px;
}
.placeholder-desc {
font-size: 28px;
color: #969799;
}
}
}
+16
View File
@@ -0,0 +1,16 @@
import { View, Text } from '@tarojs/components'
import CustomTabBar from "@/custom-tab-bar"
import './index.less'
export default function Profile() {
return (
<View className='profile-page'>
<View className='page-placeholder'>
<Text className='placeholder-icon'>👤</Text>
<Text className='placeholder-text'></Text>
<Text className='placeholder-desc'>线</Text>
</View>
<CustomTabBar />
</View>
)
}
+5
View File
@@ -0,0 +1,5 @@
export default definePageConfig({
navigationBarTitleText: '校园任务',
navigationStyle: 'custom',
"usingComponents": {}
})
+29
View File
@@ -0,0 +1,29 @@
.tasks-page {
min-height: 100vh;
background: #f7f8fa;
.page-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding-top: 300px;
.placeholder-icon {
font-size: 96px;
margin-bottom: 24px;
}
.placeholder-text {
font-size: 36px;
font-weight: 500;
color: #323233;
margin-bottom: 12px;
}
.placeholder-desc {
font-size: 28px;
color: #969799;
}
}
}
+16
View File
@@ -0,0 +1,16 @@
import { View, Text } from '@tarojs/components'
import CustomTabBar from "@/custom-tab-bar"
import './index.less'
export default function Tasks() {
return (
<View className='tasks-page'>
<View className='page-placeholder'>
<Text className='placeholder-icon'>📋</Text>
<Text className='placeholder-text'></Text>
<Text className='placeholder-desc'>线</Text>
</View>
<CustomTabBar />
</View>
)
}