Files
medical-mall/pages/mall/consumer/product-detail.uvue
2026-05-14 15:28:09 +08:00

3073 lines
86 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- 消费者端 - 商品详情页 -->
<template>
<view class="product-detail-page">
<scroll-view class="page-scroll" direction="vertical">
<view 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 class="floating-nav left-nav" :style="getNavLeftStyle()">
<view class="floating-btn" @click="goBack">
<text class="floating-btn-text"></text>
</view>
<view class="floating-btn" @click="goToSearch">
<text class="floating-btn-text">⌕</text>
</view>
</view>
<view class="floating-nav right-nav" :style="getNavRightStyle()">
<view class="floating-btn" @click="openActionPanel">
<text class="floating-btn-text">...</text>
</view>
</view>
</view>
<view class="content-wrap">
<view class="info-card price-sales-card">
<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 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 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)" />
</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>
</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" @click="addToCart">加入购物车</button>
<button class="buy-btn" @click="buyNow">立即购买</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>
}
},
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 = this.getMockReviews()
if (productId != null) {
this.loadProductDetail(productId, {
price: productPrice,
originalPrice: productOriginalPrice,
name: productName,
image: productImage
})
this.checkFavoriteStatus(productId)
this.saveFootprint(productId)
}
},
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() {
const systemInfo = uni.getSystemInfoSync()
this.statusBarHeight = systemInfo.statusBarHeight ?? 0
this.capsuleTop = this.statusBarHeight > 0 ? this.statusBarHeight : 12
this.capsuleHeight = 32
this.navButtonTop = this.capsuleTop
this.navButtonHeight = this.capsuleHeight
this.navRightReserve = 16
// #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
const screenWidth = systemInfo.screenWidth ?? systemInfo.windowWidth
this.navRightReserve = (screenWidth - menuBtn.left) + 12
}
} catch (e) {
this.navButtonTop = this.statusBarHeight > 0 ? this.statusBarHeight + 6 : 16
this.navButtonHeight = 32
this.navRightReserve = 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
}
},
getNavLeftStyle(): string {
return `top:${this.navButtonTop}px;height:${this.navButtonHeight}px;`
},
getNavRightStyle(): string {
return `top:${this.navButtonTop}px;height:${this.navButtonHeight}px;right:${this.navRightReserve}px;`
},
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') 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('id') ?? ''
return itemId != productIdStr
})
const productImage = this.product.images.length > 0 ? this.product.images[0] : this.defaultImage
footprints.unshift({
id: this.product.id,
name: this.product.name,
price: this.product.price,
original_price: this.product.original_price,
image: productImage,
sales: this.product.sales,
shopId: this.merchant.id,
shopName: this.merchant.shop_name,
viewTime: Date.now()
})
if (footprints.length > 50) {
footprints = footprints.slice(0, 50)
}
uni.setStorageSync('footprints', 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 = this.getMockReviews()
if (this.reviewCount <= 0) {
this.reviewCount = this.reviewPreview.length
}
if (this.reviewRating <= 0) {
this.reviewRating = 5
}
}
} catch (e) {
console.error('加载商品评论失败:', e)
this.reviewPreview = this.getMockReviews()
if (this.reviewCount <= 0) {
this.reviewCount = this.reviewPreview.length
}
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
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)
},
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() {
const userId = supabaseService.getCurrentUserId()
if (userId == null || userId === '') {
goToLogin(`/pages/mall/consumer/product-detail?id=${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() {
const userId = supabaseService.getCurrentUserId()
if (userId == null || userId === '') {
goToLogin(`/pages/mall/consumer/product-detail?id=${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?id=${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?id=${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;
}
.floating-nav {
position: absolute;
display: flex;
flex-direction: row;
align-items: center;
z-index: 20;
}
.left-nav {
left: 24rpx;
}
.right-nav {
right: 24rpx;
}
.floating-btn {
min-width: 72rpx;
height: 100%;
padding: 0 20rpx;
border-radius: 999rpx;
background-color: rgba(255, 255, 255, 0.88);
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-right: 14rpx;
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.08);
}
.right-nav .floating-btn {
margin-right: 0;
}
.floating-btn-text {
font-size: 28rpx;
color: #1f2329;
font-weight: 700;
}
.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%);
}
.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;
}
.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>