Files
lucky_shop/pages_goods/_components/goods-detail-view/goods-detail-view.vue
ZF sun 791d0bb623 fix(goods-detail): 骨架屏期间不再渲染真实底部 action
- 把 <slot name="action"> 移入 v-else 分支, 与 .goods-detail 同一生命周期
- 修复: 加载初期 goodsSkuDetail.goods_state=0 (默认值), 父组件 detail.vue
  模板里 <ns-goods-action> 走 v-else 显示"该商品已下架"
- 数据到达 goods_state=1 后才切到正常按钮, 导致首屏出现"已下架→正常"闪烁
- 修复后: 骨架屏状态下完全不渲染 ns-goods-action, 仅显示 .goods-detail-skeleton-action 占位

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-25 09:06:39 +08:00

911 lines
43 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>
<!-- 骨架屏:商品详情加载时按结构分段占位(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客服悬浮按钮 -->
<view class="ai-chat-float-btn" @tap.stop="$emit('aiChatClick')" v-if="showAIChatBtn">
<image :src="aiAvatarUrl" mode="aspectFill" class="ai-avatar-icon" />
</view>
<!-- 弹幕 -->
<pengpai-fadein-out v-if="goodsSkuDetail.barrage_show && goodsSkuDetail.barrageData" ref="pengpai"
:duration="1600" :wait="1900" :top="200" :left="0" :radius="60" :loop="true"
:info="goodsSkuDetail.barrageData" />
<!-- 商品媒体信息 -->
<view class="goods-media" :style="{ height: goodsSkuDetail.swiperHeight }">
<!-- 商品图片 -->
<view class="goods-img" :class="{ show: switchMedia == 'img' }">
<swiper class="swiper" @change="swiperChange" :interval="swiperInterval"
:autoplay="swiperAutoplay" autoplay="true" interval="4000" circular="true">
<swiper-item v-for="(item, index) in goodsSkuDetail.sku_images" :key="index"
:item-id="'goods_id_' + index">
<view class="item" @click="previewMedia(index)">
<image :src="$util.img(item, { size: 'big' })" @error="swiperImageError(index)"
mode="aspectFit" show-menu-by-longpress />
</view>
</swiper-item>
</swiper>
<view class="img-indicator-dots">
<text>{{ swiperCurrent }}</text>
<text v-if="goodsSkuDetail.sku_images">/{{ goodsSkuDetail.sku_images.length }}</text>
</view>
</view>
<!-- 商品视频 -->
<view class="goods-video" :class="{ show: switchMedia == 'video' }">
<video id="goodsVideo" :src="$util.img(goodsSkuDetail.video_url)"
:poster="$util.img(goodsSkuDetail.sku_image, { size: 'big' })" objectFit="cover"></video>
</view>
<!-- 切换视频、图片 -->
<view class="media-mode" v-if="goodsSkuDetail.video_url != ''">
<text :class="{ 'color-base-bg': switchMedia == 'video' }" @click="switchMedia = 'video'">{{
$lang('goods.detail.video') }}</text>
<text :class="{ 'color-base-bg': switchMedia == 'img' }"
@click="(switchMedia = 'img'), videoContext.pause()">{{ $lang('goods.detail.image') }}</text>
</view>
</view>
<!-- 价格区域 -->
<view class="goods-gression">
<slot name="price"></slot>
</view>
<view class="newdetail margin-bottom" v-if="goodsSkuDetail.isinformation == 0">
<!-- 入口区域 -->
<slot name="entrance"></slot>
<!-- 配送 -->
<!-- @click="$refs.deliveryType.open()" -->
<view class="item delivery-type" v-if="goodsSkuDetail.is_virtual == 0">
<view class="label">{{ $lang('goods.detail.send') }}</view>
<block v-if="deliveryType">
<view class="box">
<block v-for="(item, index) in deliveryType" :key="index">
<text
v-if="(goodsSkuDetail.support_trade_type || []).indexOf(index) != -1">{{ $lang('goods.detail.express') }}</text>
<!-- {{ item.name }} -->
</block>
</view>
<text class="iconfont icon-right"></text>
</block>
<block v-else>
<view class="box">{{ $lang('goods.detail.detailView.notConfigured') }}</view>
</block>
</view>
<!-- 门店 -->
<!-- <view class="item store-wrap" @click="openStoreListPopup()" v-if="addonIsExist.store && globalStoreInfo && isShowStore">
<view class="label">门店</view>
<view class="list-wrap">
<view class="name-wrap">
<text class="icondiy icon-system-shop"></text>
<text class="name">{{globalStoreInfo.store_name}}</text>
</view>
<view class="other-wrap">
<text class="distance" v-if="parseFloat(globalStoreInfo.distance)">距离{{ globalStoreInfo.distance > 1 ? globalStoreInfo.distance + 'km' : globalStoreInfo.distance * 1000 + 'm' }}</text>
<text class="decorate" v-if="parseFloat(globalStoreInfo.distance)">.</text>
<view class="address">{{ globalStoreInfo.full_address + globalStoreInfo.address }}
</view>
</view>
</view>
<text class="iconfont icon-right"></text>
</view> -->
<view class="item service" @click="openMerchantsServicePopup()"
v-if="goodsSkuDetail.goods_service.length">
<view class="label">{{ $lang('goods.detail.detailView.serviceLabel') }}</view>
<view class="list-wrap">
<view class="item-wrap" v-for="(item, index) in goodsSkuDetail.goods_service" :key="index"
v-if="index < 3">
<view class="item-wrap-box">
<view class="item-wrap-icon">
<text class="iconfont icon-dui"
v-if="!item.icon || (!item.icon.imageUrl && !item.icon.icon)"></text>
<image class="icon-img" v-else-if="item.icon.iconType == 'img'"
:src="$util.img(item.icon.imageUrl)" />
<diy-icon class="icon-box" v-else-if="item.icon.iconType == 'icon'"
:icon="item.icon.icon"
:value="item.icon.style ? item.icon.style : null"></diy-icon>
</view>
<text>{{ item.service_name }}</text>
</view>
</view>
</view>
<text class="iconfont icon-right"></text>
</view>
</view>
<!--多规格区域-->
<view class="newdetail margin-bottom" v-if="goodsSkuDetail.sku_spec_format">
<!-- 入口区域 -->
<slot name="skuspec"></slot>
</view>
<!-- 商家 -->
<view class="newdetail margin-bottom" v-if="goodsSkuDetail.merch_id > 0">
<!-- 入口区域 -->
<slot name="entrance"></slot>
<!-- 商家 -->
<view class="item store-wrap"
@click="$util.redirectTo('/pages_promotion/merch/detail', { merch_id: goodsSkuDetail.merch_id })">
<view class="list-wrap" style="display: flex;">
<view class="name-wrap">
<image :src="$util.img(goodsSkuDetail.merchinfo.merch_image)" mode="widthFix"
style="width: 100rpx;height: 100rpx;border-radius: 50rpx;" show-menu-by-longpress ></image>
</view>
<view class="other-wrap">
<view class="address" style="margin-left: 30rpx;">
<view>{{ goodsSkuDetail.merchinfo.merch_name }}</view>
<view style="font-size: 24rpx;color: #888;">{{ $lang('goods.detail.detailView.officialCertifiedMerchant') }}</view>
</view>
</view>
</view>
<text class="iconfont icon-right"></text>
</view>
</view>
<!-- 配送方式 -->
<view @touchmove.prevent.stop>
<uni-popup ref="deliveryType" type="bottom">
<view class="deliverytype-popup-layer popup-layer">
<view class="head-wrap" @click="$refs.deliveryType.close()">
<text>{{ $lang('goods.detail.detailView.deliveryTitle') }}</text>
<text class="iconfont icon-close"></text>
</view>
<scroll-view scroll-y class="type-body">
<block v-for="(item, index) in deliveryType" :key="index">
<view class="type-item"
:class="{ 'not-support': (goodsSkuDetail.support_trade_type || []).indexOf(index) == -1 }">
<text class="iconfont" :class="item.icon"></text>
<view class="content">
<view class="title">{{ item.name }}</view>
<view class="desc">{{ item.desc }}</view>
</view>
</view>
</block>
</scroll-view>
</view>
</uni-popup>
</view>
<!-- 商品服务 -->
<view @touchmove.prevent.stop>
<uni-popup ref="merchantsServicePopup" type="bottom">
<view class="goods-merchants-service-popup-layer popup-layer">
<view class="head-wrap" @click="closeMerchantsServicePopup()">
<text>{{ $lang('goods.detail.detailView.merchantsServiceTitle') }}</text>
<text class="iconfont icon-close"></text>
</view>
<scroll-view scroll-y>
<view class="item" :class="{ 'empty-desc': !item.desc }"
v-for="(item, index) in goodsSkuDetail.goods_service" :key="index">
<view class="item-icon" :class="{ 'empty-desc': !item.desc }">
<text class="iconfont icon-dui color-base-text"
v-if="!item.icon || (!item.icon.imageUrl && !item.icon.icon)"></text>
<image class="icon-img" v-else-if="item.icon.iconType == 'img'"
:src="$util.img(item.icon.imageUrl)" />
<diy-icon class="icon-box" v-else-if="item.icon.iconType == 'icon'"
:icon="item.icon.icon"
:value="item.icon.style ? item.icon.style : null"></diy-icon>
</view>
<view class="info-wrap">
<text class="title">{{ item.service_name }}</text>
<text class="describe" v-if="item.desc">{{ item.desc }}</text>
</view>
</view>
</scroll-view>
<view class="button-box">
<button type="primary" @click="closeMerchantsServicePopup()">{{ $lang('goods.detail.confirm') }}</button>
</view>
</view>
</uni-popup>
</view>
<!-- 门店列表 -->
<view @touchmove.prevent.stop>
<uni-popup ref="storeListPopup" type="bottom">
<view class="goods-merchants-service-popup-layer popup-layer store-list-wrap">
<view class="head-wrap" @click="closeStoreListPopup()">
<text>{{ $lang('goods.detail.detailView.storeListTitle') }}</text>
<text class="iconfont icon-close"></text>
</view>
<scroll-view scroll-y>
<view class="store-list-content">
<view class="list-item" v-for="(item, index) in storeList.data" :key="index"
@click="selectStore(item)">
<view class="item-box">
<view class="item-image">
<image :src="$util.img(item.store_image)" v-if="item.store_image" show-menu-by-longpress />
<image :src="$util.getDefaultImage().store" v-else show-menu-by-longpress />
</view>
<view class="item-info">
<view class="item-title">
<text class="title">{{ item.store_name }}</text>
<text class="distance color-base-text" v-if="item.distance">
{{ $lang('goods.detail.detailView.distance', { distance: item.distance > 1 ? item.distance + 'km' : item.distance * 1000 + 'm' }) }}
</text>
</view>
<view class="item-time" v-if="item.open_date">{{ $lang('goods.detail.detailView.businessHours', { time: item.open_date }) }}
</view>
<view class="item-address">{{ item.full_address + item.address }}</view>
</view>
<view class="item-right"><text class="iconfont icon-right"></text></view>
</view>
</view>
</view>
</scroll-view>
</view>
</uni-popup>
</view>
<!-- 业务区域 -->
<slot name="business"></slot>
<!-- QR二维码 -->
<view class="detail-community" v-if="goodsSkuDetail.qr_data && goodsSkuDetail.qr_data.qr_state == 1">
<view class="community-box">
<image :src="$util.img('public/uniapp/goods/detail_erweiImage.png')" mode="aspectFill" show-menu-by-longpress ></image>
<view class="community-content">
<view class="community-title">{{ goodsSkuDetail.qr_data.qr_name }}</view>
<view class="community-txt">{{ goodsSkuDetail.qr_data.community_describe }}</view>
</view>
</view>
<view class="community-btn" @click="onCommunity()">{{ $lang('goods.detail.detailView.addCommunity') }}</view>
</view>
<!-- 促销 -->
<!-- <view class="community-model" @touchmove.prevent.stop @click.stop="onCloseCommunity()" v-show="isCommunity">
<view class="community-model-content" @click.stop>
<view class="community-model-content-radius">
<view>添加社群</view>
</view>
<view class="community-model-content-draw" v-if="goodsSkuDetail.qr_data && goodsSkuDetail.qr_data.qr_img">
<image :src="goodsSkuDetail.qr_data.qr_img != '' && goodsSkuDetail.qr_data.qr_state == 1 ? $util.img(goodsSkuDetail.qr_data.qr_img) : $util.img('public/uniapp/goods/detail_erweiImage.png') " mode="aspectFill" show-menu-by-longpress/>
</view>
<view class="community-model-content-text">长按识别二维码,添加社群</view>
</view>
<view class="community-model-close" @click.stop="onCloseCommunity()">
<text class="iconfont icon-close"></text>
</view>
</view> -->
<!-- 参与流程 -->
<slot name="articipation"></slot>
<!-- 商品评价 -->
<view class="group-wrap" v-if="evaluateConfig.evaluate_show == 1 && goodsSkuDetail.isinformation == 0"
style="display: none;">
<view class="goods-evaluate" @click="toEvaluateDetail(goodsSkuDetail.goods_id)">
<view class="tit">
<!-- <view class="tit" :class="{ active: goodsEvaluate.content }"> -->
<view>
<text class="color-title font-size-base">
{{ $lang('goods.detail.detailView.evaluate') }}
<text class="font-size-base">({{ evaluateCount }})</text>
</text>
<text class="evaluate-item-empty" v-if="!evaluateCount">{{ $lang('goods.detail.detailView.noEvaluate') }}</text>
<view class="evaluate-item-empty" v-else>
<text class="font-size-tag">{{ $lang('goods.detail.detailView.viewAll') }}</text>
<text class="iconfont icon-right font-size-tag"></text>
</view>
</view>
</view>
<view class="evaluate-item" v-for="(item, index) in goodsEvaluate" :key="index"
v-if="item.content">
<view class="evaluator">
<view class="evaluator-info">
<view class="evaluator-face">
<image v-if="item.member_headimg" :src="$util.img(item.member_headimg)"
@error="item.member_headimg = $util.getDefaultImage().head"
mode="aspectFill" show-menu-by-longpress />
<image v-else :src="$util.getDefaultImage().head"
@error="item.member_headimg = $util.getDefaultImage().head"
mode="aspectFill" show-menu-by-longpress />
</view>
<view class="evaluator-name-wrap">
<text class="evaluator-name using-hidden"
v-if="item.member_name.length > 2 && item.is_anonymous == 1">
{{ item.member_name[0] }}***{{ item.member_name[item.member_name.length - 1]
}}
</text>
<text class="evaluator-name using-hidden" v-else>{{ item.member_name }}</text>
<view v-if="item.scores" class="evaluator-xing">
<xiaoStarComponent :starCount="item.scores * 2"></xiaoStarComponent>
</view>
</view>
</view>
<text class="time color-tip">{{ $util.timeStampTurnTime(item.create_time) }}</text>
</view>
<view class="cont margin-top">{{ item.content }}</view>
<scroll-view scroll-x="true">
<view class="evaluate-img" v-if="item.images">
<view class="img-box" v-for="(img, img_index) in item.images" :key="img_index"
@click="previewEvaluate(index, img_index, 'images')">
<image :src="$util.img(img)" mode="aspectFill" show-menu-by-longpress />
</view>
</view>
</scroll-view>
</view>
</view>
</view>
<!-- 规格属性 -->
<view class="goods-attr"
v-if="goodsSkuDetail.goods_attr_format && goodsSkuDetail.goods_attr_format.length > 0">
<view class="title">{{ $lang('goods.detail.detailView.specAttrTitle') }}</view>
<view class="attr-wrap">
<block v-for="(item, index) in goodsSkuDetail.goods_attr_format" :key="index">
<view class="item" v-if="goodsAttrShow || (!goodsAttrShow && index < 4)">
<text class="attr-name">{{ item.attr_name }}</text>
<text class="value-name">{{ item.attr_value_name }}</text>
</view>
</block>
<view class="attr-action" v-if="goodsSkuDetail.goods_attr_format.length > 4"
@click="switchGoodsAttr">
<block v-if="!goodsAttrShow">
{{ $lang('goods.detail.detailView.expand') }}<text class="iconfont icon-iconangledown"></text>
</block>
<block v-else>
{{ $lang('goods.detail.detailView.collapse') }}<text class="iconfont icon-iconangledown-copy"></text>
</block>
</view>
</view>
</view>
<!-- 商品详情 -->
<view v-if="hasDetailContent" class="goods-detail-tab">
<view class="detail-tab">
<view v-if="goodsSkuDetail.pdf_url" class="tab-item"
:class="{ active: detailTab === 'pdf' }" @click="switchDetailTab('pdf')">
<text class="tab-label">{{ $lang('goods.detail.document') }}</text>
</view>
<view v-if="hasHtmlTab" class="tab-item"
:class="{ active: detailTab === 'html' }" @click="switchDetailTab('html')">
<text class="tab-label">{{ $lang('goods.detail.details') }}</text>
</view>
</view>
<view class="detail-content active">
<!-- PDF文档 -->
<view v-if="detailTab === 'pdf' && goodsSkuDetail.pdf_url"
class="detail-content-item content-pdf-type">
<view class="pdf-card" @click="onTapDoc(goodsSkuDetail.pdf_url)">
<view class="pdf-icon">
<img class="iconfont icon-file" :src="$util.img('public/static/img/file/file-pdf.svg')" />
</view>
<view class="pdf-info">
<text class="pdf-name">{{ goodsSkuDetail.pdf_name || $lang('goods.detail.detailView.defaultDocName') }}</text>
<text class="pdf-desc">{{ $lang('goods.detail.detailView.viewDocDetail') }}</text>
</view>
<view class="pdf-btn">
<text>{{ $lang('goods.detail.detailView.view') }}</text>
</view>
</view>
</view>
<!-- HTML内容 -->
<view v-if="detailTab === 'html'" class="detail-content-item content-html-type">
<view class="goods-details" v-if="goodsSkuDetail.goods_content">
<!-- <rich-text :nodes="goodsSkuDetail.goods_content" @click="showImg($event)" :data-nodes="goodsSkuDetail.goods_content"></rich-text> -->
<!-- {{goodsSkuDetail.goods_content}} -->
<mp-html :content="goodsSkuDetail.goods_content" />
<!-- :loading="loading" @preview="preview" @navigate="navigate" -->
</view>
<view class="goods-details active" v-else></view>
<view class="goods-details" v-if="service && service.is_display == 1 && service.content">
<rich-text :nodes="service.content" @click="showImg($event)"
:data-nodes="service.content"></rich-text>
</view>
</view>
</view>
</view>
<!-- 商品推荐 -->
<ns-goods-recommend ref="goodrecommend" route="goods_detail"></ns-goods-recommend>
<ns-copyright></ns-copyright>
<!-- 海报 -->
<view @touchmove.prevent.stop class="poster-layer">
<uni-popup ref="posterPopup" type="center">
<template v-if="poster != '-1'">
<view class="poster-wrap">
<view class="image-wrap">
<image :src="$util.img(poster)" show-menu-by-longpress mode="widthFix" />
<view class="close iconfont icon-close" @click="closePosterPopup()"></view>
</view>
<!-- #ifdef MP || APP-PLUS -->
<view class="save-btn" @click="saveGoodsPoster()">{{ $lang('goods.detail.detailView.saveImage') }}</view>
<!-- #endif -->
<!-- #ifdef H5 -->
<view class="save-btn">{{ $lang('goods.detail.detailView.longPressToSave') }}</view>
<!-- #endif -->
</view>
</template>
</uni-popup>
</view>
<!-- 分享弹窗 -->
<view @touchmove.prevent.stop>
<uni-popup ref="sharePopup" type="bottom" class="share-popup">
<view>
<view class="share-title">{{ $lang('goods.detail.detailView.shareTitle') }}</view>
<view class="share-content">
<!-- #ifdef MP -->
<view class="share-box">
<button class="share-btn" :plain="true" open-type="share">
<view class="iconfont icon-share-friend"></view>
<text>{{ $lang('goods.detail.detailView.shareToFriends') }}</text>
</button>
</view>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<view class="share-box" v-if="goodsCircle">
<button class="share-btn" :plain="true" @click="openBusinessView">
<view class="iconfont icon-haowuquan"></view>
<text>{{ $lang('goods.detail.detailView.shareToGoodsCircle') }}</text>
</button>
</view>
<!-- #endif -->
<view class="share-box" @click="openPosterPopup">
<button class="share-btn" :plain="true">
<view class="iconfont icon-pengyouquan"></view>
<text>{{ $lang('goods.detail.detailView.generateSharePoster') }}</text>
</button>
</view>
<!-- #ifdef H5 || MP-HARMONY -->
<view class="share-box" @click="copyUrl">
<button class="share-btn" :plain="true">
<view class="iconfont icon-fuzhilianjie"></view>
<text>{{ $lang('goods.detail.detailView.copyLink') }}</text>
</button>
</view>
<!-- #endif -->
</view>
<view class="share-footer" @click="closeSharePopup"><text>{{ $lang('goods.detail.detailView.cancelShare') }}</text></view>
</view>
</uni-popup>
</view>
<slot name="fixedbtn"></slot>
</view>
</view>
</view>
<to-top v-if="showTop" @toTop="scrollToTopNative()"></to-top>
<ns-login ref="login"></ns-login>
</view>
</template>
<script>
// 商品详情视图
import scroll from '@/common/js/scroll-view.js';
import detail from './detail.js';
import IMAGE_CONFIG from '@/common/js/image-config.js';
import { createCustomerService } from '@/common/js/customer-service.js';
import pengpaiFadeinOut from '../pengpai-fadein-out/pengpai-fadein-out.vue';
export default {
name: 'goods-detail-view',
props: {
goodsSkuDetail: {
type: Object,
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);
},
showAIChatBtn() {
const customerService = createCustomerService(this);
return customerService.hasValidKefuConfig() && customerService.isAIKefu();
},
// 商品详情 Tab 是否可见:goods_content 或 service.content 存在其一
hasHtmlTab() {
const hasGoodsContent = this.goodsSkuDetail && this.goodsSkuDetail.goods_content;
const hasServiceContent = this.service && this.service.is_display == 1 && this.service.content;
return hasGoodsContent || hasServiceContent;
},
// 整个 Tab 区域是否可见:PDF 或 商品详情 至少有一个
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]
};
</script>
<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 {
position: fixed;
right: 20rpx;
top: 100rpx;
width: 80rpx;
height: 80rpx;
border-radius: 50%;
background: #ffffff;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.15);
z-index: 9999;
transition: all 0.3s ease;
overflow: hidden;
}
.ai-chat-float-btn:active {
transform: scale(0.9);
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
}
.ai-chat-float-btn .iconfont {
font-size: 40rpx;
}
.ai-chat-float-btn .ai-avatar-icon {
width: 100%;
height: 100%;
border-radius: 50%;
}
/* PDF文档样式 */
.content-pdf-type {
padding: 30rpx 24rpx;
}
.content-pdf-type .pdf-card {
display: flex;
align-items: center;
padding: 24rpx;
background-color: #fff;
border-radius: 12rpx;
border: 1rpx solid #eceaea;
}
.content-pdf-type .pdf-icon {
width: 72rpx;
height: 72rpx;
display: flex;
align-items: center;
justify-content: center;
background-color: #f5f7fa;
border-radius: 8rpx;
flex-shrink: 0;
}
.content-pdf-type .pdf-icon .iconfont {
font-size: 40rpx;
color: #409eff;
}
.content-pdf-type .pdf-info {
flex: 1;
margin-left: 20rpx;
overflow: hidden;
}
.content-pdf-type .pdf-name {
display: block;
font-size: 28rpx;
color: #303133;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
}
.content-pdf-type .pdf-desc {
display: block;
font-size: 24rpx;
color: #909399;
margin-top: 4rpx;
}
.content-pdf-type .pdf-btn {
flex-shrink: 0;
padding: 10rpx 24rpx;
background-color: var(--base-color, #4371f2);
border-radius: 32rpx;
}
.content-pdf-type .pdf-btn text {
font-size: 24rpx;
color: #ffffff;
font-weight: 500;
}
/* 商品详情 Tab 美化 */
.goods-detail-tab .detail-tab {
display: flex;
align-items: center;
justify-content: space-around;
margin-top: 0;
border-bottom: 1rpx solid #f0f0f0;
background-color: #fff;
}
.goods-detail-tab .detail-tab .tab-item {
flex: 1;
height: 88rpx;
line-height: 88rpx;
text-align: center;
color: #909399;
font-size: 30rpx;
margin: 0;
position: relative;
transition: color 0.2s ease;
}
.goods-detail-tab .detail-tab .tab-item .tab-label {
display: inline-block;
position: relative;
padding: 0 12rpx;
}
.goods-detail-tab .detail-tab .tab-item.active {
color: var(--base-color, #4371f2);
font-weight: 600;
}
.goods-detail-tab .detail-tab .tab-item.active .tab-label::after {
content: '';
position: absolute;
left: 50%;
bottom: -16rpx;
transform: translateX(-50%);
width: 48rpx;
height: 4rpx;
background-color: var(--base-color, #4371f2);
border-radius: 4rpx;
}
</style>