Files
medical-mall/pages/mall/consumer/product-detail.uvue
2026-03-06 17:30:50 +08:00

1608 lines
44 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" scroll-y="true">
<!-- 商品图片轮播 -->
<view class="product-images">
<swiper class="image-swiper" :indicator-dots="true" :autoplay="false" @change="onSwiperChange">
<swiper-item v-for="(image, index) in product.images" :key="index">
<image :src="image" class="product-image" mode="aspectFit" />
</swiper-item>
</swiper>
<view class="image-indicator">{{ currentImageIndex + 1 }} / {{ product.images.length }}</view>
</view>
<!-- 商品基本信息 -->
<view class="product-info">
<view class="price-section">
<text class="current-price">¥{{ product.price }}</text>
<text v-if="memberPrice > 0 && memberPrice < product.price" class="member-price-tag">会员价 ¥{{ memberPrice }}</text>
<text v-if="product.original_price" class="original-price">¥{{ product.original_price }}</text>
</view>
<view v-if="memberDiscount > 0" class="member-discount-row">
<text class="member-discount-text">会员专享 {{ memberDiscount }}折优惠</text>
</view>
<text class="product-name">{{ product.name }}</text>
<text class="sales-info">已售{{ product.sales }}件 · 库存{{ product.stock }}件</text>
</view>
<!-- 店铺信息 -->
<view class="shop-info" @click="goToShop">
<image :src="merchant.shop_logo ?? '/static/default-shop.png'" class="shop-logo" />
<view class="shop-details">
<text class="shop-name" @click.stop="goToShop">{{ merchant.shop_name }}</text>
<view class="shop-stats-row">
<text class="rating-text">评分: {{ merchant.rating.toFixed(1) }}</text>
<text class="sales-text">销量: {{ merchant.total_sales }}</text>
</view>
</view>
<text class="enter-shop" @click.stop="goToShop">进店 ></text>
</view>
<!-- 功能主治(药品功能) -->
<view class="function-section" v-if="product.usage">
<text class="function-title">功能主治</text>
<text class="function-content">{{ product.usage }}</text>
</view>
<!-- 优惠券入口 (新增) -->
<view class="detail-cell coupon-entry" @click="showCouponModal" v-if="coupons.length > 0">
<text class="cell-label">优惠</text>
<view class="cell-content flex-row">
<text class="coupon-tag" v-for="(coupon, index) in coupons.slice(0, 2)" :key="index">
{{ coupon.name }}
</text>
</view>
<text class="cell-arrow">领券 ></text>
</view>
<!-- 商品参数 -->
<view class="detail-cell params-section" @click="showParamsModal">
<text class="cell-label">参数</text>
<view class="cell-content">
<text class="params-summary-text">{{ getParamsSummary() }}</text>
</view>
<text class="cell-arrow">></text>
</view>
<!-- 规格选择 -->
<view class="detail-cell spec-section" @click="showSpecModal" v-if="productSkus.length > 0">
<text class="cell-label">规格</text>
<view class="cell-content">
<text class="spec-selected">{{ selectedSpec ?? '请选择规格' }}</text>
</view>
<text class="cell-arrow">></text>
</view>
<!-- 数量选择 -->
<view class="detail-cell quantity-section">
<text class="cell-label">数量</text>
<view class="cell-content flex-row align-center justify-between">
<view class="quantity-selector flex-row align-center">
<view class="quantity-btn minus" @click="decreaseQuantity">
<text class="quantity-btn-text">-</text>
</view>
<input class="quantity-input"
type="number"
:value="quantity.toString()"
:min="1"
:max="getMaxQuantity()"
@input="validateQuantity" />
<view class="quantity-btn plus" @click="increaseQuantity">
<text class="quantity-btn-text">+</text>
</view>
</view>
<text class="quantity-stock">库存{{ getAvailableStock() }}件</text>
</view>
</view>
<!-- 商品详情 -->
<view class="product-description">
<view class="section-title">商品详情</view>
<text class="description-text">{{ product.description ?? '暂无详细描述' }}</text>
<!-- 商品详情图片 -->
<view class="detail-images" v-if="product.images.length > 0">
<image v-for="(img, index) in product.images"
:key="index"
:src="img"
class="detail-image"
mode="widthFix"
@click="previewImage(index)" />
</view>
</view>
</scroll-view>
<!-- 底部操作栏 -->
<view class="bottom-actions">
<view class="action-buttons">
<!-- 客服按钮 -->
<view class="action-btn" @click="contactMerchant">
<image src="/static/icons/customer-service.png" class="action-icon-img" />
<text class="action-text">客服</text>
</view>
<view class="action-btn" @click="goToCart">
<image src="/static/tabbar/cart.png" class="action-icon-img" />
<text class="action-text">购物车</text>
</view>
<view class="action-btn" @click="toggleFavorite">
<image :src="isFavorite ? '/static/icons/favorite.png' : '/static/icons/favorite-active.png'" class="action-icon-img" />
<text class="action-text">{{ isFavorite ? '已收藏' : '收藏' }}</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 class="spec-header">
<text class="spec-title">选择规格</text>
<text class="close-btn" @click="hideSpecModal">×</text>
</view>
<scroll-view class="spec-list" direction="vertical">
<view v-for="sku in productSkus" :key="sku.id"
class="spec-item"
:class="{ active: selectedSkuId === sku.id }"
@click="selectSku(sku)">
<text class="spec-name">{{ getSkuSpecText(sku) }}</text>
<text class="spec-price">¥{{ sku.price }}</text>
<text class="spec-stock">库存{{ sku.stock }}</text>
</view>
</scroll-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 scroll-y="true" 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>
</template>
<script>
import { ProductType, MerchantType, ProductSkuType, CouponTemplateType, FootprintItemType } from '@/types/mall-types.uts'
import { supabaseService } from '@/utils/supabaseService.uts'
export default {
data() {
return {
product: {
id: '',
merchant_id: '',
category_id: '',
name: '',
description: '',
images: [] as Array<string>,
price: 0,
original_price: 0,
stock: 0,
sales: 0,
status: 0,
created_at: ''
} 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,
// 会员价相关
memberPrice: 0 as number,
memberDiscount: 0 as number,
memberLevelName: '' as string
}
},
onLoad(options: any) {
const opts = options as UTSJSONObject
const productId = (opts.getString('productId') ?? opts.getString('id')) as string
const priceStr = opts.getString('price')
const productPrice = priceStr != null ? parseFloat(priceStr) : null
const originalPriceStr = opts.getString('originalPrice')
const productOriginalPrice = originalPriceStr != null ? parseFloat(originalPriceStr) : null
let productName = opts.getString('name') as string | null
if (productName != null) {
try {
const decodedName = decodeURIComponent(productName)
productName = decodedName
} catch (e) {
console.warn('ProductName decode failed, using original:', productName)
}
}
let productImage = opts.getString('image') as string | null
if (productImage != null) {
try {
const decodedImage = decodeURIComponent(productImage)
productImage = decodedImage
} catch (e) {
console.warn('ProductImage decode failed, using original:', productImage)
}
}
if (productId != null) {
this.loadProductDetail(productId, {
price: productPrice,
originalPrice: productOriginalPrice,
name: productName,
image: productImage
})
this.checkFavoriteStatus(productId)
this.saveFootprint(productId)
if (productName != null) {
uni.setNavigationBarTitle({
title: productName
})
}
}
},
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: {
saveFootprint(productId: string) {
// 调用后端API记录足迹
supabaseService.addFootprint(productId).then(success => {
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: any): boolean {
const itemObj = item as UTSJSONObject
const itemId = itemObj.getString('id') ?? ''
return itemId != productIdStr
})
// 添加到头部
const productImage = this.product.images.length > 0 ? this.product.images[0] : '/static/default-product.png'
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()
})
// 限制数量例如最近50条
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) {
// 使用 getProductById 返回的 Product 对象
this.product = {
id: dbProduct.id,
merchant_id: dbProduct.merchant_id ?? '',
category_id: dbProduct.category_id ?? '',
name: dbProduct.name,
description: dbProduct.description ?? '',
images: dbProduct.images ?? [] as 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 string[]
} as ProductType
// 解析 tags
if (dbProduct.tags != null && dbProduct.tags != '') {
try {
const parsedTags = JSON.parse(dbProduct.tags)
if (Array.isArray(parsedTags)) {
this.product.tags = (parsedTags as any[]).map((t: any): string => t as string)
}
} catch(e) {}
}
// Handle Images - 使用 main_image_url 作为后备
if (this.product.images.length == 0 && dbProduct.main_image_url != null && dbProduct.main_image_url != '') {
this.product.images.push(dbProduct.main_image_url)
}
// Final fallback
if (this.product.images.length == 0) {
this.product.images.push('/static/default-product.png')
}
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)
// Fallback to options if available
this.product.id = productId
const opts = options as UTSJSONObject
const nameOpt = opts['name']
this.product.name = (nameOpt != null && nameOpt != '') ? decodeURIComponent(nameOpt as string) ?? '未知商品' : '未知商品'
// price 可能是 string 或 number 类型
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 ? [decodedImage] : ['/static/default-product.png']
}
// Load Merchant and SKUs
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.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) {
// 直接使用 Shop 对象的属性
this.merchant = {
id: shopResponse.id,
user_id: shopResponse.merchant_id,
shop_name: shopResponse.shop_name,
shop_logo: shopResponse.shop_logo ?? '/static/default-shop.png',
shop_banner: shopResponse.shop_banner ?? '/static/default-banner.png',
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
console.log('店铺信息加载成功:', this.merchant.shop_name)
}
} catch (e) {
console.error('Load shop failed', e)
}
}
if (!realMerchantLoaded) {
let charSum: number = 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: '/static/shop-logo.png',
shop_banner: '/static/shop-banner.png',
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) {
// 尝试从数据库加载SKU
try {
const skus = await supabaseService.getProductSkus(productId)
if (skus.length > 0) {
console.log('加载到商品SKU:', skus.length)
this.productSkus = []
for (let i = 0; i < skus.length; i++) {
const skuData = skus[i]
// 解析 specifications JSON 字符串
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)
}
return
}
} 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 discount = discountRaw as number
if (discount > 0 && discount < 10) {
this.memberDiscount = discount
this.memberPrice = Math.round(this.product.price * discount) / 10
}
}
} catch (e) {
console.log('获取会员信息失败,可能未登录或非会员:', e)
}
},
// 新增:加载优惠券
async loadCoupons() {
if (this.product.merchant_id == '') return
// Safety check for cached service definition
try {
const couponData = await supabaseService.fetchShopCoupons(this.product.merchant_id)
// 解析优惠券数据
this.coupons = []
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 string[],
product_ids: [] as 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)
}
},
// 新增:联系客服(商家)
contactMerchant() {
if (supabaseService.getCurrentUserId() == '') {
uni.navigateTo({ url: '/pages/auth/login' })
return
}
// Navigate to chat
const merchId = this.merchant.user_id ?? this.merchant.id ?? this.product.merchant_id;
uni.navigateTo({
url: `/pages/mall/consumer/chat?merchantId=${merchId}&merchantName=${this.merchant.shop_name}`
})
},
// 新增:优惠券弹窗
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
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' })
}
},
formatDate(dateStr: string): string {
if (dateStr == '') return ''
const date = new Date(dateStr)
return `${date.getFullYear()}.${date.getMonth()+1}.${date.getDate()}`
},
onSwiperChange(e: any) {
const eventObj = e as UTSJSONObject
const detail = eventObj['detail'] as UTSJSONObject
this.currentImageIndex = detail['current'] as number
},
showSpecModal() {
this.showSpec = true
},
hideSpecModal() {
this.showSpec = false
},
selectSku(sku: ProductSkuType) {
this.selectedSkuId = sku.id
this.selectedSpec = this.getSkuSpecText(sku)
this.hideSpecModal()
},
getSkuSpecText(sku: ProductSkuType): string {
if (sku.specifications != null) {
const specs = sku.specifications as UTSJSONObject
let specStr = ''
// 在 UTS 中遍历 UTSJSONObject 的推荐方式
for (const key in specs) {
const val = specs[key]
if (val != null) {
specStr += (specStr === '' ? '' : ' ') + val.toString()
}
}
if (specStr !== '') {
return specStr
}
}
return sku.sku_code ?? ''
},
async addToCart() {
if (this.productSkus.length > 0 && (this.selectedSkuId == null || this.selectedSkuId === '')) {
uni.showToast({
title: '请选择规格',
icon: 'none'
})
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' })
} else {
console.error('添加购物车返回失败')
uni.showToast({ title: '添加失败,请登录重试', icon: 'none' })
}
} catch (e) {
uni.hideLoading()
console.error('添加购物车异常', e)
uni.showToast({ title: '添加异常', icon: 'none' })
}
},
buyNow() {
if (this.productSkus.length > 0 && (this.selectedSkuId == null || this.selectedSkuId === '')) {
uni.showToast({
title: '请选择规格',
icon: 'none'
})
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 : 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
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/mall/consumer/home' })
},
goToShop() {
const merchantId = this.merchant.id ?? this.product.merchant_id ?? ''
if (merchantId != '') {
console.log('进店点击merchantId:', merchantId)
uni.navigateTo({
url: `/pages/mall/consumer/shop-detail?merchantId=${merchantId}`
})
} else {
uni.showToast({
title: '店铺信息加载中',
icon: 'none'
})
}
},
goToCart() {
uni.switchTab({ url: '/pages/main/cart' })
},
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() {
let num = this.quantity
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() {
if (this.selectedSkuId != null && this.selectedSkuId !== '') {
const sku = this.productSkus.find(s => s.id === this.selectedSkuId)
if (sku != null) return sku!.stock
}
return this.product.stock
},
getAvailableStock() {
return this.getMaxQuantity()
},
previewImage(index: number) {
uni.previewImage({
current: index,
urls: this.product.images
})
},
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 : '查看详情'
}
}
}
</script>
<style>
.product-detail-page {
background-color: #f5f5f5;
flex: 1;
display: flex;
flex-direction: column;
}
.page-scroll {
flex: 1;
height: 0;
width: 100%;
}
.product-images {
position: relative;
height: 750rpx;
background-color: #fff;
}
.image-swiper {
width: 100%;
height: 100%;
}
.product-image {
width: 100%;
height: 100%;
}
.image-indicator {
position: absolute;
bottom: 20rpx;
right: 20rpx;
background-color: rgba(0, 0, 0, 0.5);
color: #fff;
padding: 10rpx 20rpx;
border-radius: 20rpx;
font-size: 24rpx;
}
.product-info {
background-color: #fff;
padding: 30rpx;
margin-bottom: 20rpx;
}
.price-section {
margin-bottom: 20rpx;
}
.current-price {
font-size: 48rpx;
font-weight: bold;
color: #ff5000;
margin-right: 20rpx;
}
.member-price-tag {
font-size: 28rpx;
font-weight: bold;
color: #52c41a;
background-color: #f6ffed;
padding: 4rpx 12rpx;
border-radius: 8rpx;
margin-right: 20rpx;
}
.member-discount-row {
margin-bottom: 15rpx;
}
.member-discount-text {
font-size: 24rpx;
color: #52c41a;
}
.original-price {
font-size: 28rpx;
color: #999;
text-decoration-line: line-through;
}
.product-name {
font-size: 32rpx;
font-weight: bold;
color: #333;
line-height: 1.4;
margin-bottom: 15rpx;
}
.sales-info {
font-size: 26rpx;
color: #666;
}
.shop-info {
background-color: #fff;
padding: 30rpx;
margin-bottom: 20rpx;
display: flex;
flex-direction: row;
align-items: center;
}
.shop-logo {
width: 80rpx;
height: 80rpx;
border-radius: 10rpx;
margin-right: 20rpx;
}
.shop-details {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
}
.shop-name {
font-size: 30rpx;
font-weight: bold;
color: #333;
margin-bottom: 10rpx;
}
.shop-stats-row {
display: flex;
flex-direction: row;
align-items: center;
}
.rating-text, .sales-text {
font-size: 24rpx;
color: #666;
margin-right: 30rpx;
}
.enter-shop {
font-size: 26rpx;
color: #666;
}
/* Modal Popup Styles */
.popup-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: flex-end;
flex-direction: column;
z-index: 1000;
}
.popup-content {
background-color: #fff;
width: 100%;
border-radius: 20rpx 20rpx 0 0;
padding: 30rpx;
display: flex;
flex-direction: column;
max-height: 1000rpx;
}
.popup-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30rpx;
padding-bottom: 20rpx;
border-bottom: 1rpx solid #eee;
}
.popup-title {
font-size: 32rpx;
font-weight: bold;
color: #333;
}
.close-btn {
font-size: 48rpx;
color: #999;
}
.coupon-list-scroll {
flex: 1;
}
.coupon-item {
display: flex;
background-color: #fff5f5;
border-radius: 10rpx;
padding: 20rpx;
margin-bottom: 20rpx;
}
.coupon-left {
width: 180rpx;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
border-right: 1px dashed #ffccc7;
color: #ff5000;
}
.coupon-amount {
font-size: 40rpx;
font-weight: bold;
}
.symbol {
font-size: 24rpx;
}
.coupon-cond {
font-size: 22rpx;
margin-top: 5rpx;
}
.coupon-right {
flex: 1;
padding-left: 20rpx;
display: flex;
justify-content: space-between;
align-items: center;
}
.coupon-info-text {
display: flex;
flex-direction: column;
}
.coupon-name {
font-size: 28rpx;
font-weight: bold;
color: #333;
margin-bottom: 10rpx;
}
.coupon-time {
font-size: 22rpx;
color: #999;
}
.coupon-btn {
background-color: #ff5000;
color: #fff;
font-size: 24rpx;
padding: 0 24rpx;
height: 50rpx;
line-height: 50rpx;
border-radius: 25rpx;
margin: 0;
}
.product-description {
background-color: #fff;
padding: 30rpx;
padding-bottom: 140rpx;
margin-bottom: 20rpx;
}
.section-title {
font-size: 32rpx;
font-weight: bold;
color: #333;
margin-bottom: 20rpx;
}
.description-text {
font-size: 28rpx;
color: #666;
line-height: 1.6;
}
/* 统一Cell样式优化 */
.detail-cell {
background-color: #fff;
padding: 32rpx 30rpx;
display: flex;
flex-direction: row;
align-items: center;
border-bottom: 1rpx solid #f8f8f8;
}
.cell-label {
font-size: 28rpx;
color: #999;
width: 90rpx;
flex-shrink: 0;
}
.cell-content {
flex: 1;
margin-left: 10rpx;
}
.cell-arrow {
font-size: 24rpx;
color: #ccc;
margin-left: 10rpx;
}
/* 覆盖具体板块样式 */
.coupon-entry, .params-section, .spec-section {
margin-bottom: 0; /* 连在一起显示 */
}
.quantity-section {
margin-bottom: 20rpx; /* 数量选择作为最后一项保留底边距 */
border-bottom: none;
}
.params-summary-text, .spec-selected {
font-size: 28rpx;
color: #333;
}
.flex-row {
display: flex;
flex-direction: row;
}
.align-center {
align-items: center;
}
.justify-between {
justify-content: space-between;
}
/* 数量加减器样式 */
.quantity-selector {
display: flex;
flex-direction: row;
align-items: center;
border: 1rpx solid #e5e5e5;
border-radius: 8rpx;
overflow: hidden;
}
.quantity-btn {
width: 60rpx;
height: 60rpx;
display: flex;
align-items: center;
justify-content: center;
background-color: #f5f5f5;
}
.quantity-btn.minus {
border-right: 1rpx solid #e5e5e5;
}
.quantity-btn.plus {
border-left: 1rpx solid #e5e5e5;
}
.quantity-btn-text {
font-size: 28rpx;
color: #333;
}
.quantity-input {
width: 80rpx;
height: 60rpx;
text-align: center;
font-size: 28rpx;
color: #333;
border: none;
background-color: #fff;
}
.quantity-stock {
font-size: 24rpx;
color: #666;
}
.bottom-actions {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background-color: #fff;
padding: 10rpx 20rpx;
box-shadow: 0 -2rpx 10rpx rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.action-buttons {
display: flex;
flex-direction: row;
align-items: center;
margin-right: 20rpx;
}
.action-btn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin-right: 20rpx;
min-width: 80rpx;
}
.action-icon-img {
width: 44rpx;
height: 44rpx;
margin-bottom: 4rpx;
}
.action-text {
font-size: 20rpx;
color: #666;
}
.btn-group {
flex: 1;
display: flex;
flex-direction: row;
align-items: center;
}
.cart-btn, .buy-btn {
flex: 1;
height: 72rpx;
line-height: 72rpx;
border-radius: 36rpx;
font-size: 26rpx;
border: none;
margin: 0 10rpx;
}
.cart-btn {
background-color: #ff5000;
opacity: 0.8;
color: #fff;
}
.buy-btn {
background-color: #ff5000;
color: #fff;
}
.spec-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: flex-end; /* UVUE 推荐用 flex 布局对齐 */
flex-direction: column;
z-index: 999;
}
.spec-content {
background-color: #fff;
width: 100%;
border-radius: 20rpx 20rpx 0 0;
padding: 30rpx;
display: flex;
flex-direction: column;
max-height: 1000rpx;
}
.spec-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30rpx;
padding-bottom: 20rpx;
border-bottom: 1rpx solid #eee;
}
.spec-title {
font-size: 32rpx;
font-weight: bold;
color: #333;
}
.spec-list {
flex: 1;
}
.spec-item {
display: flex;
align-items: center;
padding: 25rpx 0;
border-bottom: 1rpx solid #f5f5f5;
}
.spec-item.active {
background-color: #fff3e0;
}
.spec-name {
flex: 1;
font-size: 28rpx;
color: #333;
}
.spec-price {
font-size: 26rpx;
color: #ff5000;
margin-right: 20rpx;
}
.spec-stock {
font-size: 24rpx;
color: #666;
width: 100rpx;
text-align: right;
}
/* 功能主治样式 */
.function-section {
background-color: #fff;
padding: 30rpx;
margin-bottom: 20rpx;
}
.function-title {
font-size: 30rpx;
color: #333;
font-weight: bold;
margin-bottom: 15rpx;
}
.function-content {
font-size: 28rpx;
color: #666;
line-height: 1.5;
}
/* 商品参数样式 */
.params-section {
background-color: #fff;
padding: 30rpx;
margin-bottom: 20rpx;
display: flex;
align-items: center;
}
.params-title {
font-size: 30rpx;
color: #333;
width: 120rpx;
flex-shrink: 0;
}
.params-summary {
flex: 1;
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
}
.params-item {
font-size: 26rpx;
color: #666;
line-height: 1.5;
margin-right: 20rpx;
margin-bottom: 5rpx;
white-space: nowrap;
}
.params-arrow {
font-size: 28rpx;
color: #999;
flex-shrink: 0;
margin-left: 10rpx;
}
/* 商品参数弹窗样式 */
.params-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: flex-end; /* UVUE 推荐用 flex 布局对齐 */
flex-direction: column;
z-index: 1000;
}
.params-content {
background-color: #fff;
width: 100%;
border-radius: 20rpx 20rpx 0 0;
padding: 30rpx;
display: flex;
flex-direction: column;
max-height: 1000rpx;
}
.params-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30rpx;
padding-bottom: 20rpx;
border-bottom: 1rpx solid #eee;
}
.params-title {
font-size: 32rpx;
font-weight: bold;
color: #333;
width: auto;
}
.params-list {
flex: 1;
}
.params-item {
display: flex;
align-items: flex-start;
padding: 20rpx 0;
border-bottom: 1rpx solid #f5f5f5;
}
.params-label {
font-size: 28rpx;
color: #333;
font-weight: bold;
width: 150rpx;
flex-shrink: 0;
}
.params-value {
flex: 1;
font-size: 28rpx;
color: #666;
line-height: 1.5;
}
/* 商品详情图片样式 */
.detail-images {
margin-top: 30rpx;
}
.detail-image {
width: 100%;
margin-bottom: 20rpx;
border-radius: 10rpx;
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.1);
}
/* 电脑端适配 */
@media (min-width: 768px) {
.params-section {
padding: 20rpx 30rpx;
}
.params-summary {
flex-wrap: nowrap;
justify-content: space-between;
}
.params-item {
flex: 1;
margin-right: 0;
text-align: center;
padding: 0 10rpx;
}
.params-arrow {
margin-left: 20rpx;
}
}
</style>