3374 lines
96 KiB
Plaintext
3374 lines
96 KiB
Plaintext
<!-- 消费者端 - 商品详情页 -->
|
||
<template>
|
||
<view class="product-detail-page">
|
||
<view class="custom-header" :style="`height:${customHeaderHeight}px;`">
|
||
<view class="header-fade-layer" :style="getHeaderFadeStyle()">
|
||
<view class="header-main-row">
|
||
<view class="header-back-space" :style="`width:${navButtonHeight + 20}px;`"></view>
|
||
<view class="header-search-box" @click="goToSearch">
|
||
<text class="header-search-icon">⌕</text>
|
||
<text class="header-search-placeholder">{{ getDisplayTitle() }}</text>
|
||
</view>
|
||
<view class="header-capsule-reserve" :style="`width:${navRightReserve}px;`"></view>
|
||
</view>
|
||
|
||
<view class="header-tabs-row">
|
||
<view class="header-tab-item" @click="scrollToSection('goods')">
|
||
<text :class="activeTab == 'goods' ? 'header-tab-text active' : 'header-tab-text'">宝贝</text>
|
||
<view v-if="activeTab == 'goods'" class="header-tab-line"></view>
|
||
</view>
|
||
<view class="header-tab-item" @click="scrollToSection('comments')">
|
||
<text :class="activeTab == 'comments' ? 'header-tab-text active' : 'header-tab-text'">评论</text>
|
||
<view v-if="activeTab == 'comments'" class="header-tab-line"></view>
|
||
</view>
|
||
<view class="header-tab-item" @click="scrollToSection('detail')">
|
||
<text :class="activeTab == 'detail' ? 'header-tab-text active' : 'header-tab-text'">详情</text>
|
||
<view v-if="activeTab == 'detail'" class="header-tab-line"></view>
|
||
</view>
|
||
<view class="header-tab-item" @click="scrollToSection('recommend')">
|
||
<text :class="activeTab == 'recommend' ? 'header-tab-text active' : 'header-tab-text'">推荐</text>
|
||
<view v-if="activeTab == 'recommend'" class="header-tab-line"></view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="persistent-back-btn" :style="getBackButtonStyle()" @click="goBack">
|
||
<text class="persistent-back-text">‹</text>
|
||
</view>
|
||
|
||
<scroll-view class="page-scroll" direction="vertical" @scroll="onProductScroll" :scroll-top="targetScrollTop" scroll-with-animation="true">
|
||
<view id="section-goods" class="hero-section">
|
||
<swiper class="hero-swiper" :indicator-dots="false" :autoplay="false" @change="onSwiperChange">
|
||
<swiper-item v-for="(image, index) in getProductImages()" :key="index">
|
||
<image :src="image" class="hero-image" mode="aspectFill" @error="handleProductImageError(index)" @click="previewImage(index)" />
|
||
</swiper-item>
|
||
</swiper>
|
||
<view class="hero-indicator">{{ currentImageIndex + 1 }}/{{ getProductImages().length }}</view>
|
||
</view>
|
||
|
||
<view class="content-wrap">
|
||
<view class="info-card price-sales-card">
|
||
<view v-if="isProductOffShelf()" class="off-shelf-tip">
|
||
<text class="off-shelf-tip-text">商品已下架,当前仅支持查看,暂不支持购买</text>
|
||
</view>
|
||
<view v-if="memberDiscount > 0 && memberPrice > 0 && memberPrice < product.price" class="vip-row">
|
||
<view class="vip-badge">
|
||
<text class="vip-badge-text">VIP</text>
|
||
</view>
|
||
<text class="vip-discount-text">{{ memberDiscount }}折专享</text>
|
||
</view>
|
||
|
||
<view class="price-sales-row">
|
||
<view class="price-main-wrap">
|
||
<text class="price-symbol">¥</text>
|
||
<text class="price-value">{{ getDisplayPriceText() }}</text>
|
||
<text v-if="getOriginalPriceText() != ''" class="price-original">¥{{ getOriginalPriceText() }}</text>
|
||
</view>
|
||
<text class="sales-inline">{{ getDisplaySalesText() }}</text>
|
||
</view>
|
||
|
||
<view class="price-sub-row">
|
||
<text v-if="memberPrice > 0 && memberPrice < product.price" class="save-text">已省 ¥{{ (product.price - memberPrice).toFixed(2) }}</text>
|
||
<text class="stock-text">{{ getDisplayStockText() }}</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view v-if="coupons.length > 0" class="info-card coupon-card" @click="showCouponModal">
|
||
<text class="coupon-label">优惠</text>
|
||
<view class="coupon-tags-wrap">
|
||
<text v-for="(coupon, index) in coupons.slice(0, 3)" :key="'coupon-' + index" class="coupon-chip">{{ coupon.name }}</text>
|
||
</view>
|
||
<text class="coupon-arrow">领券 ></text>
|
||
</view>
|
||
|
||
<view class="info-card title-action-card">
|
||
<view v-if="getCardTags().length > 0" class="title-tag-row">
|
||
<text v-for="(tag, index) in getCardTags()" :key="'tag-' + index" class="detail-card-tag">{{ tag }}</text>
|
||
</view>
|
||
|
||
<view class="title-body-row">
|
||
<view class="title-main-wrap">
|
||
<text class="product-title">{{ getDisplayTitle() }}</text>
|
||
<text v-if="getHighlightText() != ''" class="product-highlight-text">{{ getHighlightText() }}</text>
|
||
<text v-else-if="getShortDescriptionText() != ''" class="product-highlight-text">{{ getShortDescriptionText() }}</text>
|
||
</view>
|
||
|
||
<view class="title-side-actions">
|
||
<view class="side-action-btn" @click="toggleFavorite">
|
||
<image :src="isFavorite ? '/static/icons/favorite-active.png' : '/static/icons/favorite.png'" class="side-action-icon" />
|
||
<text class="side-action-text">{{ isFavorite ? '已收藏' : '收藏' }}</text>
|
||
</view>
|
||
<view class="side-action-btn" @click="showSharePopup">
|
||
<text class="side-action-symbol">↗</text>
|
||
<text class="side-action-text">分享</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view v-if="getServiceTags().length > 0" class="detail-service-row">
|
||
<text v-for="(tag, index) in getServiceTags()" :key="'service-tag-' + index" class="detail-service-tag">{{ tag }}</text>
|
||
</view>
|
||
|
||
<view class="meta-link-row">
|
||
<text class="meta-link-text" @click="showSpecModal">已选: {{ getSelectedSpecLabel() }}</text>
|
||
<text class="meta-link-divider">|</text>
|
||
<text class="meta-link-text" @click="showParamsModal">参数: {{ getParamsSummary() }}</text>
|
||
</view>
|
||
|
||
<view v-if="product.selling_points != null && product.selling_points.length > 0" class="selling-points-panel">
|
||
<view v-for="(point, index) in product.selling_points" :key="'selling-point-' + index" class="selling-point-item">
|
||
<text class="selling-point-dot"></text>
|
||
<text class="selling-point-text">{{ point }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="info-card guarantee-card" @click="showGuaranteeIntro">
|
||
<view class="card-header-row">
|
||
<text class="card-title">商品保障</text>
|
||
<text class="card-link-text">查看说明 ></text>
|
||
</view>
|
||
<view class="guarantee-list">
|
||
<view v-for="item in getVisibleGuarantees()" :key="item.id" class="guarantee-chip">
|
||
<text class="guarantee-dot"></text>
|
||
<text class="guarantee-text">{{ item.title }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="info-card shop-card" @click="goToShop">
|
||
<image :src="getMerchantLogo()" class="shop-logo" @error="handleShopLogoError" />
|
||
<view class="shop-details">
|
||
<text class="shop-name">{{ getMerchantName() }}</text>
|
||
<view class="shop-stats-row">
|
||
<text class="shop-stat-text">评分 {{ formatRating(merchant.rating) }}</text>
|
||
<text class="shop-stat-text">销量 {{ merchant.total_sales }}</text>
|
||
</view>
|
||
<text class="shop-desc">{{ getMerchantDescription() }}</text>
|
||
</view>
|
||
<text class="enter-shop">进店 ></text>
|
||
</view>
|
||
|
||
<view id="section-comments" class="info-card review-card">
|
||
<view class="card-header-row">
|
||
<text class="card-title">商品评论</text>
|
||
<text class="card-link-text" @click="viewAllReviews">查看全部 ></text>
|
||
</view>
|
||
|
||
<view class="review-summary-row">
|
||
<text class="review-score">{{ formatRating(reviewRating) }}</text>
|
||
<text class="review-score-label">综合评分</text>
|
||
<text class="review-count">{{ getReviewCountLabel() }}</text>
|
||
</view>
|
||
|
||
<view v-for="review in reviewPreview" :key="review.id" class="review-item">
|
||
<view class="review-meta-row">
|
||
<text class="review-user">{{ review.userName }}</text>
|
||
<text class="review-stars">{{ getReviewStars(review.rating) }}</text>
|
||
</view>
|
||
<text class="review-content">{{ review.content }}</text>
|
||
<view v-if="review.images.length > 0" class="review-image-row">
|
||
<image v-for="(img, index) in review.images.slice(0, 3)" :key="review.id + '-img-' + index" :src="img" class="review-image" mode="aspectFill" @click="previewReviewImages(review.images, index)" />
|
||
</view>
|
||
<text class="review-date">{{ formatDate(review.createdAt) }}</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view id="section-detail" class="info-card detail-card">
|
||
<view class="card-header-row">
|
||
<text class="card-title">商品详情</text>
|
||
<text class="card-link-text" @click="showParamsModal">商品参数 ></text>
|
||
</view>
|
||
|
||
<view v-if="product.usage != null && product.usage != ''" class="detail-text-block">
|
||
<text class="detail-block-title">功能主治</text>
|
||
<text class="detail-block-text">{{ product.usage }}</text>
|
||
</view>
|
||
|
||
<view class="detail-images" v-if="getDetailImages().length > 0">
|
||
<image v-for="(img, index) in getDetailImages()" :key="'detail-' + index" :src="img" class="detail-image" mode="widthFix" @error="handleDetailImageError(index)" @click="previewDetailImage(index)" @load="onDetailImageLoad" />
|
||
</view>
|
||
|
||
<text v-else-if="getDetailDescription() != ''" class="description-text">{{ getDetailDescription() }}</text>
|
||
|
||
<view v-else class="empty-detail-state">
|
||
<text class="empty-detail-text">暂无商品详情</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view id="section-recommend" class="info-card recommend-card">
|
||
<view class="card-header-row">
|
||
<text class="card-title">推荐</text>
|
||
</view>
|
||
<view class="empty-recommend-state">
|
||
<text class="empty-recommend-text">暂无推荐商品</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</scroll-view>
|
||
|
||
<view class="bottom-actions">
|
||
<view class="bottom-left-actions">
|
||
<view class="bottom-action-item" @click="handleContactService">
|
||
<image src="/static/icons/customer-service.png" class="bottom-action-icon" />
|
||
<text class="bottom-action-text">客服</text>
|
||
</view>
|
||
<view class="bottom-action-item cart-action-item" @click="goToCart">
|
||
<view class="cart-icon-wrap">
|
||
<image src="/static/tabbar/cart.png" class="bottom-action-icon" />
|
||
<text v-if="cartBadgeCount > 0" class="cart-badge">{{ getCartBadgeText() }}</text>
|
||
</view>
|
||
<text class="bottom-action-text">购物车</text>
|
||
</view>
|
||
<view class="bottom-action-item shop-action-item" @click="goToShop">
|
||
<text class="bottom-action-shop-icon">店</text>
|
||
<text class="bottom-action-text">店铺</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="btn-group">
|
||
<button :class="['cart-btn', { 'btn-disabled': isProductOffShelf() }]" @click="addToCart">{{ isProductOffShelf() ? '已下架' : '加入购物车' }}</button>
|
||
<button :class="['buy-btn', { 'btn-disabled': isProductOffShelf() }]" @click="buyNow">{{ isProductOffShelf() ? '暂不可购买' : '立即购买' }}</button>
|
||
</view>
|
||
</view>
|
||
|
||
<view v-if="showSpec" class="spec-modal" @click="hideSpecModal">
|
||
<view class="spec-content" @click.stop>
|
||
<view v-if="productSkus.length > 0 && selectedSkuId == ''" class="spec-error-tip">
|
||
<text class="error-tip-text">请选择规格</text>
|
||
</view>
|
||
<view class="spec-header-jd">
|
||
<image :src="getSelectedSkuImage()" class="spec-product-img" mode="aspectFill" />
|
||
<view class="spec-info-jd">
|
||
<view class="spec-price-row">
|
||
<text class="price-symbol">¥</text>
|
||
<text class="price-value">{{ getSelectedSkuPrice() }}</text>
|
||
</view>
|
||
<text class="spec-stock-jd">库存: {{ getSelectedSkuStock() }}件</text>
|
||
<text class="spec-choosed-jd">已选: {{ getSelectedSpecLabel() }}</text>
|
||
</view>
|
||
<text class="close-btn-jd" @click="hideSpecModal">×</text>
|
||
</view>
|
||
|
||
<scroll-view class="spec-list-jd" direction="vertical">
|
||
<view v-if="productSkus.length > 0" class="spec-group">
|
||
<text class="group-title">规格</text>
|
||
<view class="group-tags">
|
||
<view v-for="sku in productSkus" :key="sku.id" class="spec-tag" :class="{ active: selectedSkuId === sku.id }" @click="selectSku(sku)">
|
||
<text class="tag-text">{{ getSkuSpecText(sku) }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="spec-quantity-row">
|
||
<text class="group-title">数量</text>
|
||
<view class="quantity-selector-jd">
|
||
<view class="q-btn" @click="decreaseQuantity">
|
||
<text class="q-btn-text">-</text>
|
||
</view>
|
||
<input class="q-input" type="number" :value="quantity.toString()" @input="validateQuantity" />
|
||
<view class="q-btn" @click="increaseQuantity">
|
||
<text class="q-btn-text">+</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</scroll-view>
|
||
|
||
<view class="spec-footer-jd">
|
||
<button class="footer-btn cart" @click="addToCart">加入购物车</button>
|
||
<button class="footer-btn buy" @click="buyNow">立即购买</button>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view v-if="showParams" class="params-modal" @click="hideParamsModal">
|
||
<view class="params-content" @click.stop>
|
||
<view class="params-header">
|
||
<text class="params-title">商品参数</text>
|
||
<text class="close-btn" @click="hideParamsModal">×</text>
|
||
</view>
|
||
<scroll-view class="params-list" direction="vertical">
|
||
<view class="params-item" v-if="product.specification">
|
||
<text class="params-label">规格</text>
|
||
<text class="params-value">{{ product.specification }}</text>
|
||
</view>
|
||
<view class="params-item" v-if="product.usage">
|
||
<text class="params-label">功能主治</text>
|
||
<text class="params-value">{{ product.usage }}</text>
|
||
</view>
|
||
<view class="params-item" v-if="product.side_effects">
|
||
<text class="params-label">副作用</text>
|
||
<text class="params-value">{{ product.side_effects }}</text>
|
||
</view>
|
||
<view class="params-item" v-if="product.precautions">
|
||
<text class="params-label">注意事项</text>
|
||
<text class="params-value">{{ product.precautions }}</text>
|
||
</view>
|
||
<view class="params-item" v-if="product.expiry_date">
|
||
<text class="params-label">有效期</text>
|
||
<text class="params-value">{{ product.expiry_date }}</text>
|
||
</view>
|
||
<view class="params-item" v-if="product.storage_conditions">
|
||
<text class="params-label">储存条件</text>
|
||
<text class="params-value">{{ product.storage_conditions }}</text>
|
||
</view>
|
||
<view class="params-item" v-if="product.approval_number">
|
||
<text class="params-label">批准文号</text>
|
||
<text class="params-value">{{ product.approval_number }}</text>
|
||
</view>
|
||
<view class="params-item" v-if="product.tags != null && product.tags.length > 0">
|
||
<text class="params-label">标签</text>
|
||
<text class="params-value">{{ product.tags!.join(', ') }}</text>
|
||
</view>
|
||
</scroll-view>
|
||
</view>
|
||
</view>
|
||
|
||
<view v-if="showCoupons" class="popup-mask" @click="hideCouponModal">
|
||
<view class="popup-content" @click.stop>
|
||
<view class="popup-header">
|
||
<text class="popup-title">优惠券</text>
|
||
<text class="close-btn" @click="hideCouponModal">×</text>
|
||
</view>
|
||
<scroll-view direction="vertical" class="coupon-list-scroll">
|
||
<view v-for="coupon in coupons" :key="coupon.id" class="coupon-item">
|
||
<view class="coupon-left">
|
||
<text class="coupon-amount"><text class="symbol">¥</text>{{ coupon.discount_value }}</text>
|
||
<text class="coupon-cond">满{{ coupon.min_order_amount }}可用</text>
|
||
</view>
|
||
<view class="coupon-right">
|
||
<view class="coupon-info-text">
|
||
<text class="coupon-name">{{ coupon.name }}</text>
|
||
<text class="coupon-time">{{ formatDate(coupon.start_time) }}-{{ formatDate(coupon.end_time) }}</text>
|
||
</view>
|
||
<button class="coupon-btn" @click="claimCoupon(coupon)">领取</button>
|
||
</view>
|
||
</view>
|
||
</scroll-view>
|
||
</view>
|
||
</view>
|
||
|
||
<view v-if="showShare" class="share-popup-mask" @click="hideSharePopup">
|
||
<view class="share-popup-content" @click.stop>
|
||
<view class="share-popup-header">
|
||
<text class="share-popup-title">分享至</text>
|
||
<text class="close-btn" @click="hideSharePopup">×</text>
|
||
</view>
|
||
|
||
<view class="share-options">
|
||
<view class="share-option" @click="shareToWechat">
|
||
<view class="share-icon-wrapper wechat">
|
||
<text class="share-option-icon">微</text>
|
||
</view>
|
||
<text class="share-option-text">微信好友</text>
|
||
</view>
|
||
<view class="share-option" @click="shareToMoments">
|
||
<view class="share-icon-wrapper moments">
|
||
<text class="share-option-icon">圈</text>
|
||
</view>
|
||
<text class="share-option-text">朋友圈</text>
|
||
</view>
|
||
<view class="share-option" @click="shareToQQ">
|
||
<view class="share-icon-wrapper qq">
|
||
<text class="share-option-icon">Q</text>
|
||
</view>
|
||
<text class="share-option-text">QQ</text>
|
||
</view>
|
||
<view class="share-option" @click="copyLink">
|
||
<view class="share-icon-wrapper link">
|
||
<text class="share-option-icon">链</text>
|
||
</view>
|
||
<text class="share-option-text">复制链接</text>
|
||
</view>
|
||
<view class="share-option" @click="saveImage">
|
||
<view class="share-icon-wrapper image">
|
||
<text class="share-option-icon">图</text>
|
||
</view>
|
||
<text class="share-option-text">保存图片</text>
|
||
</view>
|
||
<view class="share-option" @click="generatePoster">
|
||
<view class="share-icon-wrapper poster">
|
||
<text class="share-option-icon">报</text>
|
||
</view>
|
||
<text class="share-option-text">生成海报</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="share-cancel-btn" @click="hideSharePopup">
|
||
<text class="cancel-text">取消</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script>
|
||
import { ProductType, MerchantType, ProductSkuType, CouponTemplateType, FootprintItemType, ProductGuaranteeType, ProductReviewPreviewType } from '@/types/mall-types.uts'
|
||
import { supabaseService } from '@/utils/supabaseService.uts'
|
||
import { goToLogin } from '@/utils/utils.uts'
|
||
|
||
export default {
|
||
data() {
|
||
return {
|
||
defaultImage: '/static/images/default.png',
|
||
product: {
|
||
id: '',
|
||
merchant_id: '',
|
||
category_id: '',
|
||
name: '',
|
||
short_title: '',
|
||
subtitle: '',
|
||
description: '',
|
||
images: [] as Array<string>,
|
||
price: 0,
|
||
original_price: 0,
|
||
stock: 0,
|
||
sales: 0,
|
||
status: 0,
|
||
created_at: '',
|
||
card_tags: [] as Array<string>,
|
||
service_tags: [] as Array<string>,
|
||
selling_points: [] as Array<string>,
|
||
display_sales_text: '',
|
||
detail_images: [] as Array<string>,
|
||
guarantees: [] as Array<UTSJSONObject>
|
||
} as ProductType,
|
||
merchant: {
|
||
id: '',
|
||
user_id: '',
|
||
shop_name: '',
|
||
shop_logo: '',
|
||
shop_banner: '',
|
||
shop_description: '',
|
||
contact_name: '',
|
||
contact_phone: '',
|
||
shop_status: 0,
|
||
rating: 0,
|
||
total_sales: 0,
|
||
created_at: ''
|
||
} as MerchantType,
|
||
productSkus: [] as Array<ProductSkuType>,
|
||
currentImageIndex: 0,
|
||
showSpec: false,
|
||
selectedSkuId: '',
|
||
selectedSpec: '',
|
||
quantity: 1 as number,
|
||
isFavorite: false,
|
||
showParams: false,
|
||
coupons: [] as Array<CouponTemplateType>,
|
||
showCoupons: false,
|
||
showShare: false,
|
||
memberPrice: 0 as number,
|
||
memberDiscount: 0 as number,
|
||
memberLevelName: '' as string,
|
||
statusBarHeight: 0 as number,
|
||
capsuleTop: 0 as number,
|
||
capsuleHeight: 32 as number,
|
||
navButtonTop: 0 as number,
|
||
navButtonHeight: 32 as number,
|
||
navRightReserve: 16 as number,
|
||
cartBadgeCount: 0 as number,
|
||
reviewRating: 5 as number,
|
||
reviewCount: 0 as number,
|
||
reviewPreview: [] as Array<ProductReviewPreviewType>,
|
||
reviewLoading: false,
|
||
productGuarantees: [] as Array<ProductGuaranteeType>,
|
||
detailImages: [] as Array<string>,
|
||
currentScrollTop: 0 as number,
|
||
headerOpacity: 0 as number,
|
||
activeTab: 'goods' as string,
|
||
targetScrollTop: 0 as number,
|
||
customHeaderHeight: 0 as number,
|
||
sectionOffsets: new UTSJSONObject(),
|
||
isProgrammaticScrolling: false as boolean,
|
||
capsuleLeft: 0 as number,
|
||
capsuleBottom: 0 as number,
|
||
headerMainRowHeight: 44 as number,
|
||
headerTabsRowHeight: 40 as number,
|
||
searchRightReserve: 16 as number
|
||
}
|
||
},
|
||
onLoad(options: any) {
|
||
this.initImmersiveLayout()
|
||
|
||
const opts = options as UTSJSONObject
|
||
const productId = (opts['productId'] ?? opts['id']) as string | null
|
||
const priceStr = opts['price'] as string | null
|
||
const productPrice = priceStr != null ? parseFloat(priceStr) : null
|
||
const originalPriceStr = opts['originalPrice'] as string | null
|
||
const productOriginalPrice = originalPriceStr != null ? parseFloat(originalPriceStr) : null
|
||
|
||
let productName = opts['name'] as string | null
|
||
if (productName != null) {
|
||
try {
|
||
productName = decodeURIComponent(productName)
|
||
} catch (e) {
|
||
console.warn('ProductName decode failed, using original:', productName)
|
||
}
|
||
}
|
||
|
||
let productImage = opts['image'] as string | null
|
||
if (productImage != null) {
|
||
try {
|
||
productImage = decodeURIComponent(productImage)
|
||
} catch (e) {
|
||
console.warn('ProductImage decode failed, using original:', productImage)
|
||
}
|
||
}
|
||
|
||
this.productGuarantees = this.getDefaultGuarantees()
|
||
this.reviewPreview = [] as Array<ProductReviewPreviewType>
|
||
|
||
if (productId != null) {
|
||
this.loadProductDetail(productId, {
|
||
price: productPrice,
|
||
originalPrice: productOriginalPrice,
|
||
name: productName,
|
||
image: productImage
|
||
})
|
||
this.checkFavoriteStatus(productId)
|
||
this.saveFootprint(productId)
|
||
}
|
||
|
||
setTimeout(() => {
|
||
this.measureSections()
|
||
}, 600)
|
||
},
|
||
onShow() {
|
||
this.refreshCartBadgeCount()
|
||
},
|
||
computed: {
|
||
displayPrice(): number {
|
||
if (this.selectedSkuId != null && this.selectedSkuId !== '') {
|
||
const sku = this.productSkus.find(s => s.id === this.selectedSkuId)
|
||
if (sku != null) return sku.price
|
||
}
|
||
return this.product.price
|
||
}
|
||
},
|
||
methods: {
|
||
readNumericField(item: any, key: string): number {
|
||
if (item == null) return 0
|
||
try {
|
||
if (item instanceof UTSJSONObject) {
|
||
return item.getNumber(key) ?? 0
|
||
}
|
||
} catch (error) {}
|
||
|
||
try {
|
||
const rawObj = JSON.parse(JSON.stringify(item)) as UTSJSONObject
|
||
const rawValue = rawObj.get(key)
|
||
if (rawValue == null) return 0
|
||
if (typeof rawValue == 'number') {
|
||
return rawValue as number
|
||
}
|
||
const textValue = rawValue.toString()
|
||
if (textValue == '') return 0
|
||
const parsedValue = parseInt(textValue)
|
||
return isNaN(parsedValue) ? 0 : parsedValue
|
||
} catch (error) {
|
||
return 0
|
||
}
|
||
},
|
||
|
||
extractCartItemQuantity(item: any): number {
|
||
if (item == null) return 1
|
||
try {
|
||
let quantity = this.readNumericField(item, 'quantity')
|
||
if (quantity <= 0) {
|
||
quantity = this.readNumericField(item, 'count')
|
||
}
|
||
if (quantity <= 0) {
|
||
quantity = this.readNumericField(item, 'num')
|
||
}
|
||
return quantity > 0 ? quantity : 1
|
||
} catch (error) {
|
||
console.error('提取购物车数量失败', error)
|
||
return 1
|
||
}
|
||
},
|
||
|
||
readCartCountFromStorage(): number {
|
||
try {
|
||
const cartRaw = uni.getStorageSync('cart')
|
||
if (cartRaw == null || cartRaw === '') {
|
||
return 0
|
||
}
|
||
const parsed = JSON.parse(cartRaw.toString()) as Array<UTSJSONObject>
|
||
if (!Array.isArray(parsed)) {
|
||
return 0
|
||
}
|
||
|
||
let total = 0
|
||
for (let i = 0; i < parsed.length; i++) {
|
||
total += this.extractCartItemQuantity(parsed[i])
|
||
}
|
||
return total
|
||
} catch (error) {
|
||
console.error('读取本地购物车数量失败', error)
|
||
return 0
|
||
}
|
||
},
|
||
|
||
async refreshCartBadgeCount() {
|
||
try {
|
||
const userId = supabaseService.getCurrentUserId()
|
||
if (userId == null || userId === '') {
|
||
this.cartBadgeCount = this.readCartCountFromStorage()
|
||
return
|
||
}
|
||
|
||
const cartItems = await supabaseService.getCartItems()
|
||
let total = 0
|
||
for (let i = 0; i < cartItems.length; i++) {
|
||
total += this.extractCartItemQuantity(cartItems[i])
|
||
}
|
||
this.cartBadgeCount = total
|
||
} catch (error) {
|
||
console.error('刷新购物车角标失败', error)
|
||
this.cartBadgeCount = this.readCartCountFromStorage()
|
||
}
|
||
},
|
||
|
||
getCartBadgeText(): string {
|
||
if (this.cartBadgeCount <= 0) return ''
|
||
if (this.cartBadgeCount > 99) return '99+'
|
||
return this.cartBadgeCount.toString()
|
||
},
|
||
|
||
initImmersiveLayout() {
|
||
let windowWidth = 375
|
||
try {
|
||
const windowInfo = uni.getWindowInfo()
|
||
this.statusBarHeight = windowInfo.statusBarHeight ?? 0
|
||
windowWidth = windowInfo.windowWidth ?? 375
|
||
} catch (e) {
|
||
this.statusBarHeight = 0
|
||
}
|
||
this.capsuleTop = this.statusBarHeight > 0 ? this.statusBarHeight : 12
|
||
this.capsuleHeight = 32
|
||
this.navButtonTop = this.capsuleTop
|
||
this.navButtonHeight = this.capsuleHeight
|
||
this.navRightReserve = 16
|
||
this.capsuleLeft = windowWidth - 100
|
||
this.capsuleBottom = this.capsuleTop + this.capsuleHeight
|
||
this.headerMainRowHeight = 44
|
||
this.headerTabsRowHeight = 40
|
||
this.searchRightReserve = 16
|
||
this.customHeaderHeight = this.statusBarHeight + this.headerMainRowHeight + this.headerTabsRowHeight
|
||
|
||
// #ifdef MP-WEIXIN
|
||
try {
|
||
const menuBtn = uni.getMenuButtonBoundingClientRect()
|
||
if (menuBtn != null && menuBtn.top != null && menuBtn.top > 0) {
|
||
this.capsuleTop = menuBtn.top
|
||
this.capsuleHeight = menuBtn.height > 0 ? menuBtn.height : 32
|
||
this.navButtonTop = menuBtn.top
|
||
this.navButtonHeight = this.capsuleHeight
|
||
this.navRightReserve = (windowWidth - menuBtn.left) + 12
|
||
this.capsuleLeft = menuBtn.left
|
||
this.capsuleBottom = menuBtn.bottom ?? (menuBtn.top + menuBtn.height)
|
||
this.searchRightReserve = menuBtn.left - 8
|
||
}
|
||
} catch (e) {
|
||
this.navButtonTop = this.statusBarHeight > 0 ? this.statusBarHeight + 6 : 16
|
||
this.navButtonHeight = 32
|
||
this.navRightReserve = 16
|
||
this.capsuleLeft = windowWidth - 100
|
||
this.capsuleBottom = this.navButtonTop + 32
|
||
this.searchRightReserve = 16
|
||
}
|
||
// #endif
|
||
|
||
if (this.navButtonTop < this.statusBarHeight) {
|
||
this.navButtonTop = this.statusBarHeight
|
||
}
|
||
if (this.navButtonHeight < 32) {
|
||
this.navButtonHeight = 32
|
||
}
|
||
if (this.navRightReserve < 16) {
|
||
this.navRightReserve = 16
|
||
}
|
||
if (this.searchRightReserve < 16) {
|
||
this.searchRightReserve = 16
|
||
}
|
||
this.customHeaderHeight = this.statusBarHeight + this.headerMainRowHeight + this.headerTabsRowHeight
|
||
},
|
||
|
||
getHeaderFadeStyle(): string {
|
||
const opacity = this.headerOpacity
|
||
const translateY = (1 - opacity) * (-6)
|
||
const h = this.statusBarHeight + this.headerMainRowHeight + this.headerTabsRowHeight
|
||
const pt = this.statusBarHeight
|
||
const pointerEvents = opacity < 0.05 ? 'none' : 'auto'
|
||
return `opacity:${opacity};transform:translateY(${translateY}px);height:${h}px;padding-top:${pt}px;pointer-events:${pointerEvents};`
|
||
},
|
||
|
||
getBackButtonStyle(): string {
|
||
const top = this.navButtonTop
|
||
const height = this.navButtonHeight
|
||
const bgOpacity = this.headerOpacity > 0.8 ? 0 : 0.88
|
||
return `top:${top}px;height:${height}px;background-color:rgba(255,255,255,${bgOpacity});`
|
||
},
|
||
|
||
onProductScroll(e: { detail: { scrollTop: number } }) {
|
||
const current = e.detail.scrollTop
|
||
this.currentScrollTop = current
|
||
|
||
const fadeStart = 16
|
||
const fadeEnd = 128
|
||
const progress = (current - fadeStart) / (fadeEnd - fadeStart)
|
||
this.headerOpacity = Math.max(0, Math.min(1, progress))
|
||
|
||
if (!this.isProgrammaticScrolling) {
|
||
const offsets = this.sectionOffsets
|
||
if (offsets != null) {
|
||
const goodsOffset = offsets.getNumber('goods') ?? 0
|
||
const commentsOffset = offsets.getNumber('comments') ?? 999999
|
||
const detailOffset = offsets.getNumber('detail') ?? 999999
|
||
const recommendOffset = offsets.getNumber('recommend') ?? 999999
|
||
|
||
const visualTop = current + this.customHeaderHeight + 20
|
||
|
||
let nextTab = 'goods'
|
||
if (visualTop >= recommendOffset && recommendOffset < 999999) {
|
||
nextTab = 'recommend'
|
||
} else if (visualTop >= detailOffset && detailOffset < 999999) {
|
||
nextTab = 'detail'
|
||
} else if (visualTop >= commentsOffset && commentsOffset < 999999) {
|
||
nextTab = 'comments'
|
||
} else {
|
||
nextTab = 'goods'
|
||
}
|
||
|
||
if (nextTab != this.activeTab) {
|
||
this.activeTab = nextTab
|
||
}
|
||
}
|
||
}
|
||
},
|
||
|
||
scrollToSection(tabKey: string) {
|
||
const sectionId = 'section-' + tabKey
|
||
const query = uni.createSelectorQuery()
|
||
query.select('#' + sectionId).boundingClientRect()
|
||
query.select('.page-scroll').scrollOffset()
|
||
query.exec((res: any) => {
|
||
if (res == null) return
|
||
let rect: any = null
|
||
let scrollInfo: any = null
|
||
if (Array.isArray(res)) {
|
||
rect = res[0]
|
||
scrollInfo = res[1]
|
||
}
|
||
if (rect == null || rect.top == null) return
|
||
const nodeTop = rect.top as number
|
||
const scrollTop = (scrollInfo != null && scrollInfo.scrollTop != null) ? scrollInfo.scrollTop as number : this.currentScrollTop
|
||
let target = 0
|
||
if (tabKey != 'goods') {
|
||
target = Math.max(0, nodeTop + scrollTop - this.customHeaderHeight)
|
||
}
|
||
this.isProgrammaticScrolling = true
|
||
this.targetScrollTop = -1
|
||
setTimeout(() => {
|
||
this.targetScrollTop = target
|
||
setTimeout(() => { this.isProgrammaticScrolling = false }, 400)
|
||
}, 20)
|
||
this.activeTab = tabKey
|
||
})
|
||
},
|
||
|
||
measureSections() {
|
||
const sectionIds = ['section-goods', 'section-comments', 'section-detail', 'section-recommend']
|
||
const query = uni.createSelectorQuery()
|
||
for (let i = 0; i < sectionIds.length; i++) {
|
||
query.select('#' + sectionIds[i]).boundingClientRect()
|
||
}
|
||
query.exec((res: any) => {
|
||
if (res == null || !Array.isArray(res)) return
|
||
const offsets = new UTSJSONObject()
|
||
for (let i = 0; i < res.length; i++) {
|
||
const rect = res[i]
|
||
if (rect != null && rect.top != null) {
|
||
const key = sectionIds[i].replace('section-', '')
|
||
offsets.set(key, rect.top + this.currentScrollTop)
|
||
}
|
||
}
|
||
this.sectionOffsets = offsets
|
||
})
|
||
},
|
||
|
||
onDetailImageLoad() {
|
||
setTimeout(() => {
|
||
this.measureSections()
|
||
}, 300)
|
||
},
|
||
|
||
goBack() {
|
||
try {
|
||
const pages = getCurrentPages()
|
||
if (pages != null && pages.length > 1) {
|
||
uni.navigateBack()
|
||
return
|
||
}
|
||
} catch (e) {}
|
||
uni.switchTab({ url: '/pages/main/index' })
|
||
},
|
||
|
||
goToSearch() {
|
||
uni.navigateTo({ url: '/pages/mall/consumer/search' })
|
||
},
|
||
|
||
openActionPanel() {
|
||
uni.showActionSheet({
|
||
itemList: ['首页', '分享', '客服'],
|
||
success: (res: ShowActionSheetSuccess) => {
|
||
if (res.tapIndex === 0) {
|
||
this.goToHome()
|
||
} else if (res.tapIndex === 1) {
|
||
this.showSharePopup()
|
||
} else if (res.tapIndex === 2) {
|
||
this.contactMerchant()
|
||
}
|
||
}
|
||
})
|
||
},
|
||
|
||
saveFootprint(productId: string) {
|
||
supabaseService.addFootprint(productId).then((success: boolean) => {
|
||
if (success === true) {
|
||
console.log('足迹已同步到服务器')
|
||
}
|
||
})
|
||
|
||
const footprintData = uni.getStorageSync('footprints_v2') as string | null
|
||
let footprints: Array<FootprintItemType> = []
|
||
|
||
if (footprintData != null && footprintData !== '') {
|
||
try {
|
||
footprints = JSON.parse(footprintData) as Array<FootprintItemType>
|
||
} catch (e) {
|
||
console.error('Failed to parse footprints', e)
|
||
}
|
||
}
|
||
|
||
const productIdStr = productId
|
||
footprints = footprints.filter(function(item) {
|
||
const itemObj = item as UTSJSONObject
|
||
const itemId = itemObj.getString('productId') ?? itemObj.getString('id') ?? ''
|
||
return itemId != productIdStr
|
||
})
|
||
|
||
footprints.unshift(this.getFootprintCachePayload())
|
||
|
||
if (footprints.length > 200) {
|
||
footprints = footprints.slice(0, 200)
|
||
}
|
||
uni.setStorageSync('footprints_v2', JSON.stringify(footprints))
|
||
},
|
||
|
||
async loadProductDetail(productId: string, options: any = {}) {
|
||
uni.showLoading({ title: '加载中...' })
|
||
try {
|
||
const dbProduct = await supabaseService.getProductById(productId)
|
||
|
||
if (dbProduct != null) {
|
||
this.product = {
|
||
id: dbProduct.id,
|
||
merchant_id: dbProduct.merchant_id ?? '',
|
||
category_id: dbProduct.category_id ?? '',
|
||
name: dbProduct.name,
|
||
short_title: dbProduct.short_title ?? '',
|
||
subtitle: dbProduct.subtitle ?? '',
|
||
description: dbProduct.description ?? '',
|
||
images: dbProduct.images ?? [] as Array<string>,
|
||
price: dbProduct.price ?? dbProduct.base_price ?? 0,
|
||
original_price: dbProduct.original_price ?? dbProduct.market_price ?? 0,
|
||
stock: dbProduct.stock ?? dbProduct.total_stock ?? 0,
|
||
sales: dbProduct.sale_count ?? 0,
|
||
status: dbProduct.status ?? 1,
|
||
created_at: dbProduct.created_at ?? new Date().toISOString(),
|
||
specification: dbProduct.specification ?? null,
|
||
usage: dbProduct.usage ?? null,
|
||
side_effects: dbProduct.side_effects ?? null,
|
||
precautions: dbProduct.precautions ?? null,
|
||
expiry_date: dbProduct.expiry_date ?? null,
|
||
storage_conditions: dbProduct.storage_conditions ?? null,
|
||
approval_number: dbProduct.approval_number ?? null,
|
||
tags: [] as Array<string>,
|
||
card_tags: dbProduct.card_tags ?? [] as Array<string>,
|
||
service_tags: dbProduct.service_tags ?? [] as Array<string>,
|
||
selling_points: dbProduct.selling_points ?? [] as Array<string>,
|
||
display_sales_text: dbProduct.display_sales_text ?? '',
|
||
detail_images: [] as Array<string>,
|
||
guarantees: [] as Array<UTSJSONObject>
|
||
} as ProductType
|
||
|
||
const rawProductObj = JSON.parse(JSON.stringify(dbProduct)) as UTSJSONObject
|
||
|
||
if (dbProduct.tags != null && dbProduct.tags != '') {
|
||
try {
|
||
const parsedTags = JSON.parse(dbProduct.tags)
|
||
if (Array.isArray(parsedTags)) {
|
||
const nextTags = [] as Array<string>
|
||
for (let i = 0; i < parsedTags.length; i++) {
|
||
const tag = parsedTags[i] as string
|
||
if (tag != null && tag !== '') {
|
||
nextTags.push(tag)
|
||
}
|
||
}
|
||
this.product.tags = nextTags
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
|
||
this.product.images = this.sanitizeProductImages(this.product.images)
|
||
if (this.product.images.length == 0 && dbProduct.main_image_url != null && dbProduct.main_image_url != '') {
|
||
this.product.images.push(this.normalizeImageUrl(dbProduct.main_image_url))
|
||
}
|
||
if (this.product.images.length == 0) {
|
||
this.product.images.push(this.defaultImage)
|
||
}
|
||
|
||
this.detailImages = this.resolveDetailImages(rawProductObj)
|
||
this.productGuarantees = this.resolveGuarantees(rawProductObj)
|
||
|
||
console.log('商品详情加载成功:', this.product.name, '库存:', this.product.stock, '销量:', this.product.sales)
|
||
} else {
|
||
throw new Error('No product found')
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to load product detail:', e)
|
||
this.product.id = productId
|
||
const opts = options as UTSJSONObject
|
||
const nameOpt = opts['name']
|
||
this.product.name = (nameOpt != null && nameOpt != '') ? decodeURIComponent(nameOpt as string) ?? '未知商品' : '未知商品'
|
||
this.product.short_title = this.product.name
|
||
|
||
const priceOpt = opts['price']
|
||
if (typeof priceOpt == 'number') {
|
||
this.product.price = priceOpt as number
|
||
} else if (typeof priceOpt == 'string') {
|
||
this.product.price = parseFloat(priceOpt as string)
|
||
} else {
|
||
this.product.price = 0
|
||
}
|
||
|
||
const imageOpt = opts['image']
|
||
const decodedImage = (imageOpt != null && imageOpt != '') ? decodeURIComponent(imageOpt as string) : null
|
||
this.product.images = decodedImage != null ? this.sanitizeProductImages([decodedImage]) : [this.defaultImage]
|
||
this.detailImages = [] as Array<string>
|
||
this.productGuarantees = this.getDefaultGuarantees()
|
||
}
|
||
|
||
if (this.product.merchant_id != null && this.product.merchant_id !== '') {
|
||
await this.loadMerchantInfo(this.product.merchant_id)
|
||
this.loadCoupons()
|
||
}
|
||
if (this.product.id != null && this.product.id !== '') {
|
||
this.loadProductSkus(this.product.id)
|
||
this.loadReviewPreview(this.product.id)
|
||
}
|
||
|
||
this.loadMemberPrice()
|
||
uni.hideLoading()
|
||
},
|
||
|
||
async loadMerchantInfo(merchantId: string) {
|
||
let realMerchantLoaded = false
|
||
if (merchantId.includes('-') || !merchantId.startsWith('merchant_')) {
|
||
try {
|
||
const shopResponse = await supabaseService.getShopByMerchantId(merchantId)
|
||
if (shopResponse != null) {
|
||
this.merchant = {
|
||
id: shopResponse.id,
|
||
user_id: shopResponse.merchant_id,
|
||
shop_name: shopResponse.shop_name,
|
||
shop_logo: this.normalizeImageUrl(shopResponse.shop_logo),
|
||
shop_banner: this.normalizeImageUrl(shopResponse.shop_banner),
|
||
shop_description: shopResponse.description ?? '',
|
||
contact_name: shopResponse.contact_name ?? '店主',
|
||
contact_phone: shopResponse.contact_phone ?? '',
|
||
shop_status: 1,
|
||
rating: shopResponse.rating_avg ?? 5.0,
|
||
total_sales: shopResponse.total_sales ?? 0,
|
||
created_at: shopResponse.created_at ?? new Date().toISOString()
|
||
} as MerchantType
|
||
realMerchantLoaded = true
|
||
}
|
||
} catch (e) {
|
||
console.error('Load shop failed', e)
|
||
}
|
||
}
|
||
|
||
if (!realMerchantLoaded) {
|
||
let charSum = 0
|
||
for (let i = 0; i < merchantId.length; i++) {
|
||
const charCode = merchantId.charCodeAt(i)
|
||
if (charCode != null) {
|
||
charSum += charCode
|
||
}
|
||
}
|
||
const merchantIndex = Math.abs(charSum) % 5
|
||
const shopNames = ['优质好店', '品牌直营店', '官方旗舰店', '专卖店', '精品小店']
|
||
|
||
this.merchant = {
|
||
id: merchantId,
|
||
user_id: 'user_mock_' + merchantIndex,
|
||
shop_name: shopNames[merchantIndex],
|
||
shop_logo: this.defaultImage,
|
||
shop_banner: this.defaultImage,
|
||
shop_description: '优质服务,正品保障',
|
||
contact_name: '店主',
|
||
contact_phone: '',
|
||
shop_status: 1,
|
||
rating: 4.8,
|
||
total_sales: 999,
|
||
created_at: '2023-01-01'
|
||
} as MerchantType
|
||
}
|
||
},
|
||
|
||
async loadProductSkus(productId: string) {
|
||
try {
|
||
const skus = await supabaseService.getProductSkus(productId)
|
||
if (skus.length > 0) {
|
||
this.productSkus = [] as Array<ProductSkuType>
|
||
for (let i = 0; i < skus.length; i++) {
|
||
const skuData = skus[i]
|
||
let specs: UTSJSONObject = {}
|
||
if (skuData.specifications != null && skuData.specifications != '') {
|
||
try {
|
||
specs = JSON.parse(skuData.specifications) as UTSJSONObject
|
||
} catch (e) {
|
||
console.error('解析SKU规格失败', e)
|
||
}
|
||
}
|
||
const sku: ProductSkuType = {
|
||
id: skuData.id,
|
||
product_id: skuData.product_id,
|
||
sku_code: skuData.sku_code,
|
||
specifications: specs,
|
||
price: skuData.price,
|
||
stock: skuData.stock ?? 0,
|
||
image_url: skuData.image_url ?? '',
|
||
status: skuData.status ?? 1
|
||
}
|
||
this.productSkus.push(sku)
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error('Fetch SKUs error', e)
|
||
}
|
||
},
|
||
|
||
async loadMemberPrice() {
|
||
try {
|
||
const memberInfo = await supabaseService.getUserMemberInfo()
|
||
const levelNameRaw = memberInfo.get('level_name')
|
||
const discountRaw = memberInfo.get('discount')
|
||
|
||
if (levelNameRaw != null) {
|
||
this.memberLevelName = levelNameRaw as string
|
||
}
|
||
|
||
if (discountRaw != null) {
|
||
const discountRate = discountRaw as number
|
||
if (discountRate > 0 && discountRate < 1) {
|
||
this.memberDiscount = Math.round(discountRate * 10) / 10 * 10
|
||
this.memberPrice = Math.round(this.product.price * discountRate * 100) / 100
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.log('获取会员信息失败,可能未登录或非会员:', e)
|
||
}
|
||
},
|
||
|
||
async loadCoupons() {
|
||
if (this.product.merchant_id == '') return
|
||
try {
|
||
const couponData = await supabaseService.fetchShopCoupons(this.product.merchant_id)
|
||
this.coupons = [] as Array<CouponTemplateType>
|
||
if (couponData != null && couponData.length > 0) {
|
||
for (let i = 0; i < couponData.length; i++) {
|
||
const item = couponData[i]
|
||
const couponObj = JSON.parse(JSON.stringify(item)) as UTSJSONObject
|
||
|
||
const getSafeString = (key: string): string => {
|
||
const val = couponObj.get(key)
|
||
if (val == null) return ''
|
||
if (typeof val == 'string') return val
|
||
return ''
|
||
}
|
||
|
||
const getSafeNumber = (key: string): number => {
|
||
const val = couponObj.get(key)
|
||
if (val == null) return 0
|
||
if (typeof val == 'number') return val
|
||
return 0
|
||
}
|
||
|
||
const coupon: CouponTemplateType = {
|
||
id: getSafeString('id'),
|
||
name: getSafeString('name'),
|
||
description: getSafeString('description'),
|
||
coupon_type: getSafeNumber('coupon_type'),
|
||
discount_type: getSafeNumber('discount_type'),
|
||
discount_value: getSafeNumber('discount_value'),
|
||
min_order_amount: getSafeNumber('min_order_amount'),
|
||
max_discount_amount: getSafeNumber('max_discount_amount'),
|
||
total_quantity: getSafeNumber('total_quantity'),
|
||
per_user_limit: getSafeNumber('per_user_limit'),
|
||
usage_limit: getSafeNumber('usage_limit'),
|
||
merchant_id: getSafeString('merchant_id'),
|
||
category_ids: [] as Array<string>,
|
||
product_ids: [] as Array<string>,
|
||
user_type_limit: getSafeNumber('user_type_limit'),
|
||
start_time: getSafeString('start_time'),
|
||
end_time: getSafeString('end_time'),
|
||
status: getSafeNumber('status'),
|
||
created_at: getSafeString('created_at')
|
||
}
|
||
this.coupons.push(coupon)
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.warn('SupabaseService coupon methods not available:', e)
|
||
}
|
||
},
|
||
|
||
async loadReviewPreview(productId: string) {
|
||
this.reviewLoading = true
|
||
try {
|
||
const stats = await supabaseService.getReviewStats(productId)
|
||
this.reviewCount = stats.getNumber('total_count') ?? 0
|
||
const avgRating = stats.getNumber('avg_rating') ?? 0
|
||
this.reviewRating = avgRating > 0 ? avgRating : 5
|
||
|
||
const reviewResult = await supabaseService.getProductReviews(productId, 1, 2, 0, false)
|
||
const dataRaw = reviewResult.get('data')
|
||
const parsedReviews = dataRaw != null
|
||
? this.parseReviews(dataRaw)
|
||
: [] as Array<ProductReviewPreviewType>
|
||
if (parsedReviews.length > 0) {
|
||
this.reviewPreview = parsedReviews
|
||
if (this.reviewCount <= 0) {
|
||
this.reviewCount = parsedReviews.length
|
||
}
|
||
} else {
|
||
this.reviewPreview = [] as Array<ProductReviewPreviewType>
|
||
if (this.reviewRating <= 0) {
|
||
this.reviewRating = 5
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error('加载商品评论失败:', e)
|
||
this.reviewPreview = [] as Array<ProductReviewPreviewType>
|
||
if (this.reviewRating <= 0) {
|
||
this.reviewRating = 5
|
||
}
|
||
} finally {
|
||
this.reviewLoading = false
|
||
}
|
||
},
|
||
|
||
parseReviews(rawData: any): Array<ProductReviewPreviewType> {
|
||
const result = [] as Array<ProductReviewPreviewType>
|
||
if (rawData == null) return result
|
||
try {
|
||
const rawList = JSON.parse(JSON.stringify(rawData)) as Array<UTSJSONObject>
|
||
if (!Array.isArray(rawList)) return result
|
||
|
||
for (let i = 0; i < rawList.length; i++) {
|
||
const item = rawList[i] as UTSJSONObject
|
||
const reviewImagesRaw = item.get('images')
|
||
const review: ProductReviewPreviewType = {
|
||
id: item.getString('id') ?? ('mock-review-' + i),
|
||
userName: item.getString('user_name') ?? '匿名用户',
|
||
rating: item.getNumber('rating') ?? 5,
|
||
content: item.getString('content') ?? '体验不错,包装完整,物流也很快。',
|
||
createdAt: item.getString('created_at') ?? new Date().toISOString(),
|
||
images: reviewImagesRaw != null ? this.readStringArrayFromRaw(reviewImagesRaw) : [] as Array<string>
|
||
}
|
||
if (review.images.length == 0) {
|
||
review.images = [] as Array<string>
|
||
}
|
||
result.push(review)
|
||
}
|
||
} catch (e) {
|
||
console.error('解析评论失败:', e)
|
||
}
|
||
return result
|
||
},
|
||
|
||
readStringArrayFromRaw(rawValue: any): Array<string> {
|
||
const result = [] as Array<string>
|
||
if (rawValue == null) return result
|
||
try {
|
||
if (typeof rawValue == 'string') {
|
||
const rawText = rawValue as string
|
||
if (rawText == '') return result
|
||
if (rawText.charAt(0) == '[') {
|
||
const parsed = JSON.parse(rawText)
|
||
if (Array.isArray(parsed)) {
|
||
for (let i = 0; i < parsed.length; i++) {
|
||
const item = parsed[i] as string
|
||
if (item != null && item !== '') {
|
||
result.push(this.normalizeImageUrl(item))
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
}
|
||
result.push(this.normalizeImageUrl(rawText))
|
||
return result
|
||
}
|
||
|
||
const parsedRaw = JSON.parse(JSON.stringify(rawValue))
|
||
if (Array.isArray(parsedRaw)) {
|
||
for (let i = 0; i < parsedRaw.length; i++) {
|
||
const item = parsedRaw[i] as string
|
||
if (item != null && item !== '') {
|
||
result.push(this.normalizeImageUrl(item))
|
||
}
|
||
}
|
||
}
|
||
} catch (e) {}
|
||
return result
|
||
},
|
||
|
||
resolveDetailImages(rawProductObj: UTSJSONObject): Array<string> {
|
||
const detailImagesRaw = rawProductObj.get('detail_images')
|
||
const images = detailImagesRaw != null ? this.readStringArrayFromRaw(detailImagesRaw) : [] as Array<string>
|
||
if (images.length > 0) return images
|
||
const backupImagesRaw = rawProductObj.get('detail_image_urls')
|
||
const backupImages = backupImagesRaw != null ? this.readStringArrayFromRaw(backupImagesRaw) : [] as Array<string>
|
||
if (backupImages.length > 0) return backupImages
|
||
return [] as Array<string>
|
||
},
|
||
|
||
resolveGuarantees(rawProductObj: UTSJSONObject): Array<ProductGuaranteeType> {
|
||
const defaults = this.getDefaultGuarantees()
|
||
const rawGuarantees = rawProductObj.get('guarantees')
|
||
if (rawGuarantees != null) {
|
||
try {
|
||
const parsedRaw = JSON.parse(JSON.stringify(rawGuarantees))
|
||
if (Array.isArray(parsedRaw)) {
|
||
const result = [] as Array<ProductGuaranteeType>
|
||
for (let i = 0; i < parsedRaw.length; i++) {
|
||
const item = parsedRaw[i]
|
||
if (typeof item == 'string') {
|
||
const title = item as string
|
||
if (title != null && title !== '') {
|
||
result.push({ id: 'guarantee-' + i, title: title, desc: title, enabled: true } as ProductGuaranteeType)
|
||
}
|
||
} else {
|
||
const itemObj = JSON.parse(JSON.stringify(item)) as UTSJSONObject
|
||
const title = itemObj.getString('title') ?? ''
|
||
if (title != '') {
|
||
result.push({
|
||
id: itemObj.getString('id') ?? ('guarantee-' + i),
|
||
title: title,
|
||
desc: itemObj.getString('desc') ?? title,
|
||
enabled: itemObj.getBoolean('enabled') ?? true
|
||
} as ProductGuaranteeType)
|
||
}
|
||
}
|
||
}
|
||
if (result.length > 0) return result
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
|
||
const tagGuarantees = this.resolveGuaranteesFromServiceTags()
|
||
if (tagGuarantees.length > 0) {
|
||
return tagGuarantees
|
||
}
|
||
return defaults
|
||
},
|
||
|
||
resolveGuaranteesFromServiceTags(): Array<ProductGuaranteeType> {
|
||
const defaults = this.getDefaultGuarantees()
|
||
const serviceTags = this.getServiceTags()
|
||
if (serviceTags.length == 0) return [] as Array<ProductGuaranteeType>
|
||
|
||
const result = [] as Array<ProductGuaranteeType>
|
||
for (let i = 0; i < defaults.length; i++) {
|
||
const item = defaults[i]
|
||
let enabled = item.enabled
|
||
for (let j = 0; j < serviceTags.length; j++) {
|
||
const tag = serviceTags[j]
|
||
if (tag.indexOf(item.title) !== -1 || item.title.indexOf(tag) !== -1) {
|
||
enabled = true
|
||
}
|
||
}
|
||
if (enabled) {
|
||
result.push({ id: item.id, title: item.title, desc: item.desc, enabled: true } as ProductGuaranteeType)
|
||
}
|
||
}
|
||
return result
|
||
},
|
||
|
||
getDefaultGuarantees(): Array<ProductGuaranteeType> {
|
||
return [
|
||
{ id: 'shipping', title: '发货保障', desc: '下单后尽快发货,异常可协商处理', enabled: true },
|
||
{ id: 'refund', title: '极速退款', desc: '符合条件的订单支持快速退款', enabled: true },
|
||
{ id: 'auth', title: '正品保障', desc: '商家承诺所售商品来源正规', enabled: true },
|
||
{ id: 'return', title: '7天无理由退货', desc: '支持七天无理由退货,特殊商品除外', enabled: true },
|
||
{ id: 'local', title: '本地配送', desc: '部分区域支持本地配送', enabled: false },
|
||
{ id: 'promise', title: '商家承诺', desc: '服务时效和售后说明以商家承诺为准', enabled: true }
|
||
] as Array<ProductGuaranteeType>
|
||
},
|
||
|
||
getMockReviews(): Array<ProductReviewPreviewType> {
|
||
return [
|
||
{
|
||
id: 'mock-review-1',
|
||
userName: '风和日暖',
|
||
rating: 5,
|
||
content: '包装完整,和页面描述一致,发货速度快,整体体验很好。',
|
||
createdAt: '2026-05-01T10:30:00',
|
||
images: [] as Array<string>
|
||
},
|
||
{
|
||
id: 'mock-review-2',
|
||
userName: '木子青',
|
||
rating: 4,
|
||
content: '价格合适,客服回复及时,已经回购一次,比较放心。',
|
||
createdAt: '2026-04-26T16:20:00',
|
||
images: [] as Array<string>
|
||
}
|
||
] as Array<ProductReviewPreviewType>
|
||
},
|
||
|
||
normalizeImageUrl(url: string | null): string {
|
||
if (url == null) return this.defaultImage
|
||
const trimmed = url.trim()
|
||
if (trimmed == '') return this.defaultImage
|
||
if (trimmed.indexOf('https://picsum.photos/') == 0) return this.defaultImage
|
||
if (trimmed.indexOf('http://picsum.photos/') == 0) return this.defaultImage
|
||
if (trimmed.indexOf('blob:') == 0) return this.defaultImage
|
||
if (trimmed.indexOf('/static/service/') == 0) return this.defaultImage
|
||
if (trimmed.indexOf('/static/images/product/') == 0) return this.defaultImage
|
||
if (trimmed.indexOf('__tmp__/') >= 0) return this.defaultImage
|
||
if (trimmed.indexOf('http://') == 0) return this.defaultImage
|
||
return trimmed
|
||
},
|
||
|
||
sanitizeProductImages(images: Array<string>): Array<string> {
|
||
const normalized = [] as Array<string>
|
||
for (let i = 0; i < images.length; i++) {
|
||
const fixed = this.normalizeImageUrl(images[i])
|
||
if (fixed != '') {
|
||
normalized.push(fixed)
|
||
}
|
||
}
|
||
if (normalized.length == 0) {
|
||
normalized.push(this.defaultImage)
|
||
}
|
||
return normalized
|
||
},
|
||
|
||
getProductImages(): Array<string> {
|
||
if (this.product.images.length > 0) {
|
||
return this.product.images
|
||
}
|
||
return [this.defaultImage]
|
||
},
|
||
|
||
getDetailImages(): Array<string> {
|
||
if (this.detailImages.length > 0) {
|
||
return this.detailImages
|
||
}
|
||
return [] as Array<string>
|
||
},
|
||
|
||
handleProductImageError(index: number) {
|
||
const currentImages = this.getProductImages()
|
||
const nextImages = [] as Array<string>
|
||
for (let i = 0; i < currentImages.length; i++) {
|
||
nextImages.push(i == index ? this.defaultImage : currentImages[i])
|
||
}
|
||
this.product.images = nextImages
|
||
},
|
||
|
||
handleDetailImageError(index: number) {
|
||
if (index < 0 || index >= this.detailImages.length) return
|
||
const nextImages = [] as Array<string>
|
||
for (let i = 0; i < this.detailImages.length; i++) {
|
||
nextImages.push(i == index ? this.defaultImage : this.detailImages[i])
|
||
}
|
||
this.detailImages = nextImages
|
||
},
|
||
|
||
getMerchantLogo(): string {
|
||
return this.normalizeImageUrl(this.merchant.shop_logo)
|
||
},
|
||
|
||
getMerchantName(): string {
|
||
if (this.merchant.shop_name != null && this.merchant.shop_name !== '') {
|
||
return this.merchant.shop_name
|
||
}
|
||
return '精选店铺'
|
||
},
|
||
|
||
getMerchantDescription(): string {
|
||
if (this.merchant.shop_description != null && this.merchant.shop_description !== '') {
|
||
return this.merchant.shop_description
|
||
}
|
||
return '服务以商家实际承诺为准'
|
||
},
|
||
|
||
handleShopLogoError() {
|
||
this.merchant.shop_logo = this.defaultImage
|
||
},
|
||
|
||
contactMerchant() {
|
||
if (supabaseService.getCurrentUserId() == '') {
|
||
uni.navigateTo({ url: '/pages/auth/login' })
|
||
return
|
||
}
|
||
const merchId = this.merchant.user_id != null && this.merchant.user_id !== ''
|
||
? this.merchant.user_id.toString()
|
||
: (this.merchant.id != null && this.merchant.id !== ''
|
||
? this.merchant.id.toString()
|
||
: (this.product.merchant_id != null ? this.product.merchant_id.toString() : ''))
|
||
const merchName = this.merchant.shop_name != null ? this.merchant.shop_name.toString() : ''
|
||
uni.navigateTo({
|
||
url: `/pages/mall/consumer/chat?merchantId=${encodeURIComponent(merchId)}&merchantName=${encodeURIComponent(merchName)}`,
|
||
fail: (error: any) => {
|
||
console.error('打开客服页面失败', error)
|
||
uni.showToast({ title: '页面打开失败', icon: 'none' })
|
||
}
|
||
})
|
||
},
|
||
|
||
handleContactService() {
|
||
const merchantUserId = this.merchant.user_id != null ? this.merchant.user_id.toString() : ''
|
||
const merchantId = this.merchant.id != null ? this.merchant.id.toString() : ''
|
||
const productMerchantId = this.product.merchant_id != null ? this.product.merchant_id.toString() : ''
|
||
const targetMerchantId = merchantUserId !== '' ? merchantUserId : (merchantId !== '' ? merchantId : productMerchantId)
|
||
if (targetMerchantId === '') {
|
||
uni.showToast({ title: '联系客服功能开发中', icon: 'none' })
|
||
return
|
||
}
|
||
this.contactMerchant()
|
||
},
|
||
|
||
showCouponModal() {
|
||
this.showCoupons = true
|
||
},
|
||
|
||
hideCouponModal() {
|
||
this.showCoupons = false
|
||
},
|
||
|
||
async claimCoupon(coupon: CouponTemplateType) {
|
||
const userId = supabaseService.getCurrentUserId() ?? ''
|
||
if (userId == '') {
|
||
uni.navigateTo({ url: '/pages/auth/login' })
|
||
return
|
||
}
|
||
uni.showLoading({ title: '领取中' })
|
||
|
||
let success = false
|
||
const couponId = coupon.id != null ? coupon.id : ''
|
||
if (couponId === '') {
|
||
uni.hideLoading()
|
||
uni.showToast({ title: '优惠券信息缺失', icon: 'none' })
|
||
return
|
||
}
|
||
try {
|
||
// @ts-ignore
|
||
success = await supabaseService.claimShopCoupon(couponId, userId)
|
||
} catch (e) {
|
||
try {
|
||
// @ts-ignore
|
||
success = await supabaseService.claimCoupon(couponId, userId)
|
||
} catch (e2) {
|
||
console.warn('claimCoupon method missing:', e2)
|
||
}
|
||
}
|
||
|
||
uni.hideLoading()
|
||
if (success === true) {
|
||
uni.showToast({ title: '领取成功', icon: 'success' })
|
||
} else {
|
||
uni.showToast({ title: '领取失败或已领取', icon: 'none' })
|
||
}
|
||
},
|
||
|
||
getSelectedSkuImage(): string {
|
||
if (this.selectedSkuId != '') {
|
||
const sku = this.productSkus.find(s => s.id === this.selectedSkuId)
|
||
if (sku != null && sku.image_url != null && sku.image_url != '') {
|
||
return this.normalizeImageUrl(sku.image_url as string)
|
||
}
|
||
}
|
||
return this.product.images.length > 0 ? this.product.images[0] : this.defaultImage
|
||
},
|
||
|
||
getSelectedSkuPrice(): string {
|
||
if (this.selectedSkuId != '') {
|
||
const sku = this.productSkus.find(s => s.id === this.selectedSkuId)
|
||
if (sku != null) return sku.price.toFixed(2)
|
||
}
|
||
return this.getDisplayPriceText()
|
||
},
|
||
|
||
getSelectedSkuStock(): number {
|
||
if (this.selectedSkuId != '') {
|
||
const sku = this.productSkus.find(s => s.id === this.selectedSkuId)
|
||
if (sku != null) return sku.stock
|
||
}
|
||
return this.product.stock > 0 ? this.product.stock : 0
|
||
},
|
||
|
||
formatDate(dateStr: string): string {
|
||
if (dateStr == '') return ''
|
||
const date = new Date(dateStr)
|
||
return `${date.getFullYear()}.${date.getMonth() + 1}.${date.getDate()}`
|
||
},
|
||
|
||
formatRating(value: number): string {
|
||
if (value <= 0) return '5.0'
|
||
return value.toFixed(1)
|
||
},
|
||
|
||
getReviewCountLabel(): string {
|
||
if (this.reviewCount <= 0) return '暂无评价'
|
||
return `${this.reviewCount}条评价`
|
||
},
|
||
|
||
getReviewStars(rating: number): string {
|
||
let result = ''
|
||
const safeRating = rating > 0 ? rating : 5
|
||
for (let i = 0; i < 5; i++) {
|
||
result += i < safeRating ? '★' : '☆'
|
||
}
|
||
return result
|
||
},
|
||
|
||
viewAllReviews() {
|
||
uni.showToast({ title: '查看全部评价开发中', icon: 'none' })
|
||
},
|
||
|
||
previewReviewImages(images: Array<string>, index: number) {
|
||
if (images.length == 0) return
|
||
uni.previewImage({ current: index, urls: images })
|
||
},
|
||
|
||
previewDetailImage(index: number) {
|
||
if (this.detailImages.length == 0) return
|
||
uni.previewImage({ current: index, urls: this.detailImages })
|
||
},
|
||
|
||
showGuaranteeIntro() {
|
||
uni.showToast({ title: '保障说明以后续商家配置为准', icon: 'none' })
|
||
},
|
||
|
||
onSwiperChange(e: UniSwiperChangeEvent) {
|
||
this.currentImageIndex = e.detail.current
|
||
},
|
||
|
||
showSpecModal() {
|
||
this.showSpec = true
|
||
},
|
||
|
||
hideSpecModal() {
|
||
this.showSpec = false
|
||
},
|
||
|
||
selectSku(sku: ProductSkuType) {
|
||
this.selectedSkuId = sku.id
|
||
this.selectedSpec = this.getSkuSpecText(sku)
|
||
},
|
||
|
||
isProductOffShelf(): boolean {
|
||
return this.product.status != null && this.product.status !== 1
|
||
},
|
||
|
||
showOffShelfToast() {
|
||
uni.showToast({ title: '该商品已下架', icon: 'none' })
|
||
},
|
||
|
||
getFootprintCachePayload(): UTSJSONObject {
|
||
const payload = new UTSJSONObject()
|
||
const productImage = this.product.images.length > 0 ? this.product.images[0] : this.defaultImage
|
||
payload.set('footprintId', '')
|
||
payload.set('productId', this.product.id)
|
||
payload.set('name', this.product.name)
|
||
payload.set('price', this.product.price)
|
||
payload.set('originalPrice', this.product.original_price ?? 0)
|
||
payload.set('image', productImage)
|
||
payload.set('merchantId', this.merchant.id != '' ? this.merchant.id : this.product.merchant_id)
|
||
payload.set('viewTime', Date.now())
|
||
payload.set('saleStatus', this.product.status ?? 0)
|
||
payload.set('isOffShelf', this.isProductOffShelf())
|
||
return payload
|
||
},
|
||
|
||
getSkuSpecText(sku: ProductSkuType): string {
|
||
if (sku.specifications != null) {
|
||
const specs = sku.specifications as UTSJSONObject
|
||
let specStr = ''
|
||
for (const key in specs) {
|
||
const val = specs[key]
|
||
if (val != null) {
|
||
specStr += (specStr === '' ? '' : ' ') + val.toString()
|
||
}
|
||
}
|
||
if (specStr !== '') {
|
||
return specStr
|
||
}
|
||
}
|
||
return sku.sku_code ?? ''
|
||
},
|
||
|
||
getSelectedSpecLabel(): string {
|
||
if (this.selectedSpec != '') return this.selectedSpec
|
||
if (this.productSkus.length > 0) return '请选择规格'
|
||
return '默认规格'
|
||
},
|
||
|
||
async addToCart() {
|
||
if (this.isProductOffShelf()) {
|
||
this.showOffShelfToast()
|
||
return
|
||
}
|
||
const userId = supabaseService.getCurrentUserId()
|
||
if (userId == null || userId === '') {
|
||
goToLogin(`/pages/mall/consumer/product-detail?productId=${this.product.id}`)
|
||
return
|
||
}
|
||
if (this.productSkus.length > 0 && (this.selectedSkuId == null || this.selectedSkuId === '')) {
|
||
if (!this.showSpec) {
|
||
this.showSpecModal()
|
||
}
|
||
return
|
||
}
|
||
|
||
uni.showLoading({ title: '添加中...' })
|
||
|
||
try {
|
||
const success = await supabaseService.addToCart(
|
||
this.product.id,
|
||
this.quantity,
|
||
this.selectedSkuId,
|
||
this.product.merchant_id
|
||
)
|
||
uni.hideLoading()
|
||
|
||
if (success === true) {
|
||
uni.showToast({ title: '已添加到购物车', icon: 'success' })
|
||
this.hideSpecModal()
|
||
this.refreshCartBadgeCount()
|
||
} else {
|
||
uni.showToast({ title: '添加失败,请登录重试', icon: 'none' })
|
||
}
|
||
} catch (e) {
|
||
uni.hideLoading()
|
||
console.error('添加购物车异常', e)
|
||
uni.showToast({ title: '添加异常', icon: 'none' })
|
||
}
|
||
},
|
||
|
||
buyNow() {
|
||
if (this.isProductOffShelf()) {
|
||
this.showOffShelfToast()
|
||
return
|
||
}
|
||
const userId = supabaseService.getCurrentUserId()
|
||
if (userId == null || userId === '') {
|
||
goToLogin(`/pages/mall/consumer/product-detail?productId=${this.product.id}`)
|
||
return
|
||
}
|
||
if (this.productSkus.length > 0 && (this.selectedSkuId == null || this.selectedSkuId === '')) {
|
||
if (!this.showSpec) {
|
||
this.showSpecModal()
|
||
}
|
||
return
|
||
}
|
||
|
||
const sku = (this.selectedSkuId != null && this.selectedSkuId !== '') ? this.productSkus.find(s => s.id === this.selectedSkuId) : null
|
||
|
||
const selectedItem = {
|
||
id: this.selectedSkuId,
|
||
product_id: this.product.id,
|
||
sku_id: this.selectedSkuId,
|
||
product_name: this.product.name,
|
||
product_image: (sku != null && sku.image_url != null && sku.image_url != '') ? sku.image_url : this.product.images[0],
|
||
sku_specifications: sku != null ? sku.specifications : {},
|
||
price: parseFloat((sku != null ? sku.price : this.product.price).toString()).toFixed(2) as string,
|
||
quantity: this.quantity as number,
|
||
shop_id: this.merchant.id,
|
||
shop_name: this.merchant.shop_name,
|
||
merchant_id: this.merchant.user_id ?? this.product.merchant_id
|
||
}
|
||
|
||
uni.setStorageSync('checkout_type', 'buy_now')
|
||
uni.setStorageSync('checkout_items', JSON.stringify([selectedItem]))
|
||
uni.navigateTo({ url: '/pages/mall/consumer/checkout' })
|
||
},
|
||
|
||
checkFavoriteStatus(id: string) {
|
||
this.checkFavorite(id)
|
||
},
|
||
|
||
async checkFavorite(id: string) {
|
||
const isFav = await supabaseService.checkFavorite(id)
|
||
this.isFavorite = isFav
|
||
},
|
||
|
||
async toggleFavorite() {
|
||
if (this.product.id == '') return
|
||
const userId = supabaseService.getCurrentUserId()
|
||
if (userId == null || userId === '') {
|
||
goToLogin(`/pages/mall/consumer/product-detail?productId=${this.product.id}`)
|
||
return
|
||
}
|
||
uni.showLoading({ title: '处理中' })
|
||
|
||
try {
|
||
const wasFavorite = this.isFavorite
|
||
const isNowFavorite = await supabaseService.toggleFavorite(this.product.id)
|
||
uni.hideLoading()
|
||
|
||
if (isNowFavorite !== wasFavorite) {
|
||
this.isFavorite = isNowFavorite
|
||
uni.showToast({ title: isNowFavorite ? '收藏成功' : '已取消收藏', icon: 'success' })
|
||
} else {
|
||
uni.showToast({ title: '操作失败', icon: 'none' })
|
||
this.checkFavoriteStatus(this.product.id)
|
||
}
|
||
} catch (e) {
|
||
uni.hideLoading()
|
||
console.error('Toggle favorite failed', e)
|
||
uni.showToast({ title: '操作异常', icon: 'none' })
|
||
}
|
||
},
|
||
|
||
goToHome() {
|
||
uni.switchTab({ url: '/pages/main/index' })
|
||
},
|
||
|
||
goToShop() {
|
||
const shopId = this.merchant.id != null ? this.merchant.id.toString() : ''
|
||
const merchantId = this.product.merchant_id != null ? this.product.merchant_id.toString() : ''
|
||
const targetId = shopId !== '' ? shopId : merchantId
|
||
if (targetId != '') {
|
||
uni.navigateTo({
|
||
url: `/pages/mall/consumer/shop-detail?merchantId=${encodeURIComponent(targetId)}`,
|
||
fail: (error: any) => {
|
||
console.error('打开店铺页面失败', error)
|
||
uni.showToast({ title: '页面打开失败', icon: 'none' })
|
||
}
|
||
})
|
||
} else {
|
||
uni.showToast({ title: '店铺信息缺失', icon: 'none' })
|
||
}
|
||
},
|
||
|
||
goToCart() {
|
||
uni.switchTab({
|
||
url: '/pages/main/cart',
|
||
fail: (error: any) => {
|
||
console.error('打开购物车页面失败', error)
|
||
uni.showToast({ title: '页面打开失败', icon: 'none' })
|
||
}
|
||
})
|
||
},
|
||
|
||
decreaseQuantity() {
|
||
if (this.quantity > 1) {
|
||
this.quantity--
|
||
}
|
||
},
|
||
|
||
increaseQuantity() {
|
||
const maxQuantity = this.getMaxQuantity()
|
||
if (this.quantity < maxQuantity) {
|
||
this.quantity++
|
||
} else {
|
||
uni.showToast({ title: `最多只能购买${maxQuantity}件`, icon: 'none' })
|
||
}
|
||
},
|
||
|
||
validateQuantity(e: UniInputEvent) {
|
||
let num = this.quantity
|
||
try {
|
||
const value = e.detail.value as string
|
||
const parsed = parseInt(value)
|
||
if (!isNaN(parsed)) {
|
||
num = parsed
|
||
}
|
||
} catch (err) {}
|
||
|
||
const maxQuantity = this.getMaxQuantity()
|
||
if (num < 1) {
|
||
num = 1
|
||
} else if (num > maxQuantity) {
|
||
num = maxQuantity
|
||
uni.showToast({ title: `最多只能购买${maxQuantity}件`, icon: 'none' })
|
||
}
|
||
this.quantity = num
|
||
},
|
||
|
||
getMaxQuantity(): number {
|
||
if (this.selectedSkuId != null && this.selectedSkuId !== '') {
|
||
const sku = this.productSkus.find(s => s.id === this.selectedSkuId)
|
||
if (sku != null && sku.stock > 0) return sku.stock
|
||
}
|
||
if (this.product.stock > 0) return this.product.stock
|
||
return 99
|
||
},
|
||
|
||
getAvailableStock(): number {
|
||
return this.getMaxQuantity()
|
||
},
|
||
|
||
getDisplayStockText(): string {
|
||
const stock = this.getAvailableStock()
|
||
if (stock > 0 && stock < 99) {
|
||
return `库存${stock}件`
|
||
}
|
||
if (stock > 0) {
|
||
return '库存充足'
|
||
}
|
||
return '库存以页面结算为准'
|
||
},
|
||
|
||
previewImage(index: number) {
|
||
uni.previewImage({ current: index, urls: this.getProductImages() })
|
||
},
|
||
|
||
showParamsModal() {
|
||
this.showParams = true
|
||
},
|
||
|
||
hideParamsModal() {
|
||
this.showParams = false
|
||
},
|
||
|
||
getParamsSummary(): string {
|
||
let summary = ''
|
||
if (this.product.specification != null && (this.product.specification as string) != '') summary += '规格 '
|
||
if (this.product.expiry_date != null && (this.product.expiry_date as string) != '') summary += '有效期 '
|
||
if (this.product.approval_number != null && (this.product.approval_number as string) != '') summary += '批准文号 '
|
||
const finalSummary = summary.trim()
|
||
return finalSummary != '' ? finalSummary : '查看详情'
|
||
},
|
||
|
||
getDisplayTitle(): string {
|
||
if (this.product.short_title != null && this.product.short_title !== '') {
|
||
return this.product.short_title
|
||
}
|
||
if (this.product.name != null && this.product.name !== '') {
|
||
return this.product.name
|
||
}
|
||
return '未命名商品'
|
||
},
|
||
|
||
getHighlightText(): string {
|
||
if (this.product.selling_points != null && this.product.selling_points.length > 0) {
|
||
const point = this.product.selling_points[0]
|
||
if (point != null && point !== '') {
|
||
return point
|
||
}
|
||
}
|
||
if (this.product.subtitle != null && this.product.subtitle !== '') {
|
||
return this.product.subtitle
|
||
}
|
||
return ''
|
||
},
|
||
|
||
getShortDescriptionText(): string {
|
||
if (this.product.subtitle != null && this.product.subtitle !== '') {
|
||
return this.product.subtitle
|
||
}
|
||
if (this.product.description != null && this.product.description !== '') {
|
||
return this.product.description as string
|
||
}
|
||
return ''
|
||
},
|
||
|
||
getCardTags(): Array<string> {
|
||
if (this.product.card_tags != null && this.product.card_tags.length > 0) {
|
||
return this.product.card_tags.slice(0, 2)
|
||
}
|
||
const fallbackTags = [] as Array<string>
|
||
if (this.product.tags != null && this.product.tags.length > 0) {
|
||
for (let i = 0; i < this.product.tags.length && i < 2; i++) {
|
||
const tag = this.product.tags[i]
|
||
if (tag != null && tag !== '') {
|
||
fallbackTags.push(tag)
|
||
}
|
||
}
|
||
}
|
||
return fallbackTags
|
||
},
|
||
|
||
getServiceTags(): Array<string> {
|
||
if (this.product.service_tags != null && this.product.service_tags.length > 0) {
|
||
return this.product.service_tags.slice(0, 3)
|
||
}
|
||
return [] as Array<string>
|
||
},
|
||
|
||
getVisibleGuarantees(): Array<ProductGuaranteeType> {
|
||
const result = [] as Array<ProductGuaranteeType>
|
||
for (let i = 0; i < this.productGuarantees.length; i++) {
|
||
const item = this.productGuarantees[i]
|
||
if (item.enabled === true) {
|
||
result.push(item)
|
||
}
|
||
}
|
||
return result
|
||
},
|
||
|
||
getDisplaySalesText(): string {
|
||
if (this.product.display_sales_text != null && this.product.display_sales_text !== '') {
|
||
return this.product.display_sales_text
|
||
}
|
||
const sales = this.product.sales > 0 ? this.product.sales : 0
|
||
if (sales >= 100000) return '已售10万+'
|
||
if (sales >= 10000) return '已售' + (sales / 10000).toFixed(1) + '万件'
|
||
return '已售' + sales + '件'
|
||
},
|
||
|
||
getDisplayPriceText(): string {
|
||
const price = (this.memberPrice > 0 && this.memberPrice < this.product.price) ? this.memberPrice : this.displayPrice
|
||
if (price > 0) {
|
||
return price.toFixed(2)
|
||
}
|
||
return '0.00'
|
||
},
|
||
|
||
getOriginalPriceText(): string {
|
||
if (this.memberPrice > 0 && this.memberPrice < this.product.price) {
|
||
return this.product.price > 0 ? this.product.price.toFixed(2) : ''
|
||
}
|
||
if (this.product.original_price != null && this.product.original_price > this.displayPrice) {
|
||
return this.product.original_price.toFixed(2)
|
||
}
|
||
return ''
|
||
},
|
||
|
||
getDetailDescription(): string {
|
||
if (this.product.description != null && this.product.description !== '') {
|
||
return this.product.description as string
|
||
}
|
||
if (this.product.subtitle != null && this.product.subtitle !== '') {
|
||
return this.product.subtitle as string
|
||
}
|
||
return ''
|
||
},
|
||
|
||
showSharePopup() {
|
||
this.showShare = true
|
||
},
|
||
|
||
hideSharePopup() {
|
||
this.showShare = false
|
||
},
|
||
|
||
shareToWechat() {
|
||
this.hideSharePopup()
|
||
// #ifdef MP-WEIXIN
|
||
uni.share({
|
||
provider: 'weixin',
|
||
scene: 'WXSceneSession',
|
||
type: 0,
|
||
title: this.product.name,
|
||
summary: `¥${this.product.price} - ${this.product.description ?? '精选好物'}`,
|
||
imageUrl: this.product.images.length > 0 ? this.product.images[0] : '',
|
||
success: () => {
|
||
uni.showToast({ title: '分享成功', icon: 'success' })
|
||
},
|
||
fail: (err) => {
|
||
console.error('分享失败', err)
|
||
uni.showToast({ title: '分享失败', icon: 'none' })
|
||
}
|
||
})
|
||
// #endif
|
||
// #ifndef MP-WEIXIN
|
||
uni.showToast({ title: '请在微信中打开分享', icon: 'none' })
|
||
// #endif
|
||
},
|
||
|
||
shareToMoments() {
|
||
this.hideSharePopup()
|
||
// #ifdef MP-WEIXIN
|
||
uni.share({
|
||
provider: 'weixin',
|
||
scene: 'WXSceneTimeline',
|
||
type: 0,
|
||
title: this.product.name,
|
||
summary: `¥${this.product.price} - ${this.product.description ?? '精选好物'}`,
|
||
imageUrl: this.product.images.length > 0 ? this.product.images[0] : '',
|
||
success: () => {
|
||
uni.showToast({ title: '分享成功', icon: 'success' })
|
||
},
|
||
fail: (err) => {
|
||
console.error('分享失败', err)
|
||
uni.showToast({ title: '分享失败', icon: 'none' })
|
||
}
|
||
})
|
||
// #endif
|
||
// #ifndef MP-WEIXIN
|
||
uni.showToast({ title: '请在微信中打开分享', icon: 'none' })
|
||
// #endif
|
||
},
|
||
|
||
shareToQQ() {
|
||
this.hideSharePopup()
|
||
uni.showToast({ title: 'QQ分享开发中', icon: 'none' })
|
||
},
|
||
|
||
copyLink() {
|
||
this.hideSharePopup()
|
||
const shareLink = `pages/mall/consumer/product-detail?productId=${this.product.id}`
|
||
uni.setClipboardData({
|
||
data: shareLink,
|
||
success: () => {
|
||
uni.showToast({ title: '链接已复制', icon: 'success' })
|
||
}
|
||
})
|
||
},
|
||
|
||
saveImage() {
|
||
this.hideSharePopup()
|
||
if (this.product.images.length > 0) {
|
||
uni.showLoading({ title: '保存中...' })
|
||
uni.downloadFile({
|
||
url: this.product.images[0],
|
||
success: (res) => {
|
||
const resObj = JSON.parse(JSON.stringify(res)) as UTSJSONObject
|
||
const tempFilePath = resObj.getString('tempFilePath') ?? ''
|
||
uni.saveImageToPhotosAlbum({
|
||
filePath: tempFilePath,
|
||
success: () => {
|
||
uni.hideLoading()
|
||
uni.showToast({ title: '已保存到相册', icon: 'success' })
|
||
},
|
||
fail: () => {
|
||
uni.hideLoading()
|
||
uni.showToast({ title: '保存失败', icon: 'none' })
|
||
}
|
||
})
|
||
},
|
||
fail: () => {
|
||
uni.hideLoading()
|
||
uni.showToast({ title: '下载失败', icon: 'none' })
|
||
}
|
||
})
|
||
} else {
|
||
uni.showToast({ title: '暂无图片可保存', icon: 'none' })
|
||
}
|
||
},
|
||
|
||
generatePoster() {
|
||
this.hideSharePopup()
|
||
uni.showToast({ title: '海报生成功能开发中', icon: 'none' })
|
||
}
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style>
|
||
.product-detail-page {
|
||
background-color: #f5f6fa;
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.page-scroll {
|
||
flex: 1;
|
||
height: 0;
|
||
width: 100%;
|
||
}
|
||
|
||
.hero-section {
|
||
position: relative;
|
||
height: 700rpx;
|
||
background: #ffffff;
|
||
}
|
||
|
||
.hero-swiper {
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
|
||
.hero-image {
|
||
width: 100%;
|
||
height: 100%;
|
||
background-color: #f5f5f5;
|
||
}
|
||
|
||
.hero-indicator {
|
||
position: absolute;
|
||
right: 24rpx;
|
||
bottom: 28rpx;
|
||
background-color: rgba(0, 0, 0, 0.45);
|
||
color: #ffffff;
|
||
padding: 10rpx 18rpx;
|
||
border-radius: 24rpx;
|
||
font-size: 24rpx;
|
||
}
|
||
|
||
.custom-header {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
z-index: 30;
|
||
}
|
||
|
||
.header-fade-layer {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
background-color: #ffffff;
|
||
border-bottom: 1rpx solid #f0f0f0;
|
||
z-index: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.header-main-row {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
flex: 1;
|
||
padding-left: 0;
|
||
padding-right: 0;
|
||
}
|
||
|
||
.header-back-space {
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.header-search-box {
|
||
flex: 1;
|
||
height: 64rpx;
|
||
background-color: #f5f6f8;
|
||
border-radius: 999rpx;
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
padding: 0 20rpx;
|
||
}
|
||
|
||
.header-search-icon {
|
||
font-size: 28rpx;
|
||
color: #9aa1ab;
|
||
margin-right: 8rpx;
|
||
}
|
||
|
||
.header-search-placeholder {
|
||
font-size: 26rpx;
|
||
color: #9aa1ab;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.header-capsule-reserve {
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.header-tabs-row {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
justify-content: space-around;
|
||
height: 80rpx;
|
||
padding-bottom: 4rpx;
|
||
}
|
||
|
||
.header-tab-item {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
position: relative;
|
||
height: 100%;
|
||
padding: 0 20rpx;
|
||
}
|
||
|
||
.header-tab-text {
|
||
font-size: 28rpx;
|
||
color: #5f6670;
|
||
}
|
||
|
||
.header-tab-text.active {
|
||
color: #ff4d24;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.header-tab-line {
|
||
position: absolute;
|
||
bottom: 8rpx;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
width: 32rpx;
|
||
height: 4rpx;
|
||
background-color: #ff4d24;
|
||
border-radius: 999rpx;
|
||
}
|
||
|
||
.persistent-back-btn {
|
||
position: fixed;
|
||
left: 16rpx;
|
||
width: 72rpx;
|
||
border-radius: 999rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 31;
|
||
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.08);
|
||
}
|
||
|
||
.persistent-back-text {
|
||
font-size: 36rpx;
|
||
color: #1f2329;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.recommend-card {
|
||
margin-bottom: 20rpx;
|
||
}
|
||
|
||
.empty-recommend-state {
|
||
padding: 40rpx 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.empty-recommend-text {
|
||
font-size: 24rpx;
|
||
color: #9aa1ab;
|
||
}
|
||
|
||
.content-wrap {
|
||
margin-top: -20rpx;
|
||
padding: 0 20rpx 180rpx;
|
||
position: relative;
|
||
z-index: 10;
|
||
}
|
||
|
||
.info-card {
|
||
background-color: #ffffff;
|
||
border-radius: 26rpx;
|
||
padding: 28rpx;
|
||
margin-bottom: 20rpx;
|
||
box-shadow: 0 8rpx 24rpx rgba(31, 35, 41, 0.04);
|
||
}
|
||
|
||
.price-sales-card {
|
||
background: linear-gradient(180deg, #fff8f4 0%, #ffffff 72%);
|
||
}
|
||
|
||
.off-shelf-tip {
|
||
margin-bottom: 18rpx;
|
||
padding: 14rpx 18rpx;
|
||
border-radius: 18rpx;
|
||
background-color: #fff1f0;
|
||
}
|
||
|
||
.off-shelf-tip-text {
|
||
font-size: 22rpx;
|
||
color: #d4380d;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.vip-row {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
margin-bottom: 18rpx;
|
||
}
|
||
|
||
.vip-badge {
|
||
background: linear-gradient(135deg, #ff5a1f 0%, #ff7a00 100%);
|
||
border-radius: 10rpx;
|
||
padding: 6rpx 14rpx;
|
||
margin-right: 12rpx;
|
||
}
|
||
|
||
.vip-badge-text {
|
||
font-size: 20rpx;
|
||
color: #ffffff;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.vip-discount-text {
|
||
font-size: 24rpx;
|
||
color: #ff5a1f;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.price-sales-row {
|
||
display: flex;
|
||
flex-direction: row;
|
||
justify-content: space-between;
|
||
align-items: flex-end;
|
||
}
|
||
|
||
.price-main-wrap {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: baseline;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.price-symbol {
|
||
font-size: 32rpx;
|
||
color: #ff4d24;
|
||
font-weight: 700;
|
||
margin-right: 6rpx;
|
||
}
|
||
|
||
.price-value {
|
||
font-size: 60rpx;
|
||
color: #ff4d24;
|
||
font-weight: 800;
|
||
line-height: 1;
|
||
}
|
||
|
||
.price-original {
|
||
font-size: 24rpx;
|
||
color: #9ea4ad;
|
||
text-decoration-line: line-through;
|
||
margin-left: 14rpx;
|
||
}
|
||
|
||
.sales-inline {
|
||
font-size: 24rpx;
|
||
color: #70767f;
|
||
margin-left: 20rpx;
|
||
padding-bottom: 8rpx;
|
||
}
|
||
|
||
.price-sub-row {
|
||
display: flex;
|
||
flex-direction: row;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-top: 16rpx;
|
||
}
|
||
|
||
.save-text {
|
||
font-size: 22rpx;
|
||
color: #20a162;
|
||
background-color: #effaf5;
|
||
padding: 6rpx 14rpx;
|
||
border-radius: 999rpx;
|
||
}
|
||
|
||
.stock-text {
|
||
font-size: 22rpx;
|
||
color: #7b8088;
|
||
}
|
||
|
||
.quick-buy-row {
|
||
display: flex;
|
||
flex-direction: row;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-top: 22rpx;
|
||
padding-top: 22rpx;
|
||
border-top: 1rpx solid #f3f4f7;
|
||
}
|
||
|
||
.quick-row-label {
|
||
font-size: 26rpx;
|
||
color: #222222;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.quantity-selector {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
border: 1rpx solid #eceff4;
|
||
border-radius: 999rpx;
|
||
overflow: hidden;
|
||
background-color: #fafbfc;
|
||
}
|
||
|
||
.quantity-btn {
|
||
width: 68rpx;
|
||
height: 68rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.quantity-btn-text {
|
||
font-size: 28rpx;
|
||
color: #222222;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.quantity-input {
|
||
width: 86rpx;
|
||
height: 68rpx;
|
||
text-align: center;
|
||
font-size: 28rpx;
|
||
color: #222222;
|
||
background-color: #ffffff;
|
||
}
|
||
|
||
.coupon-card {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
}
|
||
|
||
.coupon-label {
|
||
font-size: 28rpx;
|
||
color: #ff4d24;
|
||
font-weight: 700;
|
||
margin-right: 20rpx;
|
||
}
|
||
|
||
.coupon-tags-wrap {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: row;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.coupon-chip {
|
||
padding: 8rpx 16rpx;
|
||
border-radius: 999rpx;
|
||
background-color: #fff1ed;
|
||
color: #ff5a2c;
|
||
font-size: 22rpx;
|
||
margin-right: 12rpx;
|
||
margin-bottom: 8rpx;
|
||
}
|
||
|
||
.coupon-arrow {
|
||
font-size: 24rpx;
|
||
color: #8b919a;
|
||
}
|
||
|
||
.title-tag-row {
|
||
display: flex;
|
||
flex-direction: row;
|
||
flex-wrap: wrap;
|
||
margin-bottom: 16rpx;
|
||
}
|
||
|
||
.detail-card-tag {
|
||
padding: 6rpx 14rpx;
|
||
border-radius: 999rpx;
|
||
background: #e1251b;
|
||
color: #fff7d1;
|
||
font-size: 20rpx;
|
||
font-weight: 700;
|
||
margin-right: 10rpx;
|
||
margin-bottom: 8rpx;
|
||
}
|
||
|
||
.title-body-row {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.title-main-wrap {
|
||
flex: 1;
|
||
padding-right: 20rpx;
|
||
}
|
||
|
||
.product-title {
|
||
font-size: 34rpx;
|
||
font-weight: 800;
|
||
color: #1f2329;
|
||
line-height: 1.45;
|
||
max-height: 100rpx;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.product-highlight-text {
|
||
display: block;
|
||
font-size: 24rpx;
|
||
line-height: 36rpx;
|
||
color: #5d6470;
|
||
margin-top: 14rpx;
|
||
}
|
||
|
||
.title-side-actions {
|
||
width: 120rpx;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: stretch;
|
||
}
|
||
|
||
.side-action-btn {
|
||
min-height: 92rpx;
|
||
border-radius: 18rpx;
|
||
background-color: #f7f8fb;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
margin-bottom: 12rpx;
|
||
}
|
||
|
||
.side-action-btn:last-child {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
.side-action-icon {
|
||
width: 36rpx;
|
||
height: 36rpx;
|
||
margin-bottom: 8rpx;
|
||
}
|
||
|
||
.side-action-symbol {
|
||
font-size: 30rpx;
|
||
color: #252b33;
|
||
margin-bottom: 6rpx;
|
||
}
|
||
|
||
.side-action-text {
|
||
font-size: 20rpx;
|
||
color: #646b75;
|
||
}
|
||
|
||
.detail-service-row {
|
||
display: flex;
|
||
flex-direction: row;
|
||
flex-wrap: wrap;
|
||
margin-top: 20rpx;
|
||
}
|
||
|
||
.detail-service-tag {
|
||
padding: 8rpx 14rpx;
|
||
border-radius: 999rpx;
|
||
font-size: 20rpx;
|
||
font-weight: 600;
|
||
color: #12b76a;
|
||
background: #ecfdf3;
|
||
margin-right: 10rpx;
|
||
margin-bottom: 10rpx;
|
||
}
|
||
|
||
.meta-link-row {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
margin-top: 20rpx;
|
||
}
|
||
|
||
.meta-link-text {
|
||
font-size: 24rpx;
|
||
color: #606874;
|
||
}
|
||
|
||
.meta-link-divider {
|
||
font-size: 22rpx;
|
||
color: #d0d5dc;
|
||
margin: 0 12rpx;
|
||
}
|
||
|
||
.selling-points-panel {
|
||
margin-top: 22rpx;
|
||
padding: 18rpx 20rpx;
|
||
border-radius: 18rpx;
|
||
background-color: #fff8f3;
|
||
}
|
||
|
||
.selling-point-item {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: flex-start;
|
||
margin-bottom: 10rpx;
|
||
}
|
||
|
||
.selling-point-item:last-child {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
.selling-point-dot {
|
||
width: 10rpx;
|
||
height: 10rpx;
|
||
border-radius: 999rpx;
|
||
background-color: #ff5a2c;
|
||
margin-top: 12rpx;
|
||
margin-right: 12rpx;
|
||
}
|
||
|
||
.selling-point-text {
|
||
flex: 1;
|
||
font-size: 23rpx;
|
||
line-height: 34rpx;
|
||
color: #5b6470;
|
||
}
|
||
|
||
.card-header-row {
|
||
display: flex;
|
||
flex-direction: row;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 20rpx;
|
||
}
|
||
|
||
.card-title {
|
||
font-size: 30rpx;
|
||
color: #1f2329;
|
||
font-weight: 800;
|
||
}
|
||
|
||
.card-link-text {
|
||
font-size: 24rpx;
|
||
color: #7b8088;
|
||
}
|
||
|
||
.guarantee-list {
|
||
display: flex;
|
||
flex-direction: row;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.guarantee-chip {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
margin-right: 18rpx;
|
||
margin-bottom: 12rpx;
|
||
padding: 8rpx 0;
|
||
}
|
||
|
||
.guarantee-dot {
|
||
width: 12rpx;
|
||
height: 12rpx;
|
||
border-radius: 999rpx;
|
||
background-color: #ff5a2c;
|
||
margin-right: 10rpx;
|
||
}
|
||
|
||
.guarantee-text {
|
||
font-size: 24rpx;
|
||
color: #4f5662;
|
||
}
|
||
|
||
.shop-card {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
}
|
||
|
||
.shop-logo {
|
||
width: 92rpx;
|
||
height: 92rpx;
|
||
border-radius: 18rpx;
|
||
margin-right: 20rpx;
|
||
background-color: #f7f8fa;
|
||
}
|
||
|
||
.shop-details {
|
||
flex: 1;
|
||
}
|
||
|
||
.shop-name {
|
||
font-size: 30rpx;
|
||
color: #1f2329;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.shop-stats-row {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
margin-top: 10rpx;
|
||
}
|
||
|
||
.shop-stat-text {
|
||
font-size: 22rpx;
|
||
color: #6a707a;
|
||
margin-right: 20rpx;
|
||
}
|
||
|
||
.shop-desc {
|
||
display: block;
|
||
font-size: 22rpx;
|
||
color: #8d949d;
|
||
margin-top: 10rpx;
|
||
}
|
||
|
||
.enter-shop {
|
||
font-size: 24rpx;
|
||
color: #ff5a2c;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.review-summary-row {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: baseline;
|
||
margin-bottom: 18rpx;
|
||
}
|
||
|
||
.review-score {
|
||
font-size: 50rpx;
|
||
color: #ff5a2c;
|
||
font-weight: 800;
|
||
margin-right: 12rpx;
|
||
}
|
||
|
||
.review-score-label {
|
||
font-size: 22rpx;
|
||
color: #6f7680;
|
||
margin-right: 20rpx;
|
||
}
|
||
|
||
.review-count {
|
||
font-size: 22rpx;
|
||
color: #6f7680;
|
||
}
|
||
|
||
.review-item {
|
||
padding: 20rpx 0;
|
||
border-top: 1rpx solid #f2f3f6;
|
||
}
|
||
|
||
.review-meta-row {
|
||
display: flex;
|
||
flex-direction: row;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 12rpx;
|
||
}
|
||
|
||
.review-user {
|
||
font-size: 24rpx;
|
||
color: #2a3038;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.review-stars {
|
||
font-size: 22rpx;
|
||
color: #ffb400;
|
||
}
|
||
|
||
.review-content {
|
||
font-size: 24rpx;
|
||
color: #4f5662;
|
||
line-height: 36rpx;
|
||
}
|
||
|
||
.review-image-row {
|
||
display: flex;
|
||
flex-direction: row;
|
||
margin-top: 14rpx;
|
||
}
|
||
|
||
.review-image {
|
||
width: 132rpx;
|
||
height: 132rpx;
|
||
border-radius: 16rpx;
|
||
margin-right: 12rpx;
|
||
background-color: #f7f8fa;
|
||
}
|
||
|
||
.review-date {
|
||
display: block;
|
||
font-size: 20rpx;
|
||
color: #98a0aa;
|
||
margin-top: 14rpx;
|
||
}
|
||
|
||
.detail-text-block {
|
||
padding: 20rpx;
|
||
border-radius: 18rpx;
|
||
background-color: #fafbfc;
|
||
margin-bottom: 20rpx;
|
||
}
|
||
|
||
.detail-block-title {
|
||
display: block;
|
||
font-size: 26rpx;
|
||
color: #232931;
|
||
font-weight: 700;
|
||
margin-bottom: 12rpx;
|
||
}
|
||
|
||
.detail-block-text {
|
||
font-size: 24rpx;
|
||
color: #5c6370;
|
||
line-height: 36rpx;
|
||
}
|
||
|
||
.description-text {
|
||
font-size: 26rpx;
|
||
color: #575f6c;
|
||
line-height: 40rpx;
|
||
}
|
||
|
||
.detail-images {
|
||
margin-top: 8rpx;
|
||
}
|
||
|
||
.detail-image {
|
||
width: 100%;
|
||
margin-bottom: 18rpx;
|
||
border-radius: 18rpx;
|
||
background-color: #f7f8fa;
|
||
}
|
||
|
||
.empty-detail-state {
|
||
padding: 40rpx 0 20rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.empty-detail-text {
|
||
font-size: 24rpx;
|
||
color: #9aa1ab;
|
||
}
|
||
|
||
.bottom-actions {
|
||
position: fixed;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background-color: #ffffff;
|
||
box-shadow: 0 -8rpx 24rpx rgba(0, 0, 0, 0.06);
|
||
padding: 14rpx 20rpx calc(14rpx + env(safe-area-inset-bottom));
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
z-index: 50;
|
||
}
|
||
|
||
.bottom-left-actions {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
width: 300rpx;
|
||
margin-right: 16rpx;
|
||
}
|
||
|
||
.bottom-action-item {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.bottom-action-icon {
|
||
width: 42rpx;
|
||
height: 42rpx;
|
||
margin-bottom: 6rpx;
|
||
}
|
||
|
||
.bottom-action-shop-icon {
|
||
width: 42rpx;
|
||
height: 42rpx;
|
||
line-height: 42rpx;
|
||
text-align: center;
|
||
font-size: 28rpx;
|
||
color: #5f6670;
|
||
margin-bottom: 6rpx;
|
||
border-width: 1rpx;
|
||
border-style: solid;
|
||
border-color: #d9dde3;
|
||
border-radius: 10rpx;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.bottom-action-text {
|
||
font-size: 20rpx;
|
||
color: #68707a;
|
||
}
|
||
|
||
.cart-action-item {
|
||
position: relative;
|
||
}
|
||
|
||
.cart-icon-wrap {
|
||
position: relative;
|
||
width: 42rpx;
|
||
height: 42rpx;
|
||
margin-bottom: 6rpx;
|
||
}
|
||
|
||
.cart-badge {
|
||
position: absolute;
|
||
top: -10rpx;
|
||
right: -18rpx;
|
||
min-width: 28rpx;
|
||
height: 28rpx;
|
||
line-height: 28rpx;
|
||
border-radius: 14rpx;
|
||
background-color: #e02e24;
|
||
color: #ffffff;
|
||
font-size: 18rpx;
|
||
text-align: center;
|
||
padding-left: 6rpx;
|
||
padding-right: 6rpx;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.btn-group {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
}
|
||
|
||
.cart-btn,
|
||
.buy-btn {
|
||
flex: 1;
|
||
height: 78rpx;
|
||
line-height: 78rpx;
|
||
border-radius: 999rpx;
|
||
font-size: 28rpx;
|
||
font-weight: 700;
|
||
border: none;
|
||
}
|
||
|
||
.cart-btn {
|
||
background: linear-gradient(90deg, #ffb13d 0%, #ff8f1f 100%);
|
||
color: #ffffff;
|
||
margin-right: 14rpx;
|
||
}
|
||
|
||
.buy-btn {
|
||
background: linear-gradient(90deg, #ff5d2c 0%, #ff3b30 100%);
|
||
color: #ffffff;
|
||
}
|
||
|
||
.btn-disabled {
|
||
background: #d8dbe1 !important;
|
||
color: #ffffff !important;
|
||
}
|
||
|
||
.spec-modal,
|
||
.params-modal,
|
||
.popup-mask,
|
||
.share-popup-mask {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background-color: rgba(0, 0, 0, 0.48);
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
flex-direction: column;
|
||
z-index: 1000;
|
||
}
|
||
|
||
.spec-content,
|
||
.params-content,
|
||
.popup-content,
|
||
.share-popup-content {
|
||
background-color: #ffffff;
|
||
width: 100%;
|
||
border-radius: 28rpx 28rpx 0 0;
|
||
padding: 30rpx;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.spec-content {
|
||
max-height: 80%;
|
||
position: relative;
|
||
}
|
||
|
||
.params-content,
|
||
.popup-content {
|
||
max-height: 1000rpx;
|
||
}
|
||
|
||
.share-popup-content {
|
||
padding-bottom: calc(30rpx + env(safe-area-inset-bottom));
|
||
}
|
||
|
||
.spec-header-jd {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: flex-end;
|
||
position: relative;
|
||
padding-bottom: 28rpx;
|
||
border-bottom: 1rpx solid #f1f2f5;
|
||
}
|
||
|
||
.spec-product-img {
|
||
width: 180rpx;
|
||
height: 180rpx;
|
||
border-radius: 18rpx;
|
||
margin-top: -64rpx;
|
||
background-color: #ffffff;
|
||
border: 4rpx solid #ffffff;
|
||
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.08);
|
||
}
|
||
|
||
.spec-info-jd {
|
||
flex: 1;
|
||
margin-left: 22rpx;
|
||
padding-bottom: 8rpx;
|
||
}
|
||
|
||
.spec-price-row {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: baseline;
|
||
margin-bottom: 8rpx;
|
||
}
|
||
|
||
.spec-stock-jd,
|
||
.spec-choosed-jd {
|
||
display: block;
|
||
font-size: 24rpx;
|
||
color: #6f7680;
|
||
margin-bottom: 8rpx;
|
||
}
|
||
|
||
.close-btn-jd,
|
||
.close-btn {
|
||
font-size: 48rpx;
|
||
color: #9aa1ab;
|
||
}
|
||
|
||
.close-btn-jd {
|
||
position: absolute;
|
||
right: -10rpx;
|
||
top: -10rpx;
|
||
padding: 10rpx;
|
||
}
|
||
|
||
.spec-error-tip {
|
||
position: absolute;
|
||
top: -76rpx;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
background-color: rgba(0, 0, 0, 0.72);
|
||
padding: 12rpx 28rpx;
|
||
border-radius: 999rpx;
|
||
z-index: 2000;
|
||
}
|
||
|
||
.error-tip-text {
|
||
color: #ffffff;
|
||
font-size: 22rpx;
|
||
}
|
||
|
||
.spec-list-jd {
|
||
flex: 1;
|
||
}
|
||
|
||
.spec-group {
|
||
padding: 28rpx 0;
|
||
}
|
||
|
||
.group-title {
|
||
font-size: 28rpx;
|
||
color: #1f2329;
|
||
font-weight: 700;
|
||
margin-bottom: 22rpx;
|
||
display: block;
|
||
}
|
||
|
||
.group-tags {
|
||
display: flex;
|
||
flex-direction: row;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.spec-tag {
|
||
padding: 16rpx 28rpx;
|
||
border-radius: 999rpx;
|
||
background-color: #f6f7fb;
|
||
border: 2rpx solid #f6f7fb;
|
||
margin-right: 16rpx;
|
||
margin-bottom: 16rpx;
|
||
}
|
||
|
||
.spec-tag.active {
|
||
background-color: #fff1ed;
|
||
border-color: #ff5a2c;
|
||
}
|
||
|
||
.spec-tag.active .tag-text {
|
||
color: #ff5a2c;
|
||
}
|
||
|
||
.tag-text {
|
||
font-size: 24rpx;
|
||
color: #2d333b;
|
||
}
|
||
|
||
.spec-quantity-row {
|
||
display: flex;
|
||
flex-direction: row;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 24rpx 0 12rpx;
|
||
}
|
||
|
||
.quantity-selector-jd {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
background-color: #f6f7fb;
|
||
border-radius: 12rpx;
|
||
}
|
||
|
||
.q-btn {
|
||
width: 64rpx;
|
||
height: 64rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.q-btn-text {
|
||
font-size: 34rpx;
|
||
color: #2d333b;
|
||
}
|
||
|
||
.q-input {
|
||
width: 88rpx;
|
||
height: 64rpx;
|
||
text-align: center;
|
||
font-size: 28rpx;
|
||
color: #2d333b;
|
||
background-color: #ffffff;
|
||
}
|
||
|
||
.spec-footer-jd {
|
||
display: flex;
|
||
flex-direction: row;
|
||
padding-top: 20rpx;
|
||
}
|
||
|
||
.footer-btn {
|
||
flex: 1;
|
||
height: 82rpx;
|
||
line-height: 82rpx;
|
||
border-radius: 999rpx;
|
||
font-size: 28rpx;
|
||
font-weight: 700;
|
||
border: none;
|
||
}
|
||
|
||
.footer-btn.cart {
|
||
background: linear-gradient(90deg, #ffb13d 0%, #ff8f1f 100%);
|
||
color: #ffffff;
|
||
margin-right: 14rpx;
|
||
}
|
||
|
||
.footer-btn.buy {
|
||
background: linear-gradient(90deg, #ff5d2c 0%, #ff3b30 100%);
|
||
color: #ffffff;
|
||
}
|
||
|
||
.params-header,
|
||
.popup-header,
|
||
.share-popup-header {
|
||
display: flex;
|
||
flex-direction: row;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 28rpx;
|
||
padding-bottom: 20rpx;
|
||
border-bottom: 1rpx solid #f0f1f4;
|
||
}
|
||
|
||
.params-title,
|
||
.popup-title,
|
||
.share-popup-title {
|
||
font-size: 32rpx;
|
||
color: #1f2329;
|
||
font-weight: 800;
|
||
}
|
||
|
||
.params-list,
|
||
.coupon-list-scroll {
|
||
flex: 1;
|
||
}
|
||
|
||
.params-item {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: flex-start;
|
||
padding: 20rpx 0;
|
||
border-bottom: 1rpx solid #f4f5f8;
|
||
}
|
||
|
||
.params-label {
|
||
width: 150rpx;
|
||
flex-shrink: 0;
|
||
font-size: 26rpx;
|
||
color: #2d333b;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.params-value {
|
||
flex: 1;
|
||
font-size: 26rpx;
|
||
color: #616975;
|
||
line-height: 38rpx;
|
||
}
|
||
|
||
.coupon-item {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: stretch;
|
||
background-color: #fff5f2;
|
||
border-radius: 20rpx;
|
||
padding: 20rpx;
|
||
margin-bottom: 20rpx;
|
||
}
|
||
|
||
.coupon-left {
|
||
width: 180rpx;
|
||
border-right: 1rpx dashed #ffd0c4;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: #ff5a2c;
|
||
}
|
||
|
||
.coupon-amount {
|
||
font-size: 42rpx;
|
||
font-weight: 800;
|
||
}
|
||
|
||
.symbol {
|
||
font-size: 24rpx;
|
||
}
|
||
|
||
.coupon-cond {
|
||
font-size: 22rpx;
|
||
margin-top: 8rpx;
|
||
}
|
||
|
||
.coupon-right {
|
||
flex: 1;
|
||
padding-left: 20rpx;
|
||
display: flex;
|
||
flex-direction: row;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
|
||
.coupon-info-text {
|
||
flex: 1;
|
||
}
|
||
|
||
.coupon-name {
|
||
display: block;
|
||
font-size: 28rpx;
|
||
color: #262c34;
|
||
font-weight: 700;
|
||
margin-bottom: 10rpx;
|
||
}
|
||
|
||
.coupon-time {
|
||
font-size: 22rpx;
|
||
color: #9097a1;
|
||
}
|
||
|
||
.coupon-btn {
|
||
height: 56rpx;
|
||
line-height: 56rpx;
|
||
border-radius: 999rpx;
|
||
background-color: #ff5a2c;
|
||
color: #ffffff;
|
||
font-size: 24rpx;
|
||
padding: 0 24rpx;
|
||
margin: 0;
|
||
}
|
||
|
||
.share-options {
|
||
display: flex;
|
||
flex-direction: row;
|
||
flex-wrap: wrap;
|
||
padding: 10rpx 0;
|
||
}
|
||
|
||
.share-option {
|
||
width: 25%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
margin-bottom: 28rpx;
|
||
}
|
||
|
||
.share-icon-wrapper {
|
||
width: 100rpx;
|
||
height: 100rpx;
|
||
border-radius: 999rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
margin-bottom: 14rpx;
|
||
}
|
||
|
||
.share-icon-wrapper.wechat,
|
||
.share-icon-wrapper.moments {
|
||
background-color: #07c160;
|
||
}
|
||
|
||
.share-icon-wrapper.qq {
|
||
background-color: #12b7f5;
|
||
}
|
||
|
||
.share-icon-wrapper.link {
|
||
background-color: #ff9500;
|
||
}
|
||
|
||
.share-icon-wrapper.image {
|
||
background-color: #ff2d55;
|
||
}
|
||
|
||
.share-icon-wrapper.poster {
|
||
background-color: #5856d6;
|
||
}
|
||
|
||
.share-option-icon {
|
||
font-size: 34rpx;
|
||
color: #ffffff;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.share-option-text {
|
||
font-size: 22rpx;
|
||
color: #2b3139;
|
||
}
|
||
|
||
.share-cancel-btn {
|
||
width: 100%;
|
||
height: 88rpx;
|
||
border-radius: 999rpx;
|
||
background-color: #f4f5f8;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
margin-top: 12rpx;
|
||
}
|
||
|
||
.cancel-text {
|
||
font-size: 28rpx;
|
||
color: #2b3139;
|
||
font-weight: 600;
|
||
}
|
||
</style>
|