hookehuyr

feat: 添加小程序跳转支持并重构底部导航栏

- 新增 miniProgram.js 工具模块,提供 H5 环境下检测微信小程序 webview 及页面跳转能力
- 重构 AppTabbar 组件,替换 NutUI Tabbar 为自定义实现,优化视觉样式
- 在 H5 环境下优先尝试使用小程序桥接跳转页面,增强多端体验一致性
1 <template> 1 <template>
2 <view class="app-tabbar"> 2 <view class="app-tabbar">
3 - <nut-tabbar 3 + <view class="app-tabbar__placeholder" />
4 - :model-value="activeTab" 4 +
5 - bottom 5 + <view class="app-tabbar__wrap">
6 - placeholder 6 + <view class="app-tabbar__panel">
7 - safe-area-inset-bottom 7 + <view
8 - :active-color="activeColor" 8 + v-for="item in tabItems"
9 - :unactive-color="inactiveColor" 9 + :key="item.name"
10 - @tab-switch="handleTabSwitch" 10 + class="app-tabbar__item"
11 - > 11 + :class="{ 'is-active': isActive(item.name) }"
12 - <nut-tabbar-item 12 + @tap="handleTabClick(item)"
13 - v-for="item in tabItems" 13 + >
14 - :key="item.name" 14 + <view class="app-tabbar__item-inner">
15 - :name="item.name" 15 + <view class="app-tabbar__icon">
16 - :tab-title="item.title" 16 + <component
17 - > 17 + :is="item.icon"
18 - <template #icon="{ active }"> 18 + size="18"
19 - <component 19 + :color="isActive(item.name) ? activeColor : inactiveColor"
20 - :is="item.icon" 20 + />
21 - size="18" 21 + </view>
22 - :color="active ? activeColor : inactiveColor" 22 + <text class="app-tabbar__label">{{ item.title }}</text>
23 - /> 23 + </view>
24 - </template> 24 + </view>
25 - </nut-tabbar-item> 25 + </view>
26 - </nut-tabbar> 26 + </view>
27 </view> 27 </view>
28 </template> 28 </template>
29 29
30 <script setup> 30 <script setup>
31 -import { computed } from 'vue'
32 import Taro from '@tarojs/taro' 31 import Taro from '@tarojs/taro'
33 import { Home, Message, My } from '@nutui/icons-vue-taro' 32 import { Home, Message, My } from '@nutui/icons-vue-taro'
33 +// 迁移到独立 H5 项目时,别忘了把 miniProgram helper 一起带走。
34 +import { isH5Env, navigateToMiniProgramPage } from '@/utils/miniProgram'
34 35
35 const props = defineProps({ 36 const props = defineProps({
36 current: { 37 current: {
...@@ -63,44 +64,88 @@ const tabItems = [ ...@@ -63,44 +64,88 @@ const tabItems = [
63 }, 64 },
64 ] 65 ]
65 66
66 -const activeTab = computed(() => props.current) 67 +const isActive = (name) => name === props.current
67 -
68 -const handleTabSwitch = (...args) => {
69 - const nextTab = args[1]
70 - const target = tabItems.find((item) => item.name === nextTab)
71 68
72 - if (!target || target.name === props.current) { 69 +const handleTabClick = async (item) => {
70 + if (!item?.url || item.name === props.current) {
73 return 71 return
74 } 72 }
75 73
76 - Taro.redirectTo({ 74 + if (isH5Env()) {
77 - url: target.url, 75 + try {
78 - }) 76 + const has_navigated = await navigateToMiniProgramPage(item.url)
77 +
78 + if (has_navigated) {
79 + return
80 + }
81 + } catch (error) {
82 + console.error('H5 跳转小程序页面失败:', error)
83 + }
84 + }
85 +
86 + Taro.redirectTo({ url: item.url })
79 } 87 }
80 </script> 88 </script>
81 89
82 <style lang="less"> 90 <style lang="less">
83 .app-tabbar { 91 .app-tabbar {
84 - :deep(.nut-tabbar) { 92 + .app-tabbar__placeholder {
85 - left: 24rpx; 93 + height: 132rpx;
86 - right: 24rpx; 94 + flex-shrink: 0;
87 - bottom: 24rpx; 95 + }
88 - width: auto; 96 +
89 - border: 2rpx solid rgba(166, 121, 57, 0.12); 97 + .app-tabbar__wrap {
90 - border-radius: 999rpx; 98 + position: fixed;
91 - background: rgba(255, 255, 255, 0.96); 99 + left: 0;
92 - box-shadow: 0 24rpx 60rpx rgba(15, 23, 42, 0.12); 100 + right: 0;
93 - overflow: hidden; 101 + bottom: 0;
102 + z-index: 100;
103 + box-sizing: border-box;
94 } 104 }
95 105
96 - :deep(.nut-tabbar-item_icon-box) { 106 + .app-tabbar__panel {
97 - padding-top: 8rpx; 107 + display: flex;
108 + align-items: stretch;
109 + min-height: 132rpx;
110 + padding: 16rpx 24rpx;
111 + border-top: 2rpx solid rgba(166, 121, 57, 0.12);
112 + background: rgba(255, 255, 255, 0.98);
113 + box-sizing: border-box;
114 + backdrop-filter: blur(12rpx);
98 } 115 }
99 116
100 - :deep(.nut-tabbar-item_icon-box_nav-word) { 117 + .app-tabbar__item {
101 - margin-top: 8rpx; 118 + flex: 1;
119 + min-width: 0;
120 + }
121 +
122 + .app-tabbar__item-inner {
123 + display: flex;
124 + flex-direction: column;
125 + align-items: center;
126 + justify-content: center;
127 + gap: 10rpx;
128 + min-height: 100rpx;
129 + border-radius: 20rpx;
130 + color: #8b95a7;
131 + transition: background-color 0.2s ease, color 0.2s ease, transform 0.2s ease;
132 + }
133 +
134 + .app-tabbar__icon {
135 + display: flex;
136 + align-items: center;
137 + justify-content: center;
138 + line-height: 1;
139 + }
140 +
141 + .app-tabbar__label {
102 font-size: 22rpx; 142 font-size: 22rpx;
103 font-weight: 600; 143 font-weight: 600;
144 + line-height: 1.2;
145 + }
146 +
147 + .app-tabbar__item.is-active .app-tabbar__item-inner {
148 + color: #a67939;
104 } 149 }
105 } 150 }
106 </style> 151 </style>
......
1 +const WECHAT_JS_SDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js'
2 +
3 +let wechat_bridge_loading_promise = null
4 +
5 +const isBrowserEnv = () => typeof window !== 'undefined' && typeof document !== 'undefined'
6 +
7 +const getWechatBridge = () => {
8 + if (typeof window === 'undefined') {
9 + return null
10 + }
11 +
12 + return window.wx || window.jWeixin || null
13 +}
14 +
15 +const getMiniProgramBridge = () => getWechatBridge()?.miniProgram || null
16 +
17 +export const isH5Env = () => isBrowserEnv()
18 +
19 +const ensureWechatBridge = async () => {
20 + if (!isH5Env()) {
21 + return null
22 + }
23 +
24 + const current_bridge = getWechatBridge()
25 +
26 + if (current_bridge) {
27 + return current_bridge
28 + }
29 +
30 + if (!wechat_bridge_loading_promise) {
31 + wechat_bridge_loading_promise = new Promise((resolve, reject) => {
32 + const script = document.createElement('script')
33 +
34 + script.src = WECHAT_JS_SDK_URL
35 + script.async = true
36 + script.onload = () => resolve(getWechatBridge())
37 + script.onerror = () => reject(new Error('微信 JS SDK 加载失败'))
38 +
39 + document.head.appendChild(script)
40 + })
41 + }
42 +
43 + try {
44 + return await wechat_bridge_loading_promise
45 + } catch (error) {
46 + wechat_bridge_loading_promise = null
47 + throw error
48 + }
49 +}
50 +
51 +export const isWechatMiniProgramWebview = () => new Promise((resolve) => {
52 + const check_env = async () => {
53 + if (!isH5Env()) {
54 + resolve(false)
55 + return
56 + }
57 +
58 + try {
59 + await ensureWechatBridge()
60 + } catch (error) {
61 + console.error('微信 JS SDK 初始化失败:', error)
62 + resolve(false)
63 + return
64 + }
65 +
66 + const mini_program_bridge = getMiniProgramBridge()
67 +
68 + if (!mini_program_bridge) {
69 + resolve(false)
70 + return
71 + }
72 +
73 + if (typeof mini_program_bridge.getEnv !== 'function') {
74 + resolve(true)
75 + return
76 + }
77 +
78 + mini_program_bridge.getEnv((result = {}) => {
79 + resolve(!!result.miniprogram)
80 + })
81 + }
82 +
83 + check_env().catch(() => {
84 + resolve(false)
85 + })
86 +})
87 +
88 +export const navigateToMiniProgramPage = async (url = '') => {
89 + const normalized_url = String(url || '').trim()
90 +
91 + if (!normalized_url) {
92 + return false
93 + }
94 +
95 + const is_supported_webview = await isWechatMiniProgramWebview()
96 +
97 + if (!is_supported_webview) {
98 + return false
99 + }
100 +
101 + const mini_program_bridge = getMiniProgramBridge()
102 +
103 + if (typeof mini_program_bridge?.navigateTo !== 'function') {
104 + return false
105 + }
106 +
107 + return new Promise((resolve, reject) => {
108 + mini_program_bridge.navigateTo({
109 + url: normalized_url,
110 + success: () => resolve(true),
111 + fail: (error) => reject(error),
112 + })
113 + })
114 +}