Files
lucky_shop/components-diy/diy-bottom-nav.vue
ZF sun a6091f6b68 refactor(commodity-detail): optimize performance and fix UI flicker issues
1. add empty form tip for product message section
2. lazy load goods form to unblock initial page render
3. optimize category and tabbar rendering to fix flicker on WeChat mini-program
4. delay non-critical data processing to improve first-screen rendering speed
5. fix popup blank issue when opening product message form for the first time
2026-07-18 16:57:10 +08:00

463 lines
15 KiB
Vue
Raw Permalink 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>
<!-- 修复底部 TabBar 频闪:
微信小程序等环境下 `shouldShowTabBar` 在 tabBarList 异步加载期间会从 false→true 短暂切换,
配合 `v-if` 会让整个 tab 组件树卸载/重建 -> 视觉频闪 + tabBar 占用空间反复占位/释放导致上方内容跳动。
改为 `v-show`, 只切换可见性, 内部 tab 始终在 DOM 中(无 tabBarList 时只渲染空壳占位)。 -->
<view data-component-name="diy-bottom-nav" v-show="shouldShowTabBar">
<!-- tabBarList 未就绪时仍保留 placeholder 占位, 避免上方内容在加载完成后突跳 -->
<view v-if="tabBarList && tabBarList.list && tabBarList.list.length" 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定位后底部导航栏塌陷问题: 始终保留 placeholder, 避免内容随 tabBar 可见性跳 -->
<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>