first commit

This commit is contained in:
2026-02-26 09:32:03 +08:00
commit 36a8e4c51b
845 changed files with 116474 additions and 0 deletions
+703
View File
@@ -0,0 +1,703 @@
<script setup lang="ts">
import { debounce } from 'throttle-debounce'
import dayjs from 'dayjs'
import Config from '@/config/index'
const { t } = useI18n();
import StoreSkeleton from './components/store-skeleton.vue';
import { useScrollThreshold } from '@/hooks/useScrollThreshold'
import {
appCollectCollectPost, appMerchantCartCalculateSavingsPost, appMerchantCartListByMerchantIdPost,
appMerchantDetailMerchantIdGet,
appMerchantMenuMenuListByUserPost, type MerchantCartVo,
type MerchantVo
} from "@/service";
import {CollectionType} from "@/constant/enums";
import {useUserStore} from "@/store";
import { parseBusinessHoursUtils, getDistanceInMiles } from "@/utils/utils";
import CouponPopup from './components/coupon-popup.vue'
import {getMerchantCouponReceiveListApi} from "@/pages-user/service";
// import type { MerchantVo } from '@/service/types'
// 页面加载状态
const loading = ref(true);
const userStore = useUserStore();
const storeID = ref('')
onLoad((options: any)=> {
console.log(options)
if(options.id) {
storeID.value = options.id
getStoreDetail()
// getMenuList()
}
})
// 定时器用于更新闭店提示
let closingTimer: NodeJS.Timeout | null = null
// 启动闭店提示定时器
function startClosingTimer() {
if (closingTimer) {
clearInterval(closingTimer)
}
closingTimer = setInterval(() => {
if (storeDetail.value.businessHours) {
closingInfo.value = parseBusinessHours(storeDetail.value.businessHours)
// 如果不再显示提示,清除定时器
if (!closingInfo.value.show) {
clearInterval(closingTimer!)
closingTimer = null
}
}
}, 60000) // 每分钟更新一次
}
// 停止闭店提示定时器
function stopClosingTimer() {
if (closingTimer) {
clearInterval(closingTimer)
closingTimer = null
}
}
onShow(()=> {
nextTick(()=> {
if(storeID.value) {
// 查询当前店铺购物车信息
getCartInfo()
}
})
})
// 页面卸载时清除定时器
onUnmounted(() => {
stopClosingTimer()
})
// 获取商家详情信息
const storeDetail = ref<MerchantVo>({})
// 闭店提示信息
const closingInfo = ref({ show: false, minutes: 0 })
// 解析营业时间并判断是否在闭店前30分钟
function parseBusinessHours(businessHours: string) {
if (!businessHours) return { show: false, minutes: 0 }
const now = dayjs()
const currentDay = now.day() // 0=Sunday, 1=Monday, ..., 6=Saturday
const dayNames = [t('pages-store.store.weekdays.sunday'), t('pages-store.store.weekdays.monday'), t('pages-store.store.weekdays.tuesday'), t('pages-store.store.weekdays.wednesday'), t('pages-store.store.weekdays.thursday'), t('pages-store.store.weekdays.friday'), t('pages-store.store.weekdays.saturday')]
const todayName = dayNames[currentDay]
// 检查今天是否营业
if (!businessHours.includes(todayName)) {
return { show: false, minutes: 0 }
}
// 提取时间范围,格式如:05:00-16:00
const timeMatch = businessHours.match(/(\d{2}):(\d{2})-(\d{2}):(\d{2})/)
if (!timeMatch) {
return { show: false, minutes: 0 }
}
const [, startHour, startMin, endHour, endMin] = timeMatch
// 使用dayjs创建今天的开店和闭店时间
const openTime = now.hour(parseInt(startHour)).minute(parseInt(startMin)).second(0)
const closeTime = now.hour(parseInt(endHour)).minute(parseInt(endMin)).second(0)
// 如果闭店时间小于开店时间,说明跨天营业,闭店时间应该是明天
const actualCloseTime = closeTime.isBefore(openTime) ? closeTime.add(1, 'day') : closeTime
// 检查是否在营业时间内
const isOpen = now.isAfter(openTime) && now.isBefore(actualCloseTime)
if (!isOpen) {
return { show: false, minutes: 0 }
}
// 计算闭店前30分钟的时间点
const thirtyMinsBefore = actualCloseTime.subtract(30, 'minute')
// 判断是否在闭店前30分钟内
if (now.isAfter(thirtyMinsBefore) && now.isBefore(actualCloseTime)) {
const minutesLeft = actualCloseTime.diff(now, 'minute')
return { show: true, minutes: minutesLeft }
}
return { show: false, minutes: 0 }
}
const storeDistance = ref(null)
function getStoreDetail() {
loading.value = true
appMerchantDetailMerchantIdGet({
params: {
merchantId: storeID.value,
}
}).then((res: any) => {
console.log('商家详情', res)
storeDetail.value = res.data as MerchantVo
getMerchantCouponReceiveList()
// 解析营业时间并判断闭店提示
if (res.data.businessHours) {
closingInfo.value = parseBusinessHours(res.data.businessHours)
// 如果需要显示闭店提示,启动定时器
if (closingInfo.value.show) {
startClosingTimer()
}
}
if(res.data.merchantMenuVoList && res.data.merchantMenuVoList.length > 0) {
tabs.value = res.data.merchantMenuVoList.map((item) => {
return {
title: item.menuName,
key: item.id
}
})
}
// 商户的经纬度存在,并且用户的经纬度也存在
if(res.data.latitude && res.data.longitude && userStore.userLocation.latitude && userStore.userLocation.longitude) {
let distance = getDistanceInMiles(res.data.latitude, res.data.longitude, userStore.userLocation.latitude, userStore.userLocation.longitude)
console.log('距离商家距离', distance)
storeDistance.value = distance
}
// 判断配送和自取的开通状态
const hasDelivery = +storeDetail.value.deliveryService === 1
const hasPickup = +storeDetail.value.selfPickup === 1
if (!hasDelivery && !hasPickup) {
// 两个都没开通,不显示切换组件
showDeliverySwitch.value = false
} else if (!hasDelivery && hasPickup) {
// 只开通自取,默认选中自取
deliveryMethod.value = 1
showDeliverySwitch.value = true
} else if (hasDelivery && !hasPickup) {
// 只开通配送,默认选中配送
deliveryMethod.value = 0
showDeliverySwitch.value = true
} else {
// 两个都开通,默认选中配送
deliveryMethod.value = 0
showDeliverySwitch.value = true
}
}).catch((error) => {
console.error('获取商家详情失败:', error);
}).finally(()=> {
loading.value = false
})
}
const storeCouponList = ref([])
function getMerchantCouponReceiveList() {
getMerchantCouponReceiveListApi(storeID.value).then((res: any)=> {
console.log('商家优惠券列表', res)
storeCouponList.value = res.data
})
}
const cartDataList = ref<MerchantCartVo[]>([])
function getCartInfo() {
if(!userStore.isLogin) return
appMerchantCartListByMerchantIdPost({
params: {
merchantId: storeID.value,
}
}).then((res: any)=> {
console.log('购物车列表', res)
cartDataList.value = res.data
// 购物车有菜品,查询菜品会员折扣价
if(cartDataList.value.length > 0) {
appMerchantCartCalculateSavings()
}
})
}
// 查询菜品会员折扣价
const cartSavingsData = ref({})
function appMerchantCartCalculateSavings() {
appMerchantCartCalculateSavingsPost({
body: cartDataList.value.map(item => item.id)
}).then(res=> {
console.log('菜品会员折扣价', res)
cartSavingsData.value = res.data
})
}
// 优惠券列表(静态数据)
const couponList = ref([
{ id: 1, text: '20% Off' },
{ id: 2, text: '30% Off' },
{ id: 3, text: '$5 Off' },
{ id: 4, text: '40% Off' },
])
// 跳转到优惠券页面
const couponPopupRef = ref()
function handleClaimNow() {
couponPopupRef.value.init()
}
// 用户查询菜单列表
function getMenuList() {
appMerchantMenuMenuListByUserPost({
params: {
merchantId: storeID.value,
}
}).then(res=> {
console.log('商家菜单列表', res)
// 这里可以处理商家菜单列表数据
}).catch(error => {
console.error('获取商家菜单列表失败:', error);
})
}
// 配送方式
const deliveryMethod = ref(0);
const deliveryMethodOptions = [t('pages-store.store.delivery'), t('pages-store.store.pickup')];
const showDeliverySwitch = ref(true); // 是否显示配送方式切换组件
function handleClickSegmented(index: number) {
console.log("切换配送方式:", index);
if(+storeDetail.value.deliveryService !== 1 && index === 0) {
uni.showToast({
title: t('pages-store.store.toast.deliveryService'),
icon: 'none'
})
return
}
if(+storeDetail.value.selfPickup !== 1 && index === 1) {
uni.showToast({
title: t('pages-store.store.toast.selfPickup'),
icon: 'none'
})
return
}
deliveryMethod.value = index
}
const activeTab = ref(0);
const tabs = ref([]);
const showStatusBar = useScrollThreshold()
onPageScroll((e) => {
uni.$emit('page-scroll', e)
})
function navigateToDishes(item: any) {
uni.navigateTo({
url: '/pages-store/pages/store/dishes?id=' + item.id + '&storeId=' + storeID.value,
})
}
function navigateBack() {
uni.navigateBack({
delta: 1,
})
}
function navigateToCart() {
uni.navigateTo({
url:
'/pages-user/pages/cart/store-cart'
+ '?storeId=' + storeID.value
+ '&storeName=' + encodeURIComponent(storeDetail.value.merchantName),
})
}
// 收藏店铺
function handleCollectionClick() {
debouncedEmit(storeDetail.value.isCollect, storeDetail.value.id, CollectionType.STORE, ()=> {
storeDetail.value.isCollect = !storeDetail.value.isCollect
})
}
// 收藏菜品
function handleDishCollectionClick(item: any) {
debouncedEmit(item.isCollect, item.id, CollectionType.DISH, ()=> {
item.isCollect = !item.isCollect
})
}
// 防抖处理函数
const debouncedEmit = debounce(1300, (isCollected: boolean, id: string, type: CollectionType, callback: ()=> void) => {
// 收藏接口
appCollectCollectPost({
body: {
targetId: id,
targetType: type
}
}).then(res=> {
callback()
})
}, {
atBegin: true, // 立即触发
})
function navigateTo(url: string) {
uni.navigateTo({ url })
}
// 分享商家
function handleShare() {
uni.shareWithSystem({
summary: '',
href: `${Config.shareLink}pages-store/pages/store/index?id=${storeID.value}`,
success(){
// 分享完成,请注意此时不一定是成功分享
},
fail(){
// 分享失败
}
})
}
</script>
<template>
<view
class="animate-in fade-in animate-ease-out animate-duration-300"
v-show="loading"
>
<!-- 骨架屏 -->
<StoreSkeleton/>
</view>
<view
class="animate-in fade-in animate-ease-in animate-duration-300"
v-if="!loading"
>
<!-- 页面内容 -->
<view class="store-box">
<view class="relative">
<!-- 状态栏 -->
<view class="fixed top-0 left-0 z-9 w-full transition-all pt-6rpx" :class="[showStatusBar ? 'bg-#fff' : '']">
<status-bar />
<view class="flex-center-sb px-30rpx">
<image
@click="navigateBack"
src="@img/chef/1327.png"
mode="aspectFill"
class="w-48rpx h-48rpx relative z-1"
/>
<view class="flex items-center">
<image
@click="navigateTo('/pages-store/pages/store/search/index?id=' + storeID)"
src="@img-store/1333.png"
mode="aspectFill"
class="w-68rpx h-68rpx relative z-1 mr-20rpx"
/>
<view @click="handleCollectionClick" class="w-68rpx h-68rpx relative z-1 mr-20rpx">
<image
v-if="!storeDetail?.isCollect"
src="@img-store/1334.png"
mode="aspectFill"
class="w-68rpx h-68rpx"
/>
<image
v-else
src="@img-store/1337.png"
mode="aspectFill"
class="w-68rpx h-68rpx"
/>
</view>
<image
@click="handleShare"
src="@img-store/1335.png"
mode="aspectFill"
class="w-68rpx h-68rpx relative z-1"
/>
</view>
</view>
</view>
<view class="anchors"></view>
<!-- 主图 -->
<!-- <image-->
<!-- :src="storeDetail?.shopImages?.split(',')[0]"-->
<!-- mode="aspectFill"-->
<!-- class="w-750rpx h-562rpx absolute top-0 left-0"-->
<!-- />-->
<status-bar />
<wd-swiper class="bg-common" v-if="storeDetail && storeDetail?.shopImages?.split(',').length > 0" :list="storeDetail?.shopImages?.split(',')" height="562rpx" autoplay></wd-swiper>
</view>
<view class="px-30rpx pt-40rpx pb-42rpx">
<!-- 店铺信息 -->
<view class="text-center">
<view class="text-center text-40rpx lh-40rpx text-#333 font-bold">
{{ storeDetail?.merchantName }}
</view>
<view class="center text-24rpx lh-24rpx my-16rpx">
<view class="flex items-center">
<text class="text-#333 font-500">{{ storeDetail?.rating }}</text>
<image
src="@img/chef/124.png"
class="w-24rpx h-24rpx mx-4rpx"
></image>
<text class="text-#7D7D7D">({{ storeDetail?.commentCount }})</text>
</view>
<view class="flex items-center text-#CE7138 px-10rpx">
<image
src="@img-store/1339.png"
class="w-24rpx h-24rpx mr-4rpx"
></image>
CHEFLINK
</view>
<text v-if="+storeDetail?.deliveryService === 1" class="text-#7D7D7D"> {{ storeDetail?.deliveryTime }} {{ t('common.minutes') }}</text>
</view>
<view class="text-24rpx lh-24rpx text-#7D7D7D text-center mt-16rpx">
{{ t('pages-store.store.title') }} US${{ storeDetail?.minOrderPrice }}
</view>
<!--根据商家营业时间计算处理-->
<view class="text-24rpx lh-24rpx text-#7D7D7D text-center mt-16rpx">
<template v-if="closingInfo.show">
{{ t('pages-store.store.tips') }} {{ closingInfo.minutes }} {{ t('pages-store.store.tips1') }}
</template>
<template v-else>
{{ parseBusinessHoursUtils(storeDetail?.businessHours) }}
</template>
<text v-if="storeDistance" class="ml-10rpx">
{{ t('common.distance') }} {{ storeDistance }} {{ t('common.mile') }}
</text>
</view>
<text v-if="storeDetail?.totalOrderCount > 0" class="mt-16rpx h-48rpx bg-#00A76D/18 rounded-8rpx px-22rpx mx-auto text-24rpx lh-24rpx text-#00A76D mr-20rpx">
{{ t('common.sales') }} {{ storeDetail?.totalOrderCount }}
</text>
<text v-if="storeDetail?.reorderedCount > 0" class="mt-16rpx h-48rpx bg-#00A76D/18 rounded-8rpx px-22rpx mx-auto text-24rpx lh-24rpx text-#00A76D">
{{ storeDetail?.reorderedCount }} {{ t('pages-store.store.tips2') }}
</text>
</view>
<!-- 新功能插入区域 start -->
<!-- 商家优惠券 -->
<view class="py-24rpx mt-40rpx border-top border-bottom" v-if="storeCouponList.length">
<view class="flex items-center justify-between mb-24rpx">
<text class="text-28rpx lh-28rpx font-500 text-#333">{{ t('pages-store.store.merchantDiscounts') }}</text>
<view @click="handleClaimNow" class="flex items-center">
<text class="text-28rpx lh-28rpx font-500 text-#CE7138 mr-8rpx">{{ t('pages-store.store.claimNow') }}</text>
<i class="i-carbon:chevron-right text-24rpx text-#CE7138"></i>
</view>
</view>
<scroll-view
scroll-x
class="coupon-scroll"
:show-scrollbar="false"
enable-flex
>
<view class="coupon-container">
<view
v-for="(coupon, index) in storeCouponList"
:key="coupon.id || index"
class="coupon-item"
>
<view class="coupon-tag">
<text class="coupon-text">{{ coupon.nameZh }}</text>
</view>
</view>
</view>
</scroll-view>
</view>
<!-- 新功能插入区域 end -->
<!-- 自取 配送切换 -->
<!-- <view v-if="showDeliverySwitch" class="w-318rpx mt-40rpx">
<l-segmented :value="deliveryMethod" :options="deliveryMethodOptions" @click="handleClickSegmented" shape="round" bg-color="#F2F2F2" active-color="#333" />
</view> -->
<view v-if="showDeliverySwitch" class="border-#D8D8D8 border-solid border-1px rounded-20rpx min-h-164rpx mt-36rpx px-45rpx py-18rpx text-24rpx lh-24rpx flex-center-sb">
<template v-if="+storeDetail?.deliveryService === 1 && deliveryMethod === 0">
<view class="w-full h-full flex-1 text-center text-#CE7138 pr-44rpx">
<view>{{ t('pages-store.store.tips4') }} ${{ storeDetail?.minOrderPrice }}</view>
<view>{{ t('pages-store.store.tips5') }} ${{ storeDetail?.deliveryFee }}</view>
</view>
<view class="h-128rpx w-1rpx rotate-0 bg-#D8D8D8"></view>
<view class="w-full flex-1 center flex-col pl-44rpx">
<view class="text-#333 mb-8rpx">{{ storeDetail?.deliveryTime }} {{ t('common.minutes') }}</view>
<view class="text-#7D7D7D">{{ t('pages-store.store.earTime') }}</view>
</view>
</template>
<template v-if="+storeDetail?.selfPickup === 1 && deliveryMethod === 1">
<view class="w-full h-full flex-1 text-center text-#CE7138 pr-44rpx">
<view v-if="cartDataList.length > 0 && cartSavingsData && cartSavingsData.savings > 0">
{{ t('pages-store.store.use') }} {{ Config.appName }} {{ t('pages-store.store.tips3') }} ${{ cartSavingsData.savings }} {{ t('pages-store.store.discount') }}
</view>
<view v-else class="">--</view>
</view>
<view class="h-128rpx w-1rpx rotate-0 bg-#D8D8D8"></view>
<view class="w-full flex-1 center flex-col pl-44rpx">
<view class="text-#333 mb-8rpx">{{ storeDetail?.pickupTime }}{{ t('common.minutes') }}</view>
<view class="text-#7D7D7D">{{ t('pages-store.store.earTime') }}</view>
</view>
</template>
</view>
</view>
<wd-tabs v-model="activeTab" slidable="always" autoLineWidth>
<block v-for="item in tabs" :key="item">
<wd-tab :title="item.title">
</wd-tab>
</block>
</wd-tabs>
<view class="box mt--6px"></view>
<view v-if="tabs.length > 0" class="px-30rpx pb-120rpx">
<view v-if="storeDetail?.merchantMenuVoList[activeTab]?.dishList.length > 0" class="text-40rpx lh-40rpx font-500 my-36rpx">{{ t('pages-store.store.recommend') }}</view>
<view v-if="storeDetail?.merchantMenuVoList[activeTab]?.dishList.length > 0" class="grid grid-cols-2 gap-30rpx">
<template v-for="item in storeDetail?.merchantMenuVoList[activeTab]?.dishList">
<view @click="navigateToDishes(item)" class="w-100% mb-10rpx">
<view class="relative h-248rpx rounded-24rpx mb-28rpx">
<view @click.stop="handleDishCollectionClick(item)" class="w-68rpx h-68rpx absolute z-2 top-0 right-0">
<image
v-if="!item.isCollect"
src="@img-store/1334.png"
mode="aspectFill"
class="w-full h-full"
/>
<image
v-else
src="@img-store/1337.png"
mode="aspectFill"
class="w-full h-full"
/>
</view>
<image
:src="item?.dishImage?.split(',')[0]"
mode="aspectFill"
class="w-full h-full rounded-24rpx bg-common"
/>
</view>
<view class="line-clamp-1 text-30rpx text-#333 font-500">
{{ item.dishName }}
</view>
<view class="flex-center-sb mt-12rpx">
<text class="text-26rpx lh-30rpx text-#333 font-500">US${{ item.discountPrice }}</text>
<view class="member-price-tag text-[#FBE3C3] font-500 text-28rpx lh-28rpx center pl-6rpx break-all">
<text class="!text-24rpx">{{ t('pages-store.store.members') }}: </text>
${{ item.memberPrice }}
</view>
</view>
<view class="flex-center-sb mt-12rpx">
<view class="text-28rpx text-#999">
<view class="line-through">US${{ item.originalPrice }}</view>
<view>{{ t('pages-store.store.sales') }}:{{ item.salesCount }}</view>
</view>
<view class="center w-64rpx h-64rpx rounded-50% bg-white shadow-lg">
<image
src="@img/chef/1285.png"
class="w-30rpx h-30rpx shrink-0"
></image>
</view>
</view>
</view>
</template>
</view>
<template v-else>
<view class="py-100rpx center">
<image class="w-250rpx h-250rpx" src="@img/chef/100.png"></image>
</view>
</template>
</view>
<view @click="navigateTo('/pages-user/pages/member/index')" v-if="cartDataList.length > 0 && cartSavingsData && cartSavingsData.savings > 0" class="h-96rpx bg-#CE7138 pl-56rpx flex items-center fixed bottom-0 left-0 w-full z-9">
<image
src="@img/chef/1289.png"
class="mr-10rpx w-32rpx h-32rpx shrink-0"
></image>
<text class="text-[#fff] text-24rpx lh-24rpx">
{{ t('pages-store.store.use') }} {{ Config.appName }} {{ t('pages-store.store.tips3') }} ${{ cartSavingsData.savings }} {{ t('pages-store.store.discount') }}
</text>
</view>
</view>
<view v-if="cartDataList.length > 0" @click="navigateToCart" class="fixed z-9 bottom-138rpx left-50% translate-x--50% px-26rpx h-88rpx bg-#14181B rounded-44rpx center text-28rpx text-#fff font-500">
<image src="@img/chef/125.png" class="w-28rpx h-28rpx shrink-0"></image>
<view class="ml-10rpx whitespace-nowrap line-clamp-1">{{ storeDetail.merchantName }}</view>
<view class="w-8rpx h-8rpx rounded-50% bg-white mx-8rpx"></view>
<text>{{ cartDataList.length }}</text>
</view>
</view>
<coupon-popup
ref="couponPopupRef"
:coupon-list="storeCouponList"
@confirm="getMerchantCouponReceiveList"
/>
</template>
<style>
page {
background-color: #fff;
}
</style>
<style scoped lang="scss">
:deep(.wd-swiper__track) {
border-radius: 0;
}
:deep(.wd-tabs__nav-container) {
.is-active {
color: #333 !important;
font-weight: 500 !important;
}
}
:deep(.wd-tabs__nav-item-text) {
font-size: 30rpx !important;
//color: #7D7D7D;
padding-bottom: 32rpx !important;
}
:deep(.wd-tabs__line) {
border-radius: 0 !important;
height: 10rpx !important;
background-color: #333333 !important;
}
.box {
border-bottom: 10rpx solid #F6F6F6 !important;
}
.member-price-tag {
min-width: 190rpx;
height: 42rpx;
background-image: url("/static/images/chef/1282.png");
background-size: 100% 100%;
background-repeat: no-repeat;
}
.coupon-scroll {
width: 100%;
}
.coupon-container {
display: flex;
align-items: center;
white-space: nowrap;
}
.coupon-item {
margin-right: 20rpx;
flex-shrink: 0;
&:last-child {
margin-right: 0;
}
}
.coupon-tag {
min-width: 120rpx;
height: 52rpx;
display: flex;
align-items: center;
justify-content: center;
padding: 0 28rpx;
position: relative;
background-image: url("/static/images/5008.png");
background-size: 100% 100%;
background-repeat: no-repeat;
}
.coupon-text {
font-size: 24rpx;
line-height: 24rpx;
color: #CE7138;
font-weight: 400;
white-space: nowrap;
}
</style>