hookehuyr

feat(tabbar): 实现动态底部导航及页面配置

- 新增应用页和我的备份页,支持 WebView 模式
- 重构我的页面为 WebView 承接页,移除静态 UI
- 新增底部导航配置 API、Mock 数据及 Pinia 状态管理
- 重构 AppTabbar 组件以使用动态配置,支持图标映射和页面路由
- 在应用启动时预加载导航配置,优化用户体验
- 将“消息”统一更名为“资讯”,更新相关页面文案
1 +import { fn, fetch } from './fn'
2 +
3 +const Api = {
4 + TABBAR_CONFIG: '/srv/?a=tabbar&t=config',
5 +}
6 +
7 +export const getTabbarConfigAPI = () => fn(fetch.get(Api.TABBAR_CONFIG))
...@@ -4,7 +4,9 @@ export default { ...@@ -4,7 +4,9 @@ export default {
4 'pages/map-guide/index', 4 'pages/map-guide/index',
5 'pages/message/index', 5 'pages/message/index',
6 'pages/message-detail/index', 6 'pages/message-detail/index',
7 + 'pages/application/index',
7 'pages/mine/index', 8 'pages/mine/index',
9 + 'pages/mine-backup/index',
8 'pages/pay-test/index', 10 'pages/pay-test/index',
9 'pages/pay-bridge/index', 11 'pages/pay-bridge/index',
10 'pages/webview-preview/index', 12 'pages/webview-preview/index',
......
...@@ -3,6 +3,9 @@ import { createPinia } from 'pinia' ...@@ -3,6 +3,9 @@ import { createPinia } from 'pinia'
3 import './utils/polyfill' 3 import './utils/polyfill'
4 import './app.less' 4 import './app.less'
5 import { saveCurrentPagePath, hasAuth, silentAuth, navigateToAuth } from '@/utils/authRedirect' 5 import { saveCurrentPagePath, hasAuth, silentAuth, navigateToAuth } from '@/utils/authRedirect'
6 +import { useTabbarStore } from '@/stores/tabbar'
7 +
8 +const pinia = createPinia()
6 9
7 const App = createApp({ 10 const App = createApp({
8 async onLaunch(options) { 11 async onLaunch(options) {
...@@ -26,13 +29,21 @@ const App = createApp({ ...@@ -26,13 +29,21 @@ const App = createApp({
26 } catch (error) { 29 } catch (error) {
27 console.error('静默授权失败:', error) 30 console.error('静默授权失败:', error)
28 navigateToAuth(full_path || undefined) 31 navigateToAuth(full_path || undefined)
32 + return
29 } 33 }
30 } 34 }
35 +
36 + try {
37 + const tabbarStore = useTabbarStore(pinia)
38 + await tabbarStore.ensureLoaded()
39 + } catch (error) {
40 + console.error('预加载底部导航配置失败:', error)
41 + }
31 }, 42 },
32 onShow() { 43 onShow() {
33 }, 44 },
34 }) 45 })
35 46
36 -App.use(createPinia()) 47 +App.use(pinia)
37 48
38 export default App 49 export default App
......
...@@ -6,9 +6,9 @@ ...@@ -6,9 +6,9 @@
6 <view class="app-tabbar__panel"> 6 <view class="app-tabbar__panel">
7 <view 7 <view
8 v-for="item in tabItems" 8 v-for="item in tabItems"
9 - :key="item.name" 9 + :key="item.key"
10 class="app-tabbar__item" 10 class="app-tabbar__item"
11 - :class="{ 'is-active': isActive(item.name) }" 11 + :class="{ 'is-active': isActive(item.key) }"
12 @tap="handleTabClick(item)" 12 @tap="handleTabClick(item)"
13 > 13 >
14 <view class="app-tabbar__item-inner"> 14 <view class="app-tabbar__item-inner">
...@@ -16,7 +16,7 @@ ...@@ -16,7 +16,7 @@
16 <component 16 <component
17 :is="item.icon" 17 :is="item.icon"
18 size="18" 18 size="18"
19 - :color="isActive(item.name) ? activeColor : inactiveColor" 19 + :color="isActive(item.key) ? activeColor : inactiveColor"
20 /> 20 />
21 </view> 21 </view>
22 <text class="app-tabbar__label">{{ item.title }}</text> 22 <text class="app-tabbar__label">{{ item.title }}</text>
...@@ -28,8 +28,10 @@ ...@@ -28,8 +28,10 @@
28 </template> 28 </template>
29 29
30 <script setup> 30 <script setup>
31 +import { computed, onMounted } from 'vue'
31 import Taro from '@tarojs/taro' 32 import Taro from '@tarojs/taro'
32 -import { Home, Message, My } from '@nutui/icons-vue-taro' 33 +import { Category, Home, My, Notice, Service } from '@nutui/icons-vue-taro'
34 +import { useTabbarStore } from '@/stores/tabbar'
33 35
34 const props = defineProps({ 36 const props = defineProps({
35 current: { 37 current: {
...@@ -41,36 +43,35 @@ const props = defineProps({ ...@@ -41,36 +43,35 @@ const props = defineProps({
41 const activeColor = '#a67939' 43 const activeColor = '#a67939'
42 const inactiveColor = '#8b95a7' 44 const inactiveColor = '#8b95a7'
43 45
44 -const tabItems = [ 46 +const tabbarStore = useTabbarStore()
45 - { 47 +
46 - name: 'home', 48 +const tabIconMap = {
47 - title: '首页', 49 + home: Home,
48 - icon: Home, 50 + message: Notice,
49 - url: '/pages/index/index', 51 + application: Category,
50 - }, 52 + mine: My,
51 - { 53 +}
52 - name: 'message', 54 +
53 - title: '消息', 55 +const tabItems = computed(() => (
54 - icon: Message, 56 + tabbarStore.visibleTabItems.map((item) => ({
55 - url: '/pages/message/index', 57 + ...item,
56 - }, 58 + icon: tabIconMap[item.key] || Service,
57 - { 59 + }))
58 - name: 'mine', 60 +))
59 - title: '我的',
60 - icon: My,
61 - url: '/pages/mine/index',
62 - },
63 -]
64 61
65 const isActive = (name) => name === props.current 62 const isActive = (name) => name === props.current
66 63
67 const handleTabClick = (item) => { 64 const handleTabClick = (item) => {
68 - if (!item?.url || item.name === props.current) { 65 + if (!item?.page_url || item.key === props.current) {
69 return 66 return
70 } 67 }
71 68
72 - Taro.redirectTo({ url: item.url }) 69 + Taro.redirectTo({ url: item.page_url })
73 } 70 }
71 +
72 +onMounted(() => {
73 + tabbarStore.ensureLoaded()
74 +})
74 </script> 75 </script>
75 76
76 <style lang="less"> 77 <style lang="less">
......
1 +export const getTabbarConfigFixture = () => ({
2 + tab_items: [
3 + {
4 + key: 'home',
5 + title: '首页',
6 + visible: true,
7 + },
8 + {
9 + key: 'message',
10 + title: '资讯',
11 + visible: true,
12 + },
13 + {
14 + key: 'application',
15 + title: '应用',
16 + visible: true,
17 + webview_url: 'https://oa-dev.onwall.cn/f/futian_home/?f=f&p=futian_list',
18 + webview_title: '应用',
19 + },
20 + {
21 + key: 'mine',
22 + title: '我的',
23 + visible: true,
24 + webview_url: 'https://oa-dev.onwall.cn/f/futian_home/?f=f&p=futian_list',
25 + webview_title: '我的',
26 + },
27 + ],
28 +})
...@@ -3,6 +3,7 @@ import { paymentMockHandlers } from './payment.mock' ...@@ -3,6 +3,7 @@ import { paymentMockHandlers } from './payment.mock'
3 import { commonMockHandlers } from './common.mock' 3 import { commonMockHandlers } from './common.mock'
4 import { messageMockHandlers } from './message.mock' 4 import { messageMockHandlers } from './message.mock'
5 import { homeMockHandlers } from './home.mock' 5 import { homeMockHandlers } from './home.mock'
6 +import { tabbarMockHandlers } from './tabbar.mock'
6 7
7 export const mockHandlers = [ 8 export const mockHandlers = [
8 ...authMockHandlers, 9 ...authMockHandlers,
...@@ -10,4 +11,5 @@ export const mockHandlers = [ ...@@ -10,4 +11,5 @@ export const mockHandlers = [
10 ...commonMockHandlers, 11 ...commonMockHandlers,
11 ...messageMockHandlers, 12 ...messageMockHandlers,
12 ...homeMockHandlers, 13 ...homeMockHandlers,
14 + ...tabbarMockHandlers,
13 ] 15 ]
......
1 +import { getTabbarConfigFixture } from '../fixtures/tabbar.fixture'
2 +import { buildMockSuccess } from '../shared/response'
3 +
4 +export const tabbarMockHandlers = [
5 + {
6 + action: 'tabbar',
7 + type: 'config',
8 + method: 'GET',
9 + handle: () => buildMockSuccess(getTabbarConfigFixture(), '底部导航配置获取成功 (mock)'),
10 + },
11 +]
1 +export default {
2 + navigationBarTitleText: '应用',
3 +}
1 +<template>
2 + <web-view v-if="preview_url" :src="preview_url" />
3 + <view v-else class="tab-webview-page">
4 + <view class="empty-card">
5 + <text class="empty-title">暂未配置应用地址</text>
6 + <text class="empty-desc">
7 + 当前应用页还没有收到可用的外部链接,请先检查底部导航配置接口返回。
8 + </text>
9 + </view>
10 + </view>
11 +</template>
12 +
13 +<script setup>
14 +import { ref } from 'vue'
15 +import Taro, { useLoad } from '@tarojs/taro'
16 +import { useTabbarStore } from '@/stores/tabbar'
17 +
18 +const tabbarStore = useTabbarStore()
19 +const preview_url = ref('')
20 +const default_page_title = '应用'
21 +
22 +const initPage = async () => {
23 + await tabbarStore.ensureLoaded()
24 +
25 + const currentTab = tabbarStore.getTabItem('application')
26 + preview_url.value = currentTab?.webview_url || ''
27 +
28 + Taro.setNavigationBarTitle({
29 + title: currentTab?.webview_title || currentTab?.title || default_page_title,
30 + })
31 +
32 + if (!preview_url.value) {
33 + Taro.showToast({
34 + title: '暂未配置应用地址',
35 + icon: 'none',
36 + })
37 + }
38 +}
39 +
40 +useLoad(() => {
41 + initPage()
42 +})
43 +</script>
44 +
45 +<style lang="less">
46 +.tab-webview-page {
47 + min-height: 100vh;
48 + padding: 32rpx 24rpx;
49 + box-sizing: border-box;
50 + background:
51 + radial-gradient(circle at top right, rgba(166, 121, 57, 0.16), transparent 30%),
52 + linear-gradient(180deg, #fffaf3 0%, #f6f7fb 100%);
53 +}
54 +
55 +.empty-card {
56 + background: rgba(255, 255, 255, 0.94);
57 + border: 2rpx solid rgba(166, 121, 57, 0.08);
58 + border-radius: 28rpx;
59 + padding: 32rpx;
60 + box-sizing: border-box;
61 + box-shadow: 0 20rpx 60rpx rgba(15, 23, 42, 0.06);
62 +}
63 +
64 +.empty-title {
65 + display: block;
66 + font-size: 36rpx;
67 + font-weight: 700;
68 + color: #111827;
69 +}
70 +
71 +.empty-desc {
72 + display: block;
73 + margin-top: 16rpx;
74 + font-size: 26rpx;
75 + line-height: 1.7;
76 + color: #6b7280;
77 +}
78 +</style>
1 export default { 1 export default {
2 - navigationBarTitleText: '消息详情', 2 + navigationBarTitleText: '资讯详情',
3 } 3 }
......
...@@ -2,15 +2,15 @@ ...@@ -2,15 +2,15 @@
2 <view class="message-detail-page"> 2 <view class="message-detail-page">
3 <view class="page-content"> 3 <view class="page-content">
4 <view class="hero-card"> 4 <view class="hero-card">
5 - <text class="hero-title">消息详情</text> 5 + <text class="hero-title">资讯详情</text>
6 <text class="hero-desc"> 6 <text class="hero-desc">
7 - 进入详情页后,当前消息会在 mock 中自动标记为已读,返回列表即可看到状态变化。 7 + 进入详情页后,当前资讯会在 mock 中自动标记为已读,返回列表即可看到状态变化。
8 </text> 8 </text>
9 </view> 9 </view>
10 10
11 <view v-if="loading" class="placeholder-card"> 11 <view v-if="loading" class="placeholder-card">
12 <text class="section-title">加载中</text> 12 <text class="section-title">加载中</text>
13 - <text class="section-desc">正在获取消息详情...</text> 13 + <text class="section-desc">正在获取资讯详情...</text>
14 </view> 14 </view>
15 15
16 <view v-else-if="detail" class="detail-card"> 16 <view v-else-if="detail" class="detail-card">
...@@ -23,13 +23,13 @@ ...@@ -23,13 +23,13 @@
23 </view> 23 </view>
24 24
25 <view v-else class="placeholder-card"> 25 <view v-else class="placeholder-card">
26 - <text class="section-title">未找到消息</text> 26 + <text class="section-title">未找到资讯</text>
27 <text class="section-desc"> 27 <text class="section-desc">
28 - 当前消息不存在,或者真实接口还没有返回这条详情数据。 28 + 当前资讯不存在,或者真实接口还没有返回这条详情数据。
29 </text> 29 </text>
30 </view> 30 </view>
31 31
32 - <button class="back-btn" @tap="goBack">返回消息列表</button> 32 + <button class="back-btn" @tap="goBack">返回资讯列表</button>
33 </view> 33 </view>
34 </view> 34 </view>
35 </template> 35 </template>
...@@ -54,11 +54,11 @@ const fetchMessageDetail = async (id) => { ...@@ -54,11 +54,11 @@ const fetchMessageDetail = async (id) => {
54 54
55 detail.value = null 55 detail.value = null
56 Taro.showToast({ 56 Taro.showToast({
57 - title: response?.msg || '获取详情失败', 57 + title: response?.msg || '获取资讯详情失败',
58 icon: 'none', 58 icon: 'none',
59 }) 59 })
60 } catch (error) { 60 } catch (error) {
61 - console.error('获取消息详情失败:', error) 61 + console.error('获取资讯详情失败:', error)
62 detail.value = null 62 detail.value = null
63 } finally { 63 } finally {
64 loading.value = false 64 loading.value = false
......
1 export default { 1 export default {
2 - navigationBarTitleText: '消息', 2 + navigationBarTitleText: '资讯',
3 } 3 }
......
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
3 <view class="page-content"> 3 <view class="page-content">
4 <view class="hero-card"> 4 <view class="hero-card">
5 <view class="hero-head"> 5 <view class="hero-head">
6 - <text class="hero-title">消息</text> 6 + <text class="hero-title">资讯</text>
7 <text class="env-tag" :class="{ mock: current_env_use_mock }"> 7 <text class="env-tag" :class="{ mock: current_env_use_mock }">
8 {{ current_env_label }} 8 {{ current_env_label }}
9 </text> 9 </text>
...@@ -17,7 +17,7 @@ ...@@ -17,7 +17,7 @@
17 <view> 17 <view>
18 <text class="section-title">列表状态</text> 18 <text class="section-title">列表状态</text>
19 <text class="section-desc"> 19 <text class="section-desc">
20 - 共 {{ total }} 条消息,未读 {{ unread_count }} 条。进入详情页后,当前消息会在 mock 中自动变成已读。 20 + 共 {{ total }} 条资讯,未读 {{ unread_count }} 条。进入详情页后,当前资讯会在 mock 中自动变成已读。
21 </text> 21 </text>
22 </view> 22 </view>
23 <button class="refresh-btn" :loading="loading" @tap="handleRefresh"> 23 <button class="refresh-btn" :loading="loading" @tap="handleRefresh">
...@@ -28,14 +28,14 @@ ...@@ -28,14 +28,14 @@
28 <view v-if="loading && !message_list.length" class="placeholder-card"> 28 <view v-if="loading && !message_list.length" class="placeholder-card">
29 <text class="section-title">加载中</text> 29 <text class="section-title">加载中</text>
30 <text class="section-desc"> 30 <text class="section-desc">
31 - 正在拉取消息列表... 31 + 正在拉取资讯列表...
32 </text> 32 </text>
33 </view> 33 </view>
34 34
35 <view v-else-if="!message_list.length" class="placeholder-card"> 35 <view v-else-if="!message_list.length" class="placeholder-card">
36 - <text class="section-title">暂无消息</text> 36 + <text class="section-title">暂无资讯</text>
37 <text class="section-desc"> 37 <text class="section-desc">
38 - 当前接口还没有返回消息内容,可以先在 mock 里补结构,再回到这个页面验证展示效果。 38 + 当前接口还没有返回资讯内容,可以先在 mock 里补结构,再回到这个页面验证展示效果。
39 </text> 39 </text>
40 </view> 40 </view>
41 41
...@@ -110,7 +110,7 @@ const fetchMessageList = async (nextPage = 0, append = false) => { ...@@ -110,7 +110,7 @@ const fetchMessageList = async (nextPage = 0, append = false) => {
110 110
111 if (response?.code !== 1) { 111 if (response?.code !== 1) {
112 Taro.showToast({ 112 Taro.showToast({
113 - title: response?.msg || '获取消息失败', 113 + title: response?.msg || '获取资讯失败',
114 icon: 'none', 114 icon: 'none',
115 }) 115 })
116 return 116 return
...@@ -122,7 +122,7 @@ const fetchMessageList = async (nextPage = 0, append = false) => { ...@@ -122,7 +122,7 @@ const fetchMessageList = async (nextPage = 0, append = false) => {
122 has_more.value = !!response?.data?.has_more 122 has_more.value = !!response?.data?.has_more
123 page.value = nextPage 123 page.value = nextPage
124 } catch (error) { 124 } catch (error) {
125 - console.error('获取消息列表失败:', error) 125 + console.error('获取资讯列表失败:', error)
126 } finally { 126 } finally {
127 loading.value = false 127 loading.value = false
128 loading_more.value = false 128 loading_more.value = false
......
1 +export default {
2 + navigationBarTitleText: '我的备份',
3 +}
This diff is collapsed. Click to expand it.
This diff is collapsed. Click to expand it.
1 +import { defineStore } from 'pinia'
2 +import { getTabbarConfigAPI } from '@/api/tabbar'
3 +import {
4 + getDefaultTabbarItem,
5 + getDefaultTabbarItems,
6 + normalizeTabbarItems,
7 +} from '@/utils/tabbar'
8 +
9 +let tabbarRequestPromise = null
10 +
11 +export const useTabbarStore = defineStore('tabbar', {
12 + state: () => ({
13 + tabItems: getDefaultTabbarItems(),
14 + loaded: false,
15 + loading: false,
16 + }),
17 + getters: {
18 + visibleTabItems: (state) => state.tabItems.filter((item) => item.visible !== false),
19 + getTabItem: (state) => (key) => {
20 + const normalizedKey = String(key || '').trim()
21 + return state.tabItems.find((item) => item.key === normalizedKey) || getDefaultTabbarItem(normalizedKey)
22 + },
23 + },
24 + actions: {
25 + async fetchTabbarConfig(force = false) {
26 + if (!force && this.loaded) {
27 + return this.tabItems
28 + }
29 +
30 + if (this.loading && tabbarRequestPromise) {
31 + return tabbarRequestPromise
32 + }
33 +
34 + this.loading = true
35 +
36 + tabbarRequestPromise = (async () => {
37 + try {
38 + const response = await getTabbarConfigAPI()
39 + const rawTabItems = response?.data?.tab_items || response?.data?.tabs || response?.data?.list || []
40 +
41 + if (response?.code === 1) {
42 + this.tabItems = normalizeTabbarItems(rawTabItems)
43 + } else {
44 + this.tabItems = getDefaultTabbarItems()
45 + }
46 + } catch (error) {
47 + console.error('获取底部导航配置失败:', error)
48 + this.tabItems = getDefaultTabbarItems()
49 + } finally {
50 + this.loaded = true
51 + this.loading = false
52 + tabbarRequestPromise = null
53 + }
54 +
55 + return this.tabItems
56 + })()
57 +
58 + return tabbarRequestPromise
59 + },
60 + async ensureLoaded() {
61 + if (this.loaded) {
62 + return this.tabItems
63 + }
64 +
65 + return this.fetchTabbarConfig()
66 + },
67 + },
68 +})
1 +const defaultTabbarItemMap = {
2 + home: {
3 + key: 'home',
4 + title: '首页',
5 + visible: true,
6 + page_url: '/pages/index/index',
7 + webview_url: '',
8 + webview_title: '首页',
9 + },
10 + message: {
11 + key: 'message',
12 + title: '资讯',
13 + visible: true,
14 + page_url: '/pages/message/index',
15 + webview_url: '',
16 + webview_title: '资讯',
17 + },
18 + application: {
19 + key: 'application',
20 + title: '应用',
21 + visible: true,
22 + page_url: '/pages/application/index',
23 + webview_url: 'https://oa-dev.onwall.cn/f/futian_home/?f=f&p=futian_list',
24 + webview_title: '应用',
25 + },
26 + mine: {
27 + key: 'mine',
28 + title: '我的',
29 + visible: true,
30 + page_url: '/pages/mine/index',
31 + webview_url: 'https://oa-dev.onwall.cn/f/futian_home/?f=f&p=futian_list',
32 + webview_title: '我的',
33 + },
34 +}
35 +
36 +export const TABBAR_ORDER = ['home', 'message', 'application', 'mine']
37 +
38 +const normalizeVisibleValue = (rawValue, fallbackValue = true) => {
39 + if (typeof rawValue === 'boolean') {
40 + return rawValue
41 + }
42 +
43 + if (rawValue === 1 || rawValue === '1') {
44 + return true
45 + }
46 +
47 + if (rawValue === 0 || rawValue === '0') {
48 + return false
49 + }
50 +
51 + return fallbackValue
52 +}
53 +
54 +export const getDefaultTabbarItem = (key) => {
55 + const normalizedKey = String(key || '').trim()
56 + const fallbackItem = defaultTabbarItemMap[normalizedKey]
57 +
58 + if (!fallbackItem) {
59 + return null
60 + }
61 +
62 + return {
63 + ...fallbackItem,
64 + }
65 +}
66 +
67 +export const getDefaultTabbarItems = () => (
68 + TABBAR_ORDER
69 + .map((key) => getDefaultTabbarItem(key))
70 + .filter(Boolean)
71 +)
72 +
73 +export const normalizeTabbarItem = (rawItem = {}) => {
74 + const normalizedKey = String(rawItem.key || rawItem.name || '').trim()
75 + const fallbackItem = getDefaultTabbarItem(normalizedKey)
76 +
77 + if (!fallbackItem) {
78 + return null
79 + }
80 +
81 + return {
82 + ...fallbackItem,
83 + ...rawItem,
84 + key: fallbackItem.key,
85 + title: String(rawItem.title || fallbackItem.title),
86 + page_url: String(rawItem.page_url || fallbackItem.page_url || ''),
87 + webview_url: String(rawItem.webview_url || rawItem.link_url || rawItem.url || fallbackItem.webview_url || ''),
88 + webview_title: String(rawItem.webview_title || rawItem.link_title || rawItem.title || fallbackItem.webview_title || ''),
89 + visible: normalizeVisibleValue(
90 + rawItem.visible ?? rawItem.is_show ?? rawItem.show,
91 + fallbackItem.visible,
92 + ),
93 + }
94 +}
95 +
96 +export const normalizeTabbarItems = (rawItems = []) => {
97 + if (!Array.isArray(rawItems) || !rawItems.length) {
98 + return getDefaultTabbarItems()
99 + }
100 +
101 + const normalizedItems = rawItems
102 + .map((item) => normalizeTabbarItem(item))
103 + .filter(Boolean)
104 +
105 + return normalizedItems.length ? normalizedItems : getDefaultTabbarItems()
106 +}