merge: feat/google-detail-skeleton → dev/1.0
This commit is contained in:
@@ -1,7 +1,29 @@
|
||||
<template>
|
||||
<view>
|
||||
<!-- 骨架屏:商品详情加载时按结构分段占位(banner / 价格 / 标题 / 规格 / 商家 / 详情 / 推荐)
|
||||
关闭条件: loading=false 且已拿到真实商品数据(goods_id>0); 任一不满足都保留骨架屏 -->
|
||||
<view v-if="loading || !hasRealData" class="goods-detail-skeleton">
|
||||
<x-skeleton data-component-name="goods-detail-view-banner" :loading="true" :configs="skeletonBanner"></x-skeleton>
|
||||
<x-skeleton data-component-name="goods-detail-view-price" :loading="true" :configs="skeletonPrice"></x-skeleton>
|
||||
<x-skeleton data-component-name="goods-detail-view-title" :loading="true" :configs="skeletonTitle"></x-skeleton>
|
||||
<x-skeleton data-component-name="goods-detail-view-sku" :loading="true" :configs="skeletonSku"></x-skeleton>
|
||||
<x-skeleton data-component-name="goods-detail-view-merch" :loading="true" :configs="skeletonMerch"></x-skeleton>
|
||||
<x-skeleton data-component-name="goods-detail-view-detail" :loading="true" :configs="skeletonDetail"></x-skeleton>
|
||||
<x-skeleton data-component-name="goods-detail-view-recommend" :loading="true" :configs="skeletonRecommend"></x-skeleton>
|
||||
<!-- 底部 action 骨架占位:与 ns-goods-action 同样 fixed bottom, 避免数据未到时底部跳变 -->
|
||||
<view class="goods-detail-skeleton-action">
|
||||
<view class="skeleton-action-icons">
|
||||
<view class="skeleton-action-icon" v-for="i in 2" :key="'sk-act-icon-' + i"></view>
|
||||
</view>
|
||||
<view class="skeleton-action-btn"></view>
|
||||
<view class="skeleton-action-btn primary"></view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 修复鸿蒙元服务: 移除无效的 scroll-y="true" 属性 (scroll-y 是 <scroll-view> 的指令属性)
|
||||
鸿蒙 JSVM 早期版本遇到无效 attribute 会让 view 子树静默不渲染, 导致整个详情页空白 -->
|
||||
<view v-else class="goods-detail-wrap">
|
||||
<!-- 操作区域 slot: 与 .goods-detail 同一 v-else 分支, 避免骨架屏状态下被父组件渲染真实按钮(基于默认 goods_state=0 显示"该商品已下架") -->
|
||||
<slot name="action"></slot>
|
||||
<view class="goods-detail" :class="isIphoneX ? 'active' : ''">
|
||||
<view class="goods-container">
|
||||
<!-- AI客服悬浮按钮 -->
|
||||
@@ -478,9 +500,8 @@
|
||||
<slot name="fixedbtn"></slot>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 操作区域 -->
|
||||
<slot name="action"></slot>
|
||||
<to-top v-if="showTop" @toTop="scrollToTopNative()"></to-top>
|
||||
<ns-login ref="login"></ns-login>
|
||||
</view>
|
||||
@@ -503,11 +524,142 @@ export default {
|
||||
default: () => {
|
||||
return {};
|
||||
}
|
||||
},
|
||||
// 是否显示骨架屏(true:展示骨架;false:展示真实内容)
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
},
|
||||
components: {
|
||||
pengpaiFadeinOut
|
||||
},
|
||||
data() {
|
||||
// 商品详情骨架屏:模拟「轮播图 + 价格行 + 标题简介 + 规格行 + 商家 + 详情 + 推荐」
|
||||
return {
|
||||
// 1. 轮播图:方形 banner 占位
|
||||
skeletonBanner: {
|
||||
padding: '0',
|
||||
gridRows: 1,
|
||||
gridColumns: 1,
|
||||
gridRowsGap: '24rpx',
|
||||
headShow: true,
|
||||
headWidth: '100%',
|
||||
headHeight: '750rpx',
|
||||
headBorderRadius: '0',
|
||||
textShow: false
|
||||
},
|
||||
// 2. 价格行:一行价 + 一行原价
|
||||
skeletonPrice: {
|
||||
padding: '24rpx 24rpx 16rpx',
|
||||
gridRows: 2,
|
||||
gridColumns: 1,
|
||||
gridRowsGap: '16rpx',
|
||||
itemDirection: 'column',
|
||||
itemAlign: 'flex-start',
|
||||
headShow: false,
|
||||
textShow: true,
|
||||
textRows: 1,
|
||||
textRowsGap: '0',
|
||||
textWidth: ['40%', '30%'],
|
||||
textHeight: ['48rpx', '24rpx'],
|
||||
textBorderRadius: '6rpx'
|
||||
},
|
||||
// 3. 标题/简介:标题两行 + 简介 + 标签
|
||||
skeletonTitle: {
|
||||
padding: '8rpx 24rpx 24rpx',
|
||||
gridRows: 1,
|
||||
gridColumns: 1,
|
||||
gridRowsGap: '20rpx',
|
||||
itemDirection: 'column',
|
||||
itemAlign: 'flex-start',
|
||||
headShow: false,
|
||||
textShow: true,
|
||||
textRows: 4,
|
||||
textRowsGap: '16rpx',
|
||||
textWidth: ['90%', '60%', '100%', '40%'],
|
||||
textHeight: ['40rpx', '40rpx', '24rpx', '24rpx'],
|
||||
textBorderRadius: '6rpx'
|
||||
},
|
||||
// 4. 规格行:单行高度的小条
|
||||
skeletonSku: {
|
||||
padding: '20rpx 24rpx',
|
||||
gridRows: 1,
|
||||
gridColumns: 1,
|
||||
gridRowsGap: '0',
|
||||
itemDirection: 'row',
|
||||
itemAlign: 'center',
|
||||
headShow: true,
|
||||
headWidth: '120rpx',
|
||||
headHeight: '32rpx',
|
||||
headBorderRadius: '6rpx',
|
||||
textShow: true,
|
||||
textRows: 1,
|
||||
textRowsGap: '0',
|
||||
textWidth: '60%',
|
||||
textHeight: '28rpx',
|
||||
textBorderRadius: '6rpx',
|
||||
itemGap: '20rpx'
|
||||
},
|
||||
// 5. 商家:头像 + 名称
|
||||
skeletonMerch: {
|
||||
padding: '24rpx',
|
||||
gridRows: 1,
|
||||
gridColumns: 1,
|
||||
gridRowsGap: '0',
|
||||
itemDirection: 'row',
|
||||
itemAlign: 'center',
|
||||
headShow: true,
|
||||
headWidth: '80rpx',
|
||||
headHeight: '80rpx',
|
||||
headBorderRadius: '50%',
|
||||
textShow: true,
|
||||
textRows: 2,
|
||||
textRowsGap: '12rpx',
|
||||
textWidth: ['50%', '80%'],
|
||||
textHeight: ['28rpx', '20rpx'],
|
||||
textBorderRadius: '6rpx',
|
||||
itemGap: '24rpx'
|
||||
},
|
||||
// 6. 商品详情:多行段落
|
||||
skeletonDetail: {
|
||||
padding: '24rpx',
|
||||
gridRows: 1,
|
||||
gridColumns: 1,
|
||||
gridRowsGap: '0',
|
||||
itemDirection: 'column',
|
||||
itemAlign: 'flex-start',
|
||||
headShow: false,
|
||||
textShow: true,
|
||||
textRows: 6,
|
||||
textRowsGap: '16rpx',
|
||||
textWidth: ['100%', '100%', '90%', '100%', '80%', '60%'],
|
||||
textHeight: '24rpx',
|
||||
textBorderRadius: '6rpx'
|
||||
},
|
||||
// 7. 推荐:两列卡片缩略图
|
||||
skeletonRecommend: {
|
||||
padding: '24rpx',
|
||||
gridRows: 1,
|
||||
gridColumns: 2,
|
||||
gridRowsGap: '24rpx',
|
||||
gridColumnsGap: '24rpx',
|
||||
itemDirection: 'column',
|
||||
itemAlign: 'flex-start',
|
||||
headShow: true,
|
||||
headWidth: '100%',
|
||||
headHeight: '320rpx',
|
||||
headBorderRadius: '12rpx',
|
||||
textShow: true,
|
||||
textRows: 2,
|
||||
textRowsGap: '12rpx',
|
||||
textWidth: ['100%', '70%'],
|
||||
textHeight: ['24rpx', '24rpx'],
|
||||
textBorderRadius: '6rpx',
|
||||
itemGap: '12rpx'
|
||||
}
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
aiAvatarUrl() {
|
||||
return this.$util.img(IMAGE_CONFIG.aiAvatar);
|
||||
@@ -526,6 +678,11 @@ export default {
|
||||
hasDetailContent() {
|
||||
const hasPdf = this.goodsSkuDetail && this.goodsSkuDetail.pdf_url;
|
||||
return hasPdf || this.hasHtmlTab;
|
||||
},
|
||||
// 是否有真实商品数据: goods_id > 0 才算拿到商品详情
|
||||
// 用于「未拿到任何数据时不显示真实内容」, 避免加载失败/异常时切到空详情页
|
||||
hasRealData() {
|
||||
return this.goodsSkuDetail && Number(this.goodsSkuDetail.goods_id) > 0;
|
||||
}
|
||||
},
|
||||
mixins: [scroll, detail]
|
||||
@@ -534,6 +691,79 @@ export default {
|
||||
|
||||
<style lang="scss">
|
||||
@import '@/common/css/goods_detail.scss';
|
||||
|
||||
/* 骨架屏外层:与商品详情整体上对齐,左右内边距由各 skeleton 自己控制 */
|
||||
.goods-detail-skeleton {
|
||||
width: 100%;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
/* 底部 action 骨架占位:与 ns-goods-action 同样 fixed bottom, 高度接近真实按钮 */
|
||||
.goods-detail-skeleton-action {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 100rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: #fff;
|
||||
z-index: 9;
|
||||
padding: 0 16rpx;
|
||||
box-sizing: border-box;
|
||||
border-top: 1rpx solid #f2f2f2;
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.goods-detail-skeleton-action .skeleton-action-icons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: 16rpx;
|
||||
}
|
||||
|
||||
.goods-detail-skeleton-action .skeleton-action-icon {
|
||||
width: 60rpx;
|
||||
height: 60rpx;
|
||||
border-radius: 50%;
|
||||
background-color: #EAEDF5;
|
||||
background: linear-gradient(90deg, #EAEDF5 25%, #F9FAFF 37%, #EAEDF5 50%);
|
||||
background-size: 400% 100%;
|
||||
animation: skActAnim 1.8s ease infinite;
|
||||
margin-right: 16rpx;
|
||||
}
|
||||
|
||||
.goods-detail-skeleton-action .skeleton-action-icon:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.goods-detail-skeleton-action .skeleton-action-btn {
|
||||
flex: 1;
|
||||
height: 70rpx;
|
||||
border-radius: 10rpx;
|
||||
margin-right: 16rpx;
|
||||
background-color: #EAEDF5;
|
||||
background: linear-gradient(90deg, #EAEDF5 25%, #F9FAFF 37%, #EAEDF5 50%);
|
||||
background-size: 400% 100%;
|
||||
animation: skActAnim 1.8s ease infinite;
|
||||
}
|
||||
|
||||
.goods-detail-skeleton-action .skeleton-action-btn:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.goods-detail-skeleton-action .skeleton-action-btn.primary {
|
||||
background: linear-gradient(90deg, #d8d8d8 25%, #ececec 37%, #d8d8d8 50%);
|
||||
background-size: 400% 100%;
|
||||
}
|
||||
|
||||
@keyframes skActAnim {
|
||||
0% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
100% {
|
||||
background-position: 0 50%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<style scoped>
|
||||
.ai-chat-float-btn {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</view> -->
|
||||
<!-- #endif -->
|
||||
|
||||
<goods-detail-view :goodsSkuDetail="goodsSkuDetail" ref="goodsDetailView" @aiChatClick="sendToAIChat(goodsSkuDetail)">
|
||||
<goods-detail-view :goodsSkuDetail="goodsSkuDetail" :loading="loading" ref="goodsDetailView" @aiChatClick="sendToAIChat(goodsSkuDetail)">
|
||||
<!-- 价格区域 -->
|
||||
<template v-slot:price>
|
||||
<!-- 限时折扣 -->
|
||||
@@ -533,7 +533,9 @@
|
||||
</goods-detail-view>
|
||||
<to-top v-if="showTop" @toTop="scrollToTopNative()"></to-top>
|
||||
<ns-login ref="login"></ns-login>
|
||||
<loading-cover ref="loadingCover"></loading-cover>
|
||||
<!-- 商品详情页已有骨架屏, 不再启用 loading-cover 全屏遮罩(initShow=false),
|
||||
否则骨架屏会被遮罩盖住、用户看不到任何占位过渡 -->
|
||||
<loading-cover ref="loadingCover" :initShow="false"></loading-cover>
|
||||
|
||||
<!-- 隐私弹窗 -->
|
||||
<privacy-popup ref="privacyPopup"></privacy-popup>
|
||||
|
||||
@@ -4,6 +4,9 @@ export default {
|
||||
goodsRoute: '/pages_goods/detail',
|
||||
couponList: [], //优惠券列表
|
||||
couponBtnSwitch: false, //获取优惠券防止重复提交
|
||||
// 商品详情骨架屏:true 显示骨架,false 显示真实内容
|
||||
// 首屏进入即为 true,商品详情数据首屏渲染完成后(与 loading-cover 同时机)置 false
|
||||
loading: true,
|
||||
|
||||
posterApi: '/api/goods/poster',
|
||||
|
||||
@@ -103,13 +106,12 @@ export default {
|
||||
await this.getGoodsSkuDetail();
|
||||
} catch (e) {
|
||||
console.error('getGoodsSkuDetail failed:', e);
|
||||
// 即使失败也要保证 loading-cover 关闭, 避免空白屏
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const ref = this.$refs && this.$refs.loadingCover;
|
||||
if (ref && typeof ref.hide === 'function') ref.hide();
|
||||
} catch (e2) { /* swallow */ }
|
||||
}, 0);
|
||||
// 失败时不关闭骨架屏, 由视图层 hasRealData(goods_id>0) 自动保留占位,
|
||||
// 避免错误地把页面切到"空详情"状态
|
||||
uni.showToast({
|
||||
title: this.$lang ? this.$lang('common.networkError') : '网络异常,请重试',
|
||||
icon: 'none'
|
||||
});
|
||||
}
|
||||
},
|
||||
onHide() {
|
||||
@@ -203,14 +205,12 @@ export default {
|
||||
await this.$nextTick();
|
||||
// #endif
|
||||
|
||||
// 关闭 loading-cover 提前到 nextTick 后, 让用户看到首屏内容
|
||||
// 用 setTimeout 0 推迟到下一个 task, 避开当前 sync 阶段 vendor 报错导致 hide 失败
|
||||
// 关闭商品详情骨架屏: 在真实数据首屏渲染完成后再隐藏骨架屏,
|
||||
// 用户会看到骨架屏 → 真实详情 的清晰过渡
|
||||
// 最短展示 300ms: 数据请求很快时也保留足够感知时间, 否则一闪而过用户根本看不到骨架屏
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const ref = this.$refs && this.$refs.loadingCover;
|
||||
if (ref && typeof ref.hide === 'function') ref.hide();
|
||||
} catch (e) { /* swallow */ }
|
||||
}, 0);
|
||||
this.loading = false;
|
||||
}, 300);
|
||||
|
||||
// 处理商品数据 (同步字段变换 + 后台并发 getGoodsForm)
|
||||
await this.handleGoodsSkuData();
|
||||
|
||||
Reference in New Issue
Block a user