feat: 添加小程序跳转支持并重构底部导航栏
- 新增 miniProgram.js 工具模块,提供 H5 环境下检测微信小程序 webview 及页面跳转能力 - 重构 AppTabbar 组件,替换 NutUI Tabbar 为自定义实现,优化视觉样式 - 在 H5 环境下优先尝试使用小程序桥接跳转页面,增强多端体验一致性
Showing
2 changed files
with
199 additions
and
40 deletions
| 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" | ||
| 9 | - :unactive-color="inactiveColor" | ||
| 10 | - @tab-switch="handleTabSwitch" | ||
| 11 | - > | ||
| 12 | - <nut-tabbar-item | ||
| 13 | v-for="item in tabItems" | 8 | v-for="item in tabItems" |
| 14 | :key="item.name" | 9 | :key="item.name" |
| 15 | - :name="item.name" | 10 | + class="app-tabbar__item" |
| 16 | - :tab-title="item.title" | 11 | + :class="{ 'is-active': isActive(item.name) }" |
| 12 | + @tap="handleTabClick(item)" | ||
| 17 | > | 13 | > |
| 18 | - <template #icon="{ active }"> | 14 | + <view class="app-tabbar__item-inner"> |
| 15 | + <view class="app-tabbar__icon"> | ||
| 19 | <component | 16 | <component |
| 20 | :is="item.icon" | 17 | :is="item.icon" |
| 21 | size="18" | 18 | size="18" |
| 22 | - :color="active ? activeColor : inactiveColor" | 19 | + :color="isActive(item.name) ? activeColor : inactiveColor" |
| 23 | /> | 20 | /> |
| 24 | - </template> | 21 | + </view> |
| 25 | - </nut-tabbar-item> | 22 | + <text class="app-tabbar__label">{{ item.title }}</text> |
| 26 | - </nut-tabbar> | 23 | + </view> |
| 24 | + </view> | ||
| 25 | + </view> | ||
| 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 | ||
| 68 | -const handleTabSwitch = (...args) => { | 69 | +const handleTabClick = async (item) => { |
| 69 | - const nextTab = args[1] | 70 | + if (!item?.url || item.name === props.current) { |
| 70 | - const target = tabItems.find((item) => item.name === nextTab) | 71 | + return |
| 72 | + } | ||
| 71 | 73 | ||
| 72 | - if (!target || target.name === props.current) { | 74 | + if (isH5Env()) { |
| 75 | + try { | ||
| 76 | + const has_navigated = await navigateToMiniProgramPage(item.url) | ||
| 77 | + | ||
| 78 | + if (has_navigated) { | ||
| 73 | return | 79 | return |
| 74 | } | 80 | } |
| 81 | + } catch (error) { | ||
| 82 | + console.error('H5 跳转小程序页面失败:', error) | ||
| 83 | + } | ||
| 84 | + } | ||
| 75 | 85 | ||
| 76 | - Taro.redirectTo({ | 86 | + Taro.redirectTo({ url: item.url }) |
| 77 | - url: target.url, | ||
| 78 | - }) | ||
| 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> | ... | ... |
src/utils/miniProgram.js
0 → 100644
| 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 | +} |
-
Please register or login to post a comment