diff --git a/CLAUDE.md b/CLAUDE.md index 89bbac6..745c640 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 `` in individual pages + +### Do NOT use CoverView +- `CoverView` / `CoverImage` have known bugs on real devices: invisible, click events don't bubble +- Use regular `` 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 `` 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 diff --git a/src/app.config.ts b/src/app.config.ts index 38c6228..ef57de9 100644 --- a/src/app.config.ts +++ b/src/app.config.ts @@ -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, -} -) \ No newline at end of file +}) diff --git a/src/components/PublishPopup/index.less b/src/components/PublishPopup/index.less new file mode 100644 index 0000000..2ea567e --- /dev/null +++ b/src/components/PublishPopup/index.less @@ -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; + } + } +} diff --git a/src/components/PublishPopup/index.tsx b/src/components/PublishPopup/index.tsx new file mode 100644 index 0000000..a014ac6 --- /dev/null +++ b/src/components/PublishPopup/index.tsx @@ -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 ( + + + + 发布 + 选择你要发布的内容类型 + + + {PUBLISH_OPTIONS.map((option) => ( + onSelect(option)} + > + {option.icon} + + {option.label} + {option.desc} + + + ))} + + + 取消 + + + + ) +} + +export type { PublishOption } diff --git a/src/custom-tab-bar/index.config.ts b/src/custom-tab-bar/index.config.ts new file mode 100644 index 0000000..29210de --- /dev/null +++ b/src/custom-tab-bar/index.config.ts @@ -0,0 +1,3 @@ +export default { + "component": true +} diff --git a/src/custom-tab-bar/index.less b/src/custom-tab-bar/index.less new file mode 100644 index 0000000..061c45b --- /dev/null +++ b/src/custom-tab-bar/index.less @@ -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; + } + } +} diff --git a/src/custom-tab-bar/index.tsx b/src/custom-tab-bar/index.tsx new file mode 100644 index 0000000..4d118b5 --- /dev/null +++ b/src/custom-tab-bar/index.tsx @@ -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 ( + <> + + {leftTabs.map((tab) => ( + handleTabClick(tab)} + > + {tab.icon} + {tab.label} + + ))} + + {/* 中间发布按钮 —— 不属于 tabBar list,纯 UI 元素 */} + + + + + 发布 + + + {rightTabs.map((tab) => ( + handleTabClick(tab)} + > + {tab.icon} + {tab.label} + + ))} + + + {/* 发布弹窗 —— 使用 VantUI Popup,独立于 tabBar 渲染 */} + + + ) +} diff --git a/src/pages/index/index.tsx b/src/pages/index/index.tsx index 42d5f84..6e58767 100644 --- a/src/pages/index/index.tsx +++ b/src/pages/index/index.tsx @@ -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() { {/* 底部安全距离 */} + + ) } diff --git a/src/pages/messages/index.config.ts b/src/pages/messages/index.config.ts new file mode 100644 index 0000000..4bbd8f2 --- /dev/null +++ b/src/pages/messages/index.config.ts @@ -0,0 +1,4 @@ +export default definePageConfig({ + navigationBarTitleText: '消息', + navigationStyle: 'custom', +}) diff --git a/src/pages/messages/index.less b/src/pages/messages/index.less new file mode 100644 index 0000000..64f76aa --- /dev/null +++ b/src/pages/messages/index.less @@ -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; + } + } +} diff --git a/src/pages/messages/index.tsx b/src/pages/messages/index.tsx new file mode 100644 index 0000000..ec84474 --- /dev/null +++ b/src/pages/messages/index.tsx @@ -0,0 +1,16 @@ +import { View, Text } from '@tarojs/components' +import CustomTabBar from "@/custom-tab-bar" +import './index.less' + +export default function Messages() { + return ( + + + 💬 + 消息 + 即将上线,敬请期待 + + + + ) +} diff --git a/src/pages/profile/index.config.ts b/src/pages/profile/index.config.ts new file mode 100644 index 0000000..977ab5f --- /dev/null +++ b/src/pages/profile/index.config.ts @@ -0,0 +1,4 @@ +export default definePageConfig({ + navigationBarTitleText: '我的', + navigationStyle: 'custom', +}) diff --git a/src/pages/profile/index.less b/src/pages/profile/index.less new file mode 100644 index 0000000..c66ca7b --- /dev/null +++ b/src/pages/profile/index.less @@ -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; + } + } +} diff --git a/src/pages/profile/index.tsx b/src/pages/profile/index.tsx new file mode 100644 index 0000000..59d0af2 --- /dev/null +++ b/src/pages/profile/index.tsx @@ -0,0 +1,16 @@ +import { View, Text } from '@tarojs/components' +import CustomTabBar from "@/custom-tab-bar" +import './index.less' + +export default function Profile() { + return ( + + + 👤 + 我的 + 即将上线,敬请期待 + + + + ) +} diff --git a/src/pages/tasks/index.config.ts b/src/pages/tasks/index.config.ts new file mode 100644 index 0000000..1c4a702 --- /dev/null +++ b/src/pages/tasks/index.config.ts @@ -0,0 +1,5 @@ +export default definePageConfig({ + navigationBarTitleText: '校园任务', + navigationStyle: 'custom', + "usingComponents": {} +}) diff --git a/src/pages/tasks/index.less b/src/pages/tasks/index.less new file mode 100644 index 0000000..c26d7f5 --- /dev/null +++ b/src/pages/tasks/index.less @@ -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; + } + } +} diff --git a/src/pages/tasks/index.tsx b/src/pages/tasks/index.tsx new file mode 100644 index 0000000..3a1a8f3 --- /dev/null +++ b/src/pages/tasks/index.tsx @@ -0,0 +1,16 @@ +import { View, Text } from '@tarojs/components' +import CustomTabBar from "@/custom-tab-bar" +import './index.less' + +export default function Tasks() { + return ( + + + 📋 + 校园任务 + 即将上线,敬请期待 + + + + ) +}