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
463 lines
15 KiB
Vue
463 lines
15 KiB
Vue
<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> |