Files
medical-mall/pages/mall/consumer/shop-detail.uvue

960 lines
24 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="shop-detail-page">
<scroll-view class="page-scroll" scroll-y="true" @scrolltolower="onScrollToLower" refresher-enabled="true" @refresherrefresh="onRefresherRefresh" :refresher-triggered="isRefresherTriggered">
<!-- 店铺头部信息 -->
<view class="shop-header">
<image :src="merchant.shop_banner != '' ? merchant.shop_banner : '/static/default-banner.png'" class="shop-banner" mode="aspectFill" />
<view class="shop-info-card">
<image :src="merchant.shop_logo != '' ? merchant.shop_logo : '/static/default-shop.png'" class="shop-logo" />
<view class="shop-basic-info">
<text class="shop-name">{{ merchant.shop_name }}</text>
<view class="shop-stats">
<text class="stat-item">⭐ {{ merchant.rating.toFixed(1) }}</text>
<text class="stat-item">销量 {{ merchant.total_sales }}</text>
</view>
</view>
<view class="shop-actions">
<view class="action-btn chat-btn" @click="contactService">
<text class="action-text">客服</text>
</view>
<view class="action-btn follow-btn" @click="toggleFollow">
<text class="action-text" :class="{ followed: isFollowed }">{{ isFollowed ? '已关注' : '+ 关注' }}</text>
</view>
</view>
</view>
<text class="shop-desc">{{ merchant.shop_description != '' ? merchant.shop_description : '这家店很懒,什么都没写~' }}</text>
<!-- 优惠券列表 (新增) -->
<view class="shop-coupons" v-if="coupons.length > 0">
<scroll-view scroll-x="true" class="coupon-scroll" show-scrollbar="false">
<view class="coupon-wrapper">
<view class="coupon-card" v-for="coupon in coupons" :key="coupon.id" @click="claimCoupon(coupon)">
<view class="coupon-left">
<text class="coupon-amount"><text style="font-size:10px">¥</text>{{ coupon.discount_value }}</text>
<text class="coupon-cond" v-if="coupon.min_order_amount > 0">满{{ coupon.min_order_amount }}</text>
<text class="coupon-cond" v-else>无门槛</text>
</view>
<view class="coupon-right">
<text class="coupon-btn-label">领取</text>
</view>
</view>
</view>
</scroll-view>
</view>
</view>
<!-- 商品列表 -->
<view class="product-section">
<view class="results-header">
<text class="results-title">全部商品</text>
<view class="filter-tabs">
<text class="filter-tab active">综合</text>
<text class="filter-tab">销量</text>
<text class="filter-tab">价格</text>
</view>
</view>
<view class="results-list">
<view v-for="product in products" :key="product.id" class="result-item" @click="goToProduct(product.id)">
<image :src="product.images[0]" class="product-image" mode="aspectFill" />
<text class="product-name" :lines="2">{{ product.name }}</text>
<view class="product-bottom">
<text class="product-price">¥{{ product.price }}</text>
<view class="product-add-btn" @click.stop="addToCart(product)">
<text class="add-icon">+</text>
</view>
</view>
</view>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup lang="uts">
import { ref, onMounted } from 'vue'
import { MerchantType, ProductType } from '@/types/mall-types.uts'
import { supabaseService } from '@/utils/supabaseService.uts'
// 优惠券类型定义
type CouponType = {
id: string
discount_value: number
min_order_amount: number
name: string
start_time: string
end_time: string
status: number
}
// 分页相关状态
const currentPage = ref(1)
const pageSize = ref(6) // 默认显示六个
const hasMore = ref(true)
const isLoading = ref(false)
const currentMerchantId = ref('')
const merchant = ref<MerchantType>({
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)
const products = ref<ProductType[]>([])
const isFollowed = ref(false)
const coupons = ref<CouponType[]>([]) // 新增优惠券
const isRefresherTriggered = ref(false)
// 函数定义必须在 onMounted 之前
// checkFollowStatus 必须在 loadShopData 之前定义
const checkFollowStatus = async (shopId: string) => {
const userId = supabaseService.getCurrentUserId()
if (userId != null && userId != '') {
try {
// @ts-ignore
isFollowed.value = await supabaseService.isShopFollowed(shopId, userId)
} catch(e) {
console.warn('isShopFollowed method not found')
}
}
}
const loadShopData = async (id: string) => {
console.log('Loading shop data for:', id)
const shop = await supabaseService.getShopByMerchantId(id)
if (shop != null) {
console.log('Shop loaded successfully:', shop.shop_name)
// 使用显式类型转换
const merchantData: MerchantType = {
id: shop.id,
user_id: shop.merchant_id,
shop_name: shop.shop_name,
shop_logo: shop.shop_logo != null ? shop.shop_logo : '/static/default-shop.png',
shop_banner: shop.shop_banner != null ? shop.shop_banner : '/static/default-banner.png',
shop_description: shop.description != null ? shop.description : '',
contact_name: shop.contact_name != null ? shop.contact_name : '',
contact_phone: shop.contact_phone != null ? shop.contact_phone : '',
shop_status: 1,
rating: shop.rating_avg != null ? shop.rating_avg : 5.0,
total_sales: shop.total_sales != null ? shop.total_sales : 0,
created_at: shop.created_at != null ? shop.created_at : ''
}
merchant.value = merchantData
// 检查关注状态
checkFollowStatus(shop.id)
} else {
console.warn('Shop data is null for ID:', id)
uni.showToast({
title: '未找到店铺信息',
icon: 'none',
duration: 3000
})
}
}
const loadCoupons = async (id: string) => {
try {
// @ts-ignore
const res = await supabaseService.fetchShopCoupons(id)
if (res != null && Array.isArray(res)) {
const couponList: CouponType[] = []
for (let i = 0; i < res.length; i++) {
const item = res[i]
const itemObj = JSON.parse(JSON.stringify(item)) as UTSJSONObject
couponList.push({
id: itemObj.getString('id') ?? '',
discount_value: itemObj.getNumber('discount_value') ?? 0,
min_order_amount: itemObj.getNumber('min_order_amount') ?? 0,
name: itemObj.getString('name') ?? '',
start_time: itemObj.getString('start_time') ?? '',
end_time: itemObj.getString('end_time') ?? '',
status: itemObj.getNumber('status') ?? 1
} as CouponType)
}
coupons.value = couponList
}
} catch(e1) {
console.warn('SupabaseService.fetchShopCoupons method missing. Please rebuild project.')
}
}
const loadShopProducts = async (id: string) => {
if (isLoading.value) return
isLoading.value = true
// 保存当前使用的MerchantID供下拉/触底使用
if (currentPage.value === 1) {
currentMerchantId.value = id
}
console.log(`shop-detail loadShopProducts for: ${id} page: ${currentPage.value}`)
let res: any = {}
try {
// @ts-ignore
res = await supabaseService.getProductsByMerchantId(id, currentPage.value, pageSize.value)
} catch(e) {
console.error('getProductsByMerchantId missing or error', e)
isLoading.value = false
uni.stopPullDownRefresh()
return
}
console.log(`shop-detail getProductsByMerchantId result count: ${res.data?.length}`)
const rawList = res.data
if (rawList != null && Array.isArray(rawList) && rawList.length > 0) {
// 过滤掉已经在列表中的重复商品 (防止分页计算错误导致的重复)
const newItems: ProductType[] = []
const existingIds = products.value.map(p => p.id)
const list = rawList.map((item: any): ProductType => {
// 解析图片数组
let images: string[] = []
// 转换为 UTSJSONObject 安全访问属性
const itemObj = JSON.parse(JSON.stringify(item)) as UTSJSONObject
// 1. 尝试 main_image_url
const mainImageUrl = itemObj.getString('main_image_url')
if (mainImageUrl != null && mainImageUrl != '') {
images.push(mainImageUrl)
}
// 2. 尝试 image_urls (如果 main 为空,或者需要展示多图)
const imageUrls = itemObj.get('image_urls')
if (imageUrls != null) {
try {
if (Array.isArray(imageUrls)) {
const arr = imageUrls as string[]
if (arr.length > 0) {
if (images.length == 0) images.push(...arr)
}
} else if (typeof imageUrls === 'string') {
const rawUrl = imageUrls as string
if (rawUrl.startsWith('[')) {
const parsed = JSON.parse(rawUrl)
if (Array.isArray(parsed)) {
const arr = parsed as string[]
if (images.length == 0) images.push(...arr)
}
} else {
if (images.indexOf(rawUrl) === -1) images.push(rawUrl)
}
}
} catch(e) {
console.error('解析图片数组失败:', e)
}
}
// 没有任何图片则使用默认
if (images.length === 0) {
images.push('/static/default-product.png')
}
return {
id: itemObj.getString('id') ?? '',
merchant_id: itemObj.getString('merchant_id') ?? '',
category_id: itemObj.getString('category_id') ?? '',
name: itemObj.getString('name') ?? '未知商品',
description: itemObj.getString('description') ?? '',
images: images,
price: itemObj.getNumber('base_price') ?? 0,
original_price: itemObj.getNumber('market_price') ?? 0,
stock: itemObj.getNumber('total_stock') ?? 0,
sales: itemObj.getNumber('sale_count') ?? 0,
status: 1,
created_at: itemObj.getString('created_at') ?? ''
} as ProductType
})
// 只有在 currentPage > 1 时才需要过滤currentPage = 1 时直接替换
if (currentPage.value === 1) {
products.value = list
} else {
for (let i = 0; i < list.length; i++) {
if (existingIds.indexOf(list[i].id) === -1) {
newItems.push(list[i])
}
}
if (newItems.length > 0) {
products.value.push(...newItems)
}
}
currentPage.value++
hasMore.value = list.length >= pageSize.value
} else {
hasMore.value = false
}
isLoading.value = false
uni.stopPullDownRefresh()
}
onMounted(() => {
const pages = getCurrentPages()
const options = pages[pages.length - 1].options as UTSJSONObject
// Search传递的是 id (shop_id), 其他地方可能传递 merchantId
const mId = options.get('merchantId')
const pId = options.get('id')
const paramId = (mId != null ? mId : pId) as string
if (paramId != null && paramId != '') {
console.log('Page mounted with params:', paramId)
// 优先加载店铺信息
loadShopData(paramId).then(() => {
// 加载成功后,使用确定的 merchant_id 来查询关联数据 (商品/优惠券通常是关联在 merchant_id 上的)
const realMerchantId = merchant.value.user_id // 这里 user_id 映射了 DB 中的 merchant_id
if (realMerchantId != null && realMerchantId != '') {
console.log('Chain loading products for Corrected Merchant ID:', realMerchantId)
currentMerchantId.value = realMerchantId // 更新当前上下文ID
loadShopProducts(realMerchantId)
loadCoupons(realMerchantId)
} else {
// 防御性策略:如果没能获取 merchant_id尝试用传入 ID
console.warn('Shop load failed or id empty, fallback using original id:', paramId)
currentMerchantId.value = paramId
loadShopProducts(paramId)
loadCoupons(paramId)
}
})
} else {
console.error('No ID passed to shop-detail')
uni.showToast({title: '参数错误', icon: 'error'})
}
})
const onRefresherRefresh = () => {
isRefresherTriggered.value = true
currentPage.value = 1
hasMore.value = true
isLoading.value = false
if (currentMerchantId.value != '') {
const id = currentMerchantId.value
Promise.all([
loadShopData(id),
loadCoupons(id),
loadShopProducts(id)
]).then(() => {
isRefresherTriggered.value = false
})
} else {
setTimeout(() => {
isRefresherTriggered.value = false
}, 500)
}
}
const onScrollToLower = () => {
if (hasMore.value && !isLoading.value && currentMerchantId.value != '') {
console.log('Scroll to lower, loading more...')
loadShopProducts(currentMerchantId.value)
}
}
onPullDownRefresh(() => {
onRefresherRefresh()
})
onReachBottom(() => {
onScrollToLower()
})
const claimCoupon = async (coupon: any) => {
const userId = supabaseService.getCurrentUserId()
if (userId == null) {
uni.navigateTo({ url: '/pages/auth/login' })
return
}
uni.showLoading({ title: '领取中' })
// 转换为 UTSJSONObject 安全访问属性
const couponObj = JSON.parse(JSON.stringify(coupon)) as UTSJSONObject
const couponId = couponObj.getString('id') ?? ''
let success = false
try {
// @ts-ignore
success = await supabaseService.claimShopCoupon(couponId, userId)
} catch(e1) {
try {
// @ts-ignore
success = await supabaseService.claimCoupon(couponId, userId)
} catch(e2) {
console.warn('claimCoupon not found')
}
}
uni.hideLoading()
if (success) {
uni.showToast({ title: '领取成功', icon: 'success' })
} else {
uni.showToast({ title: '领取失败', icon: 'none' })
}
}
const toggleFollow = async () => {
const userId = supabaseService.getCurrentUserId()
if (userId == null) {
uni.navigateTo({ url: '/pages/auth/login' })
return
}
// 这里的 merchant.value.id 假如是 ML_SHOPS.id
const shopId = merchant.value.id
if (shopId == null || shopId == '') return
uni.showLoading({ title: '处理中' })
// @ts-ignore
if (isFollowed.value) {
// 取消关注
// @ts-ignore
const success = await supabaseService.unfollowShop(shopId, userId)
if (success) {
isFollowed.value = false
uni.showToast({ title: '已取消关注', icon: 'none' })
} else {
uni.showToast({ title: '操作失败', icon: 'none' })
}
} else {
// 关注
// @ts-ignore
const success = await supabaseService.followShop(shopId, userId)
if (success) {
isFollowed.value = true
uni.showToast({ title: '关注成功', icon: 'success' })
} else {
uni.showToast({ title: '关注失败', icon: 'none' })
}
}
uni.hideLoading()
}
const contactService = () => {
const currentUser = supabaseService.getCurrentUserId()
if (currentUser == null) {
uni.navigateTo({ url: '/pages/user/login' })
return
}
if (merchant.value.user_id != null && merchant.value.user_id != '') {
uni.navigateTo({
url: `/pages/mall/consumer/chat?merchantId=${merchant.value.user_id}&merchantName=${encodeURIComponent(merchant.value.shop_name)}`
})
} else {
uni.showToast({ title: '无法联系商家', icon: 'none'})
}
}
const addToCart = async (product: ProductType) => {
uni.showLoading({ title: '检查商品...' })
try {
// 使用店铺的 merchant_id
const merchantId = merchant.value.user_id ?? ''
// 检查商品是否有SKU
const skus = await supabaseService.getProductSkus(product.id)
uni.hideLoading()
if (skus.length > 0) {
// 有规格,提示并跳转到商品详情页选择规格
uni.showToast({
title: '请选择规格',
icon: 'none'
})
setTimeout(() => {
uni.navigateTo({
url: '/pages/mall/consumer/product-detail?id=' + product.id
})
}, 500)
} else {
// 无规格,直接加入购物车
uni.showLoading({ title: '添加中...' })
const success = await supabaseService.addToCart(product.id, 1, '', merchantId)
uni.hideLoading()
if (success) {
uni.showToast({
title: '已添加到购物车',
icon: 'success'
})
} else {
uni.showToast({
title: '添加失败,请重试',
icon: 'none'
})
}
}
} catch (e) {
console.error('添加到购物车异常', e)
uni.hideLoading()
uni.showToast({
title: '操作失败',
icon: 'none'
})
}
}
const goToProduct = (id: string) => {
uni.navigateTo({
url: `/pages/mall/consumer/product-detail?productId=${id}`
})
}
</script>
<style>
.shop-detail-page {
background-color: #f5f5f5;
flex: 1;
display: flex;
flex-direction: column;
}
.page-scroll {
flex: 1;
height: 0;
width: 100%;
}
.shop-header {
background-color: #fff;
padding-bottom: 20px;
margin-bottom: 10px;
display: flex;
flex-direction: column;
}
.shop-banner {
width: 100%;
height: 150px;
background-color: #eee;
}
.shop-info-card {
display: flex;
flex-direction: row;
align-items: flex-start;
padding: 0 15px;
margin-top: -30px;
position: relative;
z-index: 1;
width: 100%;
box-sizing: border-box;
}
.shop-logo {
width: 60px;
height: 60px;
border-radius: 8px;
border: 2px solid #fff;
background-color: #fff;
margin-right: 12px;
flex-shrink: 0;
}
.shop-basic-info {
flex: 1;
display: flex;
flex-direction: column;
padding-top: 35px;
}
.shop-name {
font-size: 18px;
font-weight: bold;
color: #333;
margin-bottom: 6px;
line-height: 1.2;
}
.shop-stats {
display: flex;
flex-direction: row;
align-items: center;
}
.stat-item {
font-size: 11px;
color: #666;
margin-right: 8px;
background-color: #f5f5f5;
padding: 2px 8px;
border-radius: 4px;
}
.shop-actions {
display: flex;
flex-direction: row;
align-items: center;
padding-top: 40px;
flex-shrink: 0;
}
.action-btn {
border-radius: 17px;
margin-left: 8px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
padding: 4px 12px;
cursor: pointer;
}
.action-text {
font-size: 12px;
}
.chat-btn {
background-color: #ffffff;
border: 1px solid #ddd;
}
.chat-btn .action-text {
color: #333;
}
.follow-btn {
background-color: #ff5000;
border: 1px solid #ff5000;
min-width: 60px;
}
.follow-btn .action-text {
color: #ffffff;
}
.follow-btn .followed {
opacity: 0.9;
}
.shop-desc {
color: #999;
padding: 10px 15px 0;
line-height: 1.5;
width: 100%;
box-sizing: border-box;
font-size: 13px;
}
/* PC 端响应式覆盖 */
@media screen and (min-width: 1025px) {
.shop-header {
align-items: center;
}
.shop-banner {
height: 300px;
max-width: 1200px;
}
.shop-info-card {
max-width: 1200px;
margin-top: -40px;
}
.shop-logo {
width: 100px;
height: 100px;
margin-right: 20px;
}
.shop-basic-info {
padding-top: 45px;
}
.shop-name {
font-size: 24px;
margin-bottom: 12px;
}
.shop-stats .stat-item {
font-size: 14px;
padding: 6px 15px;
margin-right: 15px;
}
.shop-actions {
padding-top: 50px;
}
.action-btn {
padding: 8px 24px;
margin-left: 15px;
border-radius: 20px;
}
.action-text {
font-size: 14px;
}
.shop-desc {
max-width: 1200px;
font-size: 14px;
padding: 15px 15px;
}
}
/* Coupon Styles */
.shop-coupons {
margin-top: 20px;
padding: 0 15px;
width: 100%;
max-width: 1200px;
box-sizing: border-box;
}
.coupon-scroll {
width: 100%;
white-space: nowrap;
flex-direction: row;
}
.coupon-wrapper {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
align-items: center;
}
.coupon-card {
display: flex;
flex-direction: row;
background-color: #fff5f5;
border: 1px solid #ffccc7;
border-radius: 4px;
margin-right: 15px;
width: 180px; /* PC 端优惠券加宽 */
height: 70px;
overflow: hidden;
flex-shrink: 0;
cursor: pointer;
}
.coupon-left {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
border-right: 1px dashed #ffccc7;
padding: 0 10px;
}
.coupon-amount {
color: #ff5000;
font-weight: bold;
font-size: 20px;
}
.coupon-cond {
color: #999;
font-size: 12px;
}
.coupon-right {
width: 50px;
display: flex;
justify-content: center;
align-items: center;
background-color: #ff5000;
flex-direction: column;
}
.coupon-btn-label {
color: #fff;
font-size: 14px;
text-align: center;
line-height: 1.2;
}
.product-section {
padding: 20px 0;
width: 100%;
max-width: 1200px;
margin: 0 auto;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: flex-start;
}
.results-header {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 10px 12px;
background-color: #fff;
margin-bottom: 2px;
width: 100%;
box-sizing: border-box;
}
.results-title {
font-size: 15px;
font-weight: bold;
color: #333;
padding-left: 10px;
border-left: 5px solid #ff5000;
}
.filter-tabs {
display: flex;
flex-direction: row;
align-items: center;
flex-wrap: wrap;
justify-content: flex-start;
}
.filter-tab {
font-size: 13px;
color: #666;
padding: 8px 12px;
border-radius: 20px;
border: 1px solid #e0e0e0;
transition: all 0.2s ease;
white-space: nowrap;
text-align: center;
display: flex;
justify-content: center;
align-items: center;
margin-left: 8px;
}
.filter-tab.active {
background: #ff5000;
color: white;
border-color: #ff5000;
}
.results-list {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: flex-start;
padding: 10px;
width: 100%;
box-sizing: border-box;
margin-top: 5px;
background-color: #fff;
}
.result-item {
display: flex;
flex-direction: column;
background: #fff;
border-radius: 8px;
overflow: hidden;
width: 48%;
margin-bottom: 12px;
margin-right: 2%;
border: 1px solid #f0f0f0;
box-sizing: border-box;
}
.result-item:nth-child(2n) {
margin-right: 0;
}
.product-image {
width: 100%;
height: 170px;
object-fit: cover;
background-color: #f5f5f5;
border-radius: 8px;
margin-bottom: 8px;
}
.product-name {
font-size: 13px;
color: #333;
margin-bottom: 5px;
line-height: 1.4;
height: 36px;
overflow: hidden;
text-overflow: ellipsis;
padding: 0 8px;
}
.product-bottom {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 0 8px 8px;
}
.product-price {
font-size: 15px;
color: #ff5000;
font-weight: bold;
}
.product-add-btn {
width: 24px;
height: 24px;
background-color: #ff5000;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.add-icon {
color: #fff;
font-size: 16px;
font-weight: bold;
}
/* 电脑端响应式覆盖 */
@media screen and (min-width: 1025px) {
.product-section {
max-width: 95%;
width: 1200px;
margin: 0 auto;
}
.result-item {
width: 23%;
margin-right: 2%;
}
.result-item:nth-child(2n) {
margin-right: 2%;
}
.result-item:nth-child(4n) {
margin-right: 0;
}
}
/* 大桌面端 (1400px以上) */
@media screen and (min-width: 1400px) {
.result-item {
width: 23%;
}
}
.shop-banner {
width: 100%;
height: 200px;
background-color: #f5f5f5;
}
@media screen and (min-width: 1025px) {
.shop-banner {
height: 300px; /* 大屏加宽 Banner */
border-radius: 0 0 20px 20px;
}
}
</style>