Files
lucky_shop/components-diy/diy-bottom-nav.vue
ZF sun 55a9ed5946 refactor: 重构底部导航高亮逻辑,新增跨平台兼容与工具脚本
1. 抽离底部导航归属逻辑为纯函数resolveEntryTab,优化分包页面高亮规则,对齐iOS HIG规范
2. 新增页面Tab通配符映射,简化PAGE_TAB_MAP维护
3. 修复鸿蒙平台webview返回按钮适配问题
4. 新增pages.json校验与导航标题自动注入脚本
5. 优化启动页动画性能,提升合成渲染效率
6. 修复diy-picture组件v-for缺少key的警告
7. 清理鸿蒙平台多余的分包构建配置
8. 更新统计数据与store注释说明
2026-07-16 11:47:29 +08:00

458 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view data-component-name="diy-bottom-nav" v-if="shouldShowTabBar">
<view class="tab-bar" :style="{ backgroundColor: tabBarList.backgroundColor }">
<view class="tabbar-border"></view>
<view class="item" v-for="(item, index) in tabBarList.list" :key="item.id" @tap.stop="redirectTo(item.link)">
<view class="bd">
<view class="icon" v-if="tabBarList.type == 1 || tabBarList.type == 2"
:animation="isCartItem(item.link) ? cartAnimation : null"
:id="isCartItem(item.link) ? 'tabbarCart' : null">
<block v-if="verify(item.link)">
<image v-if="item.selected_icon_type == 'img'"
:src="$util.img(item.selectedIconPath)" />
<diy-icon v-if="item.selected_icon_type == 'icon'" :icon="item.selectedIconPath"
:value="item.selected_style ? item.selected_style : null"></diy-icon>
</block>
<block v-else>
<image v-if="item.icon_type == 'img'" :src="$util.img(item.iconPath)" />
<diy-icon v-if="item.icon_type == 'icon'" :icon="item.iconPath"
:value="item.style ? item.style : null"></diy-icon>
</block>
<view class="cart-count-mark font-size-activity-tag"
:class="{ max: cartNumber > 99 }"
:style="{ background: 'var(--price-color)' }"
v-if="isCartItem(item.link) && cartNumber > 0">
{{ cartNumber > 99 ? '99+' : cartNumber }}
</view>
</view>
<view class="label"
v-if="(tabBarList.type == 1 || tabBarList.type == 3) && tabBarList.theme == 'diy'"
:style="{ color: verify(item.link) ? tabBarList.textHoverColor : tabBarList.textColor }">
{{ $langConfig.getField(item, 'text') }}
</view>
<view class="label"
v-if="(tabBarList.type == 1 || tabBarList.type == 3) && tabBarList.theme == 'default'"
:style="{ color: verify(item.link) ? 'var(--base-color)' : '#333333' }">
{{ $langConfig.getField(item, 'text') }}
</view>
</view>
</view>
</view>
<!-- 解决fixed定位后底部导航栏塌陷问题 -->
<view class="tab-bar-placeholder"></view>
</view>
</template>
<script>
import { adaptSubpackageUrl, checkTabBarActive, decodeEntity } from '@/common/js/util.js'
import { resolveEntryTab } from '@/common/js/util-tabbar.js'
import DiyMinx from './minx.js'
// 底部导航栏
export default {
name: 'diy-bottom-nav',
props: {
value: {
type: Object
},
name: {
type: String,
default: ''
}
},
mixins: [DiyMinx],
data() {
return {
currentRoute: '', //当前页面路径
jumpFlag: true, //是否可以跳转,防止重复点击
cartAnimation: {}
};
},
mounted() {
this.updateCurrentRoute();
this.$nextTick(() => {
if (!this.$store.state.cartPosition) {
let query = uni.createSelectorQuery().in(this);
query.select('#tabbarCart')
.boundingClientRect(data => {
if (data) this.$store.commit('setCartPosition', data);
}).exec();
query.select('.tab-bar')
.boundingClientRect(data => {
if (data) this.$store.commit('setTabBarHeight', data.height +
'px');
}).exec();
}
});
// 监听页面显示事件,更新当前路由
this.$on('hook:onShow', () => {
this.updateCurrentRoute();
});
},
computed: {
// 当前语言(响应式):通过 componentRefresh 建立依赖,切语言时自动更新
// 不能直接用 uni.getStorageSync 作为 data,因为它不是响应式的
currentLang() {
// 通过 componentRefresh 建立响应式依赖
// 切语言时 store 中的 componentRefresh 会 +1,触发重新计算
this.componentRefresh;
return uni.getStorageSync("lang") || 'zh-cn';
},
cartChange() {
return this.$store.state.cartChange;
},
packageEntryTab() {
return this.$store.state.packageEntryTab;
},
// 响应 tabBarList 异步加载: 后端返回前 updateCurrentRoute 是 no-op,
// tabBarList 就绪后必须重跑一次, 否则冷启动+秒点链接会出现"先不高亮→再突然高亮"
tabBarReady() {
return this.tabBarList && this.tabBarList.list && this.tabBarList.list.length > 0;
},
},
watch: {
cartChange: function (nval, oval) {
if (nval > oval) {
let animation = uni.createAnimation({
duration: 200,
timingFunction: 'ease'
});
animation.scale(1.2).step();
this.cartAnimation = animation.export();
setTimeout(() => {
animation.scale(1).step();
this.cartAnimation = animation.export();
}, 300);
}
},
// tabBarList 异步加载完成后, 补一次 updateCurrentRoute
// 修复: 冷启动时用户在 tabBarList 就绪前点击链接, 第一次 updateCurrentRoute 是 no-op
tabBarReady: {
handler(ready) {
if (ready) {
this.updateCurrentRoute();
}
},
immediate: false,
},
},
methods: {
// 统一归一化路径:currentPage.route 不带前导 '/',但 tab 的 wap_url 带前导 '/'
// 统一加前导 '/' 后再 split,确保 parts[1]=分包名、parts[2]=页面名
_normalizeRoute(route) {
if (!route) return '';
return route.startsWith('/') ? route : '/' + route;
},
// 从归一化路径中提取分包名(parts[1])
_getPackageName(normalizedRoute) {
const parts = normalizedRoute.split('?')[0].split('/');
return parts[1] || '';
},
// 判断某个链接是否指向购物车页面
isCartItem(link) {
if (!link || !link.wap_url) return false;
return adaptSubpackageUrl(link.wap_url).split('?')[0] === '/pages_goods/cart';
},
// 更新当前路由,追踪每个分包的入口 tab
updateCurrentRoute() {
const currentPages = getCurrentPages();
const currentPage = currentPages[currentPages.length - 1];
if (!currentPage || !currentPage.route) return;
// 归一化:补全前导 '/',确保后续 split('/') 后 parts[1]=分包名
const newRoute = this._normalizeRoute(currentPage.route);
const currentPackageName = newRoute.split('?')[0].split('/')[1] || '';
if (this.tabBarList && this.tabBarList.list && currentPackageName) {
// 收集历史栈路由 (排除当前页, 已 _normalizeRoute)
// 注意: 组件里直接传 raw route 给 resolveEntryTab,
// 内部 adaptSubpackageUrl 会兜底处理老路径 /pages/member/ 等
const historyRoutes = currentPages.length >= 2
? currentPages.slice(0, -1).map((p) =>
p && p.route ? this._normalizeRoute(p.route) : null)
: [];
// 调用纯函数 resolveEntryTab 完成 4 步查找
// 规则: 从哪个 TabBar 出发, 非 tab 页就归属哪个 TabBar 高亮
// 修复: home → diy 微页面 等跨分包导航被"同分包第一个 tab"误判的问题
const entryTab = resolveEntryTab({
currentPageRoute: newRoute,
tabBarList: this.tabBarList,
historyRoutes,
// 传 NO_TAB_PAGES 进去, 让 login/pay/AI 等 modal 页不参与归属判定
// 例子: home → login → diy 应算作 home → diy (login 是 modal)
noTabPages: this.$util && this.$util.NO_TAB_PAGES ? this.$util.NO_TAB_PAGES : null,
});
if (entryTab) {
this.$store.commit('setPackageEntryTab', {
...this.packageEntryTab,
[currentPackageName]: entryTab
});
}
}
this.currentRoute = newRoute;
},
redirectTo(link) {
this.$emit('callback');
const redirectLink = link ? { ...link } : link;
if (link && link.wap_url) {
const decodedUrl = decodeEntity(link.wap_url);
const adaptedUrl = adaptSubpackageUrl(decodedUrl);
const packageName = this._getPackageName(adaptedUrl);
if (packageName) {
this.$store.commit('setPackageEntryTab', {
...this.packageEntryTab,
[packageName]: adaptedUrl.split('?')[0]
});
}
// 若目标页面不是 TabBar 根页面,追加 from_tab 来源参数,
// 用于非 Tab 页面的 TabBar 高亮记忆
if (this.tabBarList && this.tabBarList.list) {
const tabUrls = this.tabBarList.list
.filter(item => item.link && item.link.wap_url)
.map(item => adaptSubpackageUrl(decodeEntity(item.link.wap_url)).split('?')[0]);
if (!tabUrls.includes(adaptedUrl.split('?')[0])) {
const currentPages = getCurrentPages();
const currentPage = currentPages[currentPages.length - 1];
const currentTab = currentPage && currentPage.route
? tabUrls.find(tabUrl => checkTabBarActive(tabUrl, '/' + currentPage.route, this.tabBarList, this.packageEntryTab))
: null;
if (currentTab) {
redirectLink.from_tab = currentTab;
}
}
}
}
this.$util.diyRedirectTo(redirectLink);
},
verify(link) {
if (link == null || link == '' || !link.wap_url) return false;
const currentPages = getCurrentPages();
const currentPage = currentPages[currentPages.length - 1];
const currentPageRoute = currentPage && currentPage.route
? this._normalizeRoute(currentPage.route)
: '';
// 读取当前页面 query 参数(兼容小程序与 H5)
let currentPageQuery = {};
if (currentPage) {
if (currentPage.options) {
currentPageQuery = currentPage.options;
} else if (currentPage.$page && currentPage.$page.options) {
currentPageQuery = currentPage.$page.options;
} else if (currentPage.$route && currentPage.$route.query) {
currentPageQuery = currentPage.$route.query;
}
}
if (this.name === 'DIY_VIEW_INDEX') {
// DIY_VIEW_INDEX 短路: 当 name 是 DIY_VIEW_INDEX 时, 任意匹配的 tab 都高亮
// 修复前: 只对 HOME (INDEX_PAGE_URL) 生效
// 修复后: 适用于所有 tab 页面 (如 merchant DIY 首页、cart DIY 首页 等)
// 前提: 当前页面本身就是该 tab 根, 链接 == 当前页 → step 1 早就命中
// 这个短路主要兜底: "DIY_VIEW_INDEX 页面 + tab 链接" 的强语义关联
const linkUrl = adaptSubpackageUrl(link.wap_url).split('?')[0];
const currentUrl = currentPageRoute.split('?')[0];
if (linkUrl === currentUrl) {
return true;
}
}
return checkTabBarActive(link.wap_url, currentPageRoute, this.tabBarList, this.packageEntryTab, currentPageQuery);
}
}
};
</script>
<style lang="scss">
.placeholder {
height: 112rpx;
&.bluge {
height: 180rpx;
}
}
.safe-area {
padding-bottom: 0;
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom);
}
.tab-bar {
background-color: #fff;
box-sizing: border-box;
/* ============================================================
* 固定在最底端,不能位置漂移(微信小程序 / 鸿蒙元服务 / 快应用 H5)
* ------------------------------------------------------------
* 现象(只在客户端小程序里复现,浏览器 H5 没事):
* 页面带 navigationStyle:custom + 页面内有 scroll-view,
* 滚到底部时 .tab-bar 会被微信客户端的页面避让逻辑推上去,
* 出现"位置漂移"。
*
* 根因(微信小程序特性):
* 1) 微信小程序里 position:fixed 的 containing block 是 page
* 根容器(不是 viewport),可视区变化时 fixed 元素下沿会
* 跟着重算,造成漂移。
* 2) 父级一旦出现 transform/filter/will-change,fixed 的
* containing block 会被进一步改写,fixed 元素行为更不可控。
*
* 加固措施:
* - bottom: 0 !important 钉死下沿
* - 提升 z-index 到 2147483647,避免被其他 fixed 元素遮挡
* - transform: translateZ(0) 强制提升到独立合成层,降低父级
* transform 干扰;will-change:transform 提示浏览器持续合成
* - 微信小程序里 #ifdef MP-WEIXIN / #ifdef MP-HARMONY 走原生
* tabBar,diy-bottom-nav 通常不会同时存在,但保留兜底
* ============================================================ */
position: fixed !important;
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
top: auto !important;
width: 100%;
z-index: 2147483647 !important;
display: flex;
border-top: 2rpx solid #f5f5f5;
/* 强制提升合成层,降低父级 transform/filter 改变 containing block 的概率 */
transform: translateZ(0) !important;
-webkit-transform: translateZ(0) !important;
will-change: transform !important;
padding-bottom: 0;
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom);
.tabbar-border {
background-color: rgba(255, 255, 255, 0.329412);
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 2rpx;
-webkit-transform: scaleY(0.5);
transform: scaleY(0.5);
}
.item {
display: flex;
align-items: center;
-webkit-box-orient: vertical;
-webkit-box-direction: normal;
flex: 1;
flex-direction: column;
padding-bottom: 10rpx;
box-sizing: border-box;
.bd {
position: relative;
height: 100rpx;
flex-direction: column;
text-align: center;
display: flex;
justify-content: center;
align-items: center;
.icon {
position: relative;
display: inline-block;
margin-top: 10rpx;
width: 40rpx;
height: 40rpx;
font-size: 40rpx;
line-height: 40rpx;
image {
width: 100%;
height: 100%;
}
>view {
height: inherit;
display: flex;
align-items: center;
}
.bar-icon {
font-size: 42rpx;
}
}
.label {
position: relative;
text-align: center;
font-size: 24rpx;
line-height: 1;
margin-top: 12rpx;
}
}
&.bulge {
.bd {
position: relative;
height: 100rpx;
flex-direction: column;
text-align: center;
.icon {
margin-top: -60rpx;
margin-bottom: 4rpx;
border-radius: 50%;
width: 100rpx;
height: 102rpx;
padding: 10rpx;
border-top: 2rpx solid #f5f5f5;
background-color: #fff;
box-sizing: border-box;
image {
width: 100%;
height: 100%;
border-radius: 50%;
}
}
.label {
position: relative;
text-align: center;
font-size: 24rpx;
height: 40rpx;
line-height: 40rpx;
}
}
}
.cart-count-mark {
position: absolute;
top: -8rpx;
right: -18rpx;
width: 24rpx;
height: 24rpx !important;
display: flex;
justify-content: center;
align-items: center;
color: #fff;
padding: 6rpx;
border-radius: 50%;
z-index: 99;
&.max {
width: 40rpx;
border-radius: 24rpx;
right: -28rpx;
}
}
}
}
.tab-bar-placeholder {
padding-bottom: calc(constant(safe-area-inset-bottom) + 112rpx);
padding-bottom: calc(env(safe-area-inset-bottom) + 112rpx);
}
</style>