Files
cheflinkuser/src/pages/home/components/tabbar-home/tabbar-home.vue
T
2026-04-14 15:02:00 +08:00

754 lines
22 KiB
Vue
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.
<script setup lang="ts">
import useEventEmit from "@/hooks/useEventEmit";
import {CollectionType, EventEnum} from "@/constant/enums";
import { useConfigStore, useUserStore } from "@/store";
import Config from '@/config/index'
import { debounce } from 'throttle-debounce'
import MsgBox from "@/pages/home/components/tabbar-home/components/msg-box.vue";
import Search from "@/pages/home/components/tabbar-home/components/search.vue";
import ClassBullet from "./components/class-bullet.vue";
import TabsType from "./components/tabs-type.vue";
import FeaturedOn from "./components/featured-on/index.vue";
import FiltrateTool from "@/components/filtrate-tool/index.vue";
import NearbyMerchants from "./components/nearby-merchants/index.vue";
import FoodBox from "./components/food-box/index.vue";
import HomeSkeleton from "./components/home-skeleton.vue";
import {
appMarketActivityListPost,
appMerchantCartListMerchantPost,
appMerchantCategoryListGet,
appMerchantFeaturedListPost,
appMerchantLabelListGet,
appMerchantNearbyListPost, appMerchantRecommendListPost,
appCollectCollectPost,
appRecipeCategoryListGet
} from "@/service";
import usePage from "@/hooks/usePage";
import {getFeaturedDishList} from "@/pages-store/service";
import { formatSalesCount } from "@/utils/utils";
const configStore = useConfigStore();
const userStore = useUserStore();
const props = defineProps<{
scoreRange?: string;
price?: Array<string> | string;
}>();
const emit = defineEmits(["toggleScore", "togglePrice", "toggleNotOpen"]);
const { t } = useI18n();
const loading = ref(false)
function isSoldOutStock(stockLike: unknown) {
const n = Number(stockLike)
return !Number.isNaN(n) && n <= 0
}
/** 底部营销条文案(接口若返回 marketingLabel / hotSaleTag 等则展示) */
function getDishPromoLabel(item: Record<string, unknown>): string {
const raw = item.marketingLabel ?? item.hotSaleTag ?? item.rankTag ?? item.promotionLabel
return typeof raw === 'string' && raw.trim() ? raw.trim() : ''
}
function navigateTo(url: string) {
if(userStore.checkLogin()) {
uni.navigateTo({
url,
});
}
}
const swiperList = ref([]);
const currentSwiper = ref(0);
async function initData() {
// 只在首次加载时显示骨架屏,避免切换时的白屏
if(featuredList.value.length === 0) {
loading.value = true
}
appMarketActivityList()
getAppMerchantLabelList()
getAppMerchantCategoryList()
getAppFeaturedList()
getAppNearbyListPost()
// 获取当前用户购物车信息
userStore.getUserCartAllData()
}
// 筛选事件触发
function toggleScore() {
emit("toggleScore");
}
function togglePrice() {
emit("togglePrice");
}
function toggleNotOpen() {
emit("toggleNotOpen");
}
async function getIndexList() {
// 切换时立即刷新列表,无需等待nextTick
if (paging.value) {
paging.value.refresh()
}
}
defineExpose({
initData,
init: getIndexList,
});
// 获取轮播图列表
function appMarketActivityList() {
appMarketActivityListPost({}).then(res=> {
console.log('互动列表', res)
swiperList.value = res.data
})
}
// 滚动信息获取 APP-商家标签分类控制器(用户端首页上面左右滚动的)
const appMerchantLabelList = ref<any>([])
function getAppMerchantLabelList() {
appRecipeCategoryListGet({}).then(res => {
console.log('滚动信息获取 APP-商家标签分类控制器(用户端首页上面左右滚动的)', res)
appMerchantLabelList.value = res.data || []
})
// appMerchantLabelListGet({}).then(res => {
// console.log('滚动信息获取 APP-商家标签分类控制器(用户端首页上面左右滚动的)', res)
// appMerchantLabelList.value = res.data || []
// })
}
// 查询所有商家分类数据
const appMerchantCategoryList = ref([])
const currentCategory = ref('')
function getAppMerchantCategoryList() {
appMerchantCategoryListGet({}).then((res: any) => {
console.log('查询所有商家分类数据', res)
appMerchantCategoryList.value = res.data || []
})
}
// 查询精选商家列表(首页)
const featuredList = ref([])
function getAppFeaturedList() {
appMerchantFeaturedListPost({
body: {
lat: userStore.userLocation.latitude,
lng: userStore.userLocation.longitude,
}
}).then(res=> {
featuredList.value = res.data || []
}).finally(()=> {
loading.value = false
})
}
// 查询附近商家列表(首页) /app/merchant/nearbyList
const nearbyList = ref([])
function getAppNearbyListPost() {
appMerchantNearbyListPost({
body: {
lat: userStore.userLocation.latitude,
lng: userStore.userLocation.longitude,
}
}).then(res=> {
nearbyList.value = res.data || []
})
}
// 是否自提
const selfPickup = ref<number | null>(null)
function togglePickup(value: number) {
selfPickup.value = value;
// paging.value.refresh()
}
// 是否有折扣
const discount = ref<number | null>(null)
function toggleDiscount(value: number) {
discount.value = value;
// paging.value.refresh()
}
const {paging, dataList, queryList} = usePage(getList)
function getList(pageNum: number, pageSize: number) {
return new Promise(resolve => {
getFeaturedDishList({
pageNum,
pageSize,
}).then(res => {
console.log('查询精选菜品列表', res)
resolve({rows: res.rows})
})
})
}
// 回到顶部
const showBackToTop = ref(false)
function scrollToTop() {
if (paging.value) {
paging.value.scrollToTop()
}
}
// 监听页面滚动
function onPageScroll(e: any) {
// 滚动距离大于60时显示,小于等于60时隐藏
showBackToTop.value = e.detail.scrollTop > 120
}
// 点击头部分类
const merchantLabelId = ref('')
function handleItemClick(e) {
console.log(e, '点击头部分类')
merchantLabelId.value = e.id
// paging.value.refresh()
}
function tabsTypeChange(id: string) {
currentCategory.value = id
navigateTo('/pages-store/pages/home-store/index?merchantCategoryIds=' + id)
// console.log('分类切换', id)
// paging.value.refresh()
}
// 是否展示精选商家和附近商家 true 显示 false隐藏
const isShowMerchant = computed(()=> {
if(!selfPickup.value && !discount.value && !props.scoreRange && !props.price && !currentCategory.value) {
return true // 没有筛选条件时显示
} else {
return true // 有筛选条件时隐藏
}
})
// 精选菜品瀑布流:按序均分到两列(0,2,4… / 1,3,5…),分页追加后仍交错排列
const featuredDishColumns = computed(() => {
const list = dataList.value
return [
list.filter((_, i) => i % 2 === 0),
list.filter((_, i) => i % 2 === 1),
]
})
/** 顶栏购物车角标(多商家购物车汇总件数) */
const cartBadgeTotal = computed(() => {
const list = userStore.userCartAllData
if (!Array.isArray(list) || list.length === 0) return 0
let n = 0
for (const m of list) {
n += (m as { merchantCartVoList?: unknown[] })?.merchantCartVoList?.length || 0
}
return n
})
function goCart() {
navigateTo('/pages-user/pages/cart/index')
}
// 手动触发下拉刷新了
function onRefresh() {
console.log('手动触发下拉刷新了')
merchantLabelId.value = ''
currentCategory.value = ''
paging.value.refresh()
}
function handleClickSwiper(item: any) {
console.log(item, '点击轮播图')
switch (Number(item.activityType)) {
case 1: // 商家列表
navigateTo('/pages-store/pages/list/index')
break
case 2: // 活动菜品列表
navigateTo('/pages-store/pages/dishes/index?id=' + item.id)
break
case 3: // 会员
navigateTo('/pages-user/pages/member/index')
break
}
}
function navigateToDishes(item: any) {
uni.navigateTo({
url: '/pages-store/pages/store/dishes?id=' + item.id + '&storeId=' + item.merchantId,
})
}
// 收藏菜品
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, // 立即触发
})
</script>
<template>
<view
class="home-page-root"
:style="[
{
height: configStore.windowHeight + 'px',
},
]"
>
<z-paging @onRefresh="onRefresh" ref="paging" v-model="dataList" :auto="false" @query="queryList" @scroll="onPageScroll" :refresher-enabled="true" :auto-show-back-to-top="false">
<template #top>
<status-bar />
<!-- 设计稿品牌行 + 右侧地址胶囊消息/客服购物车 -->
<view class="home-top-header px-24rpx pt-12rpx pb-8rpx">
<view class="flex items-center justify-between gap-12rpx">
<view class="flex items-center gap-14rpx min-w-0 flex-1">
<image src="@img/logo.png" class="w-52rpx h-52rpx shrink-0" style="border-radius: 50%;"></image>
<text class="text-32rpx lh-36rpx text-#1a1a1a font-bold tracking-tight shrink-0">{{ Config.appName }}</text>
</view>
<view class="flex items-center gap-10rpx shrink-0">
<view class="home-loc-pill" @click="navigateTo('/pages-user/pages/search-address/index')">
<!-- <image src="@img/chef/101.png" class="home-loc-pill__pin w-22rpx h-22rpx shrink-0"></image> -->
<text class="home-loc-pill__text line-clamp-1">{{
userStore.userLocation.location || t('pages.home.default-location')
}}</text>
<image src="@img/chef/119.png" class="home-loc-pill__arrow w-20rpx h-20rpx shrink-0 op-50 mt-2rpx"></image>
</view>
<view class="home-cart-btn" @click="goCart">
<view class="i-carbon:shopping-cart text-36rpx text-#14181b"></view>
<view v-if="userStore.isLogin && cartBadgeTotal > 0" class="home-cart-badge">{{ cartBadgeTotal > 99 ? '99+' : cartBadgeTotal }}</view>
</view>
</view>
</view>
<view class="home-delivery-actions-row mt-14rpx flex items-center justify-between gap-16rpx">
<view @click="navigateTo('/pages/address/index')" class="home-delivery-row flex items-center min-w-0 flex-1 text-26rpx lh-32rpx text-#00A76D">
<text v-if="userStore.appointmentTimeShow">{{ t('pages.address.appTime') }}: {{ userStore.appointmentTimeShow }}</text>
<text v-else>{{ t('pages.address.reservation') }}</text>
<image src="@img/chef/119.png" class="w-22rpx h-22rpx ml-8rpx shrink-0"></image>
</view>
<msg-box compact class="shrink-0" @toggleNotOpen="toggleNotOpen" />
</view>
</view>
</template>
<view
class="animate-in fade-in animate-ease-out animate-duration-300"
v-show="loading && featuredList.length === 0"
>
<home-skeleton />
</view>
<view class="px-24rpx pt-12rpx pb-8rpx">
<search />
<!-- 分类标签双行横滑 -->
<view class="mt-28rpx" v-if="appMerchantLabelList.length > 0">
<class-bullet :categories="appMerchantLabelList" @itemClick="handleItemClick" />
</view>
</view>
<swiper
class="home-promo-swiper card-swiper"
:circular="true"
:autoplay="true"
previous-margin="48rpx"
next-margin="48rpx"
>
<swiper-item
v-for="(item, sIdx) in swiperList"
:key="item.id ?? sIdx"
@click="handleClickSwiper(item)"
>
<image
:src="item.activityImage"
class="swiper-item-content w-full h-100% rounded-32rpx bg-common"
></image>
</swiper-item>
</swiper>
<!-- 快捷入口圆形分类 -->
<tabs-type @changeType="tabsTypeChange" v-if="appMerchantCategoryList.length > 0" :list="appMerchantCategoryList" :currentId="currentCategory" class="mt-28rpx home-tabs-quick" />
<!-- 筛选工具 -->
<!-- <filtrate-tool class="mt-32rpx" @togglePickup="togglePickup" @toggleDiscount="toggleDiscount" @toggleScore="toggleScore" @togglePrice="togglePrice" />-->
<view class="animate-in fade-in animate-ease-in animate-duration-300" v-if="isShowMerchant">
<!-- Featured on ChefLink 精选商家浅底 + 横向卡片对齐设计稿 -->
<view v-if="featuredList.length > 0" class="featured-merchants-section mt-36rpx px-24rpx pt-8rpx pb-16rpx">
<view class="mb-24rpx px-6rpx text-32rpx lh-44rpx text-#1a1a1a">
{{ t('pages.home.featured-on') }}
</view>
<featured-on :list="featuredList" />
</view>
<!-- Nearby Merchants 附近商家 -->
<view v-if="nearbyList.length > 0" class="nearby-merchants-block mt-36rpx px-24rpx pb-16rpx">
<view class="mb-24rpx px-6rpx text-32rpx lh-44rpx text-#1a1a1a ">
{{ t('pages.home.nearby-merchants') }}
</view>
<nearby-merchants :list="nearbyList" />
</view>
</view>
<!-- List 精选菜品瀑布流浅底 + 白卡片 + 阴影结构对齐设计稿 -->
<view class="featured-dishes-section mt-36rpx px-24rpx pb-40rpx">
<view class="mb-24rpx px-6rpx text-32rpx lh-44rpx text-#1a1a1a "
>{{ t('pages.home.featured-dishes') }}</view>
<view class="waterfall-row flex gap-16rpx items-start">
<view
v-for="(col, colIndex) in featuredDishColumns"
:key="colIndex"
class="waterfall-col flex-1 min-w-0 flex flex-col gap-16rpx"
>
<view
v-for="item in col"
:key="item.id || String(item.merchantId) + '-' + item.dishName"
@click="navigateToDishes(item)"
class="featured-dish-card w-full"
>
<view class="featured-dish-image">
<view v-if="item.isNew == 1" class="dish-new-ribbon">
<text class="dish-new-ribbon__text">NEW</text>
</view>
<image
:src="item?.dishImage?.split(',')[0]"
mode="aspectFill"
class="featured-dish-img"
/>
<view
v-if="isSoldOutStock(item?.stock)"
class="featured-dish-sold-dim"
/>
<image
v-if="isSoldOutStock(item?.stock)"
src="/static/app/images/SoldOut.png"
mode="aspectFill"
class="featured-dish-sold-overlay"
/>
<view
@click.stop="handleDishCollectionClick(item)"
class="featured-dish-collect w-56rpx h-56rpx absolute z-4 top-12rpx right-12rpx center"
>
<image
v-if="!item.isCollect"
src="@img-store/1334.png"
mode="aspectFill"
class="w-44rpx h-44rpx featured-dish-collect-icon"
/>
<image
v-else
src="@img-store/1337.png"
mode="aspectFill"
class="w-44rpx h-44rpx featured-dish-collect-icon"
/>
</view>
</view>
<view class="featured-dish-body">
<view class="featured-dish-meta flex items-start justify-between gap-12rpx mb-14rpx">
<view class="min-w-0 flex-1">
<text class="featured-dish-price">US${{ item?.discountPrice }}</text>
<!-- <text
v-if="Number(item?.originalPrice) > Number(item?.discountPrice)"
class="featured-dish-original"
>US${{ item?.originalPrice }}</text> -->
</view>
<text class="featured-dish-sales shrink-0">{{ t('pages-store.store.sales') }}: {{ formatSalesCount(item?.salesCount) }}</text>
</view>
<view class="featured-dish-title line-clamp-2 mb-16rpx">
{{ item?.dishName }}
</view>
<view class="flex items-center justify-between gap-12rpx">
<view
v-if="Number(item?.memberPrice) > 0"
class="featured-dish-member shrink min-w-0"
>
<text class="featured-dish-member-inner">{{ t('pages-store.store.members') }}: US${{ item?.memberPrice }}</text>
</view>
<view v-else class="flex-1 min-w-0"></view>
<view class="featured-dish-add center shrink-0">
<image
src="@img/chef/1285.png"
class="w-28rpx h-28rpx"
/>
</view>
</view>
<view
v-if="getDishPromoLabel(item as Record<string, unknown>)"
class="featured-dish-promo mt-16rpx"
>
<text class="featured-dish-promo-text">{{ getDishPromoLabel(item as Record<string, unknown>) }}</text>
</view>
</view>
</view>
</view>
</view>
</view>
<template #bottom>
<view class="h-50px"></view>
<view :style="[configStore.iosSafeBottomPlaceholder]"></view>
</template>
</z-paging>
<!-- 回到顶部购物车已并入顶栏不再使用底部浮条 -->
<view v-if="showBackToTop" @click="scrollToTop" class="home-back-top fixed left-30rpx w-88rpx h-88rpx bg-#14181B rounded-50% center shadow-lg">
<image src="@img/chef/119.png" class="w-40rpx h-40rpx shrink-0 rotate-180"></image>
</view>
</view>
</template>
<style scoped lang="scss">
.home-page-root {
background: #f2f2f2;
}
.home-top-header {
background: #f2f2f2;
}
.home-loc-pill {
display: flex;
align-items: center;
gap: 8rpx;
max-width: 200rpx;
padding: 12rpx 18rpx;
background: #fff;
border-radius: 999rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.06);
}
.home-loc-pill__text {
flex: 1;
min-width: 0;
font-size: 22rpx;
line-height: 28rpx;
font-weight: 500;
color: #333;
}
.home-cart-btn {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 72rpx;
height: 72rpx;
flex-shrink: 0;
background: #fff;
border-radius: 50%;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.07);
}
.home-cart-badge {
position: absolute;
top: 4rpx;
right: 4rpx;
min-width: 28rpx;
height: 28rpx;
padding: 0 6rpx;
font-size: 18rpx;
line-height: 28rpx;
font-weight: 600;
color: #fff;
text-align: center;
background: #e23636;
border-radius: 999rpx;
}
.home-delivery-row {
padding-left: 2rpx;
}
.home-promo-swiper {
margin-top: 8rpx;
}
.home-tabs-quick {
padding-left: 8rpx;
padding-right: 8rpx;
}
.nearby-merchants-block {
background: #f2f2f2;
}
/* 为底栏 Tab 预留空间,避免被遮挡 */
.home-back-top {
bottom: calc(100rpx + env(safe-area-inset-bottom, 0px));
}
.card-swiper {
height: 400rpx;
}
.swiper-item-content {
width: 100%;
height: 100%;
transform: scale(0.94);
border-radius: 32rpx;
box-shadow: 0 8rpx 28rpx rgba(0, 0, 0, 0.08);
transition: transform 0.3s ease;
}
.swiper-item-active .swiper-item-content {
transform: scale(1);
}
/* 精选商家 / 精选菜品 区块(与页面背景统一) */
.featured-merchants-section,
.featured-dishes-section {
background: #f2f2f2;
}
.featured-dish-collect-icon {
filter: drop-shadow(0 2rpx 6rpx rgba(0, 0, 0, 0.18));
}
/* 瀑布流商品图:固定 1:1 区域,便于「已售完」等角标统一 */
.featured-dish-image {
position: relative;
width: 100%;
height: 0;
padding-bottom: 100%;
background: #f0f0f0;
overflow: hidden;
}
.featured-dish-img {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
display: block;
z-index: 1;
}
.featured-dish-sold-dim {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
z-index: 2;
background: rgba(20, 24, 27, 0.42);
pointer-events: none;
}
.featured-dish-sold-overlay {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
z-index: 3;
pointer-events: none;
}
.featured-dish-card {
background: #fff;
border-radius: 24rpx;
overflow: hidden;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.06);
}
.featured-dish-body {
padding: 20rpx 20rpx 22rpx;
}
.featured-dish-price {
color: #e02e24;
font-size: 32rpx;
font-weight: 600;
line-height: 1.2;
}
.featured-dish-original {
margin-left: 10rpx;
color: #b3b3b3;
font-size: 22rpx;
font-weight: 400;
text-decoration: line-through;
vertical-align: baseline;
}
.featured-dish-sales {
color: #999;
font-size: 24rpx;
line-height: 1.35;
max-width: 48%;
text-align: right;
}
.featured-dish-title {
color: #1a1a1a;
font-size: 28rpx;
font-weight: 500;
line-height: 1.45;
}
/* 会员价:暖色胶囊,替代长条形切图 */
.featured-dish-member {
display: inline-flex;
align-items: center;
max-width: calc(100% - 88rpx);
}
.featured-dish-member-inner {
display: inline-block;
padding: 6rpx 18rpx;
border-radius: 999rpx;
background: linear-gradient(180deg, #fff5eb 0%, #ffe8d6 100%);
color: #c45c1a;
font-size: 24rpx;
font-weight: 500;
line-height: 1.35;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.featured-dish-add {
width: 56rpx;
height: 56rpx;
border-radius: 50%;
background: #14181b;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.12);
}
/* 可选营销条(淡红底 + 文案) */
.featured-dish-promo {
padding: 12rpx 16rpx;
border-radius: 12rpx;
background: #fff0f0;
}
.featured-dish-promo-text {
color: #e02e24;
font-size: 22rpx;
font-weight: 500;
line-height: 1.4;
}
.dish-new-ribbon {
position: absolute;
top: 0;
left: 0;
width: 184rpx;
height: 184rpx;
overflow: hidden;
z-index: 5;
pointer-events: none;
&__text {
position: absolute;
top: 26rpx;
left: -66rpx;
width: 240rpx;
text-align: center;
font-size: 22rpx;
font-weight: 700;
color: #fff;
letter-spacing: 2rpx;
line-height: 44rpx;
background: #E23636;
transform: rotate(-45deg);
}
}
</style>