913 lines
43 KiB
Vue
913 lines
43 KiB
Vue
<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="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>
|
||
|
||
<!-- 商品视频:objectFit=contain 保留原始宽高比,在固定高度的图片容器内居中播放 -->
|
||
<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="contain"
|
||
playsinline webkit-playsinline x5-playsinline></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="hasHtmlTab" class="tab-item"
|
||
:class="{ active: detailTab === 'html' }" @click="switchDetailTab('html')">
|
||
<text class="tab-label">{{ $lang('goods.detail.details') }}</text>
|
||
</view>
|
||
<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>
|
||
<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> |