修改样式

This commit is contained in:
2026-06-05 15:03:32 +08:00
parent 7d891c9f7b
commit f2cde43bf4
58 changed files with 2762 additions and 939 deletions
@@ -1,79 +1,30 @@
<template>
<view class="class-bullet-container">
<!-- 第一行跑马灯 + 手动拖动 -->
<view
class="scroll-row"
@touchstart="onTouchStart1"
@touchmove.stop.prevent="onTouchMove1"
@touchend="onTouchEnd1"
@touchcancel="onTouchEnd1"
>
<view class="marquee-viewport">
<view class="marquee-track" :style="trackStyle1">
<view class="track-group row1-group-a">
<view
v-for="(item, idx) in categories"
:key="item.id + '-row1-a-' + idx"
class="category-item"
@click="handleItemClick(item)"
>
<image v-if="item.categoryImage || item.logoUrl" :src="item.categoryImage || item.logoUrl" class="category-icon" mode="aspectFit" />
<text class="category-text">{{ item.categoryName || item.name }}</text>
</view>
</view>
<view class="track-group">
<view
v-for="(item, idx) in categories"
:key="item.id + '-row1-b-' + idx"
class="category-item"
@click="handleItemClick(item)"
>
<image v-if="item.categoryImage || item.logoUrl" :src="item.categoryImage || item.logoUrl" class="category-icon" mode="aspectFit" />
<text class="category-text">{{ item.categoryName || item.name }}</text>
</view>
</view>
</view>
<view class="category-grid">
<view
v-for="item in topCategories"
:key="item.id"
class="category-item"
@click="handleItemClick(item)"
>
<image
v-if="item.categoryImage || item.logoUrl"
:src="item.categoryImage || item.logoUrl"
class="category-icon"
mode="aspectFit"
/>
<text class="category-text">{{ item.categoryName || item.name }}</text>
</view>
</view>
<!-- 第二行同向跑马灯 + 手动拖动 -->
<view
class="scroll-row"
@touchstart="onTouchStart2"
@touchmove.stop.prevent="onTouchMove2"
@touchend="onTouchEnd2"
@touchcancel="onTouchEnd2"
>
<view class="marquee-viewport">
<view class="marquee-track" :style="trackStyle2">
<view class="track-group row2-group-a">
<view
v-for="(item, idx) in categories"
:key="item.id + '-row2-a-' + idx"
class="category-item"
@click="handleItemClick(item)"
>
<image v-if="item.categoryImage || item.logoUrl" :src="item.categoryImage || item.logoUrl" class="category-icon" mode="aspectFit" />
<text class="category-text">{{ item.categoryName || item.name }}</text>
</view>
</view>
<view
v-for="(item, idx) in categories"
:key="item.id + '-row2-b-' + idx"
class="category-item"
@click="handleItemClick(item)"
>
<image v-if="item.categoryImage || item.logoUrl" :src="item.categoryImage || item.logoUrl" class="category-icon" mode="aspectFit" />
<text class="category-text">{{ item.categoryName || item.name }}</text>
</view>
</view>
<view class="category-item" @click="handleMoreClick">
<image src="@/static/app/images/more.png" class="category-icon" mode="aspectFit" />
<text class="category-text">更多</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, ref, onMounted, onUnmounted, nextTick, getCurrentInstance, watch } from 'vue'
import { computed } from 'vue'
import { useCategoryNavStore } from '@/store'
// 定义分类项接口(与模板字段一致)
@@ -118,114 +69,7 @@ const mockCategories: CategoryItem[] = [
const categories = computed(() => {
return props.categories.length > 0 ? props.categories : mockCategories
})
const offset1 = ref(0)
const offset2 = ref(0)
const loopWidth1 = ref(0)
const loopWidth2 = ref(0)
const isTouching1 = ref(false)
const isTouching2 = ref(false)
const touchStartX1 = ref(0)
const touchStartX2 = ref(0)
const touchStartOffset1 = ref(0)
const touchStartOffset2 = ref(0)
let resumeTimer1: ReturnType<typeof setTimeout> | null = null
let resumeTimer2: ReturnType<typeof setTimeout> | null = null
let raf1 = 0
let raf2 = 0
let lastTs1 = 0
let lastTs2 = 0
const RESUME_DELAY = 800
const PX_PER_SEC_1 = 22
const PX_PER_SEC_2 = 18
const trackStyle1 = computed(() => ({
transform: `translate3d(${-offset1.value}px, 0, 0)`,
}))
const trackStyle2 = computed(() => ({
transform: `translate3d(${-offset2.value}px, 0, 0)`,
}))
function normalizeOffset(value: number, loopWidth: number) {
if (loopWidth <= 0) return value
let next = value % loopWidth
if (next < 0) next += loopWidth
return next
}
function onTouchStart1(e: any) {
const touch = e?.touches?.[0]
isTouching1.value = true
touchStartX1.value = Number(touch?.clientX || 0)
touchStartOffset1.value = offset1.value
if (resumeTimer1) {
clearTimeout(resumeTimer1)
resumeTimer1 = null
}
}
function onTouchMove1(e: any) {
if (!isTouching1.value) return
const touch = e?.touches?.[0]
const x = Number(touch?.clientX || 0)
const delta = x - touchStartX1.value
offset1.value = normalizeOffset(touchStartOffset1.value - delta, loopWidth1.value)
}
function onTouchEnd1() {
resumeTimer1 = setTimeout(() => {
isTouching1.value = false
resumeTimer1 = null
}, RESUME_DELAY)
}
function onTouchStart2(e: any) {
const touch = e?.touches?.[0]
isTouching2.value = true
touchStartX2.value = Number(touch?.clientX || 0)
touchStartOffset2.value = offset2.value
if (resumeTimer2) {
clearTimeout(resumeTimer2)
resumeTimer2 = null
}
}
function onTouchMove2(e: any) {
if (!isTouching2.value) return
const touch = e?.touches?.[0]
const x = Number(touch?.clientX || 0)
const delta = x - touchStartX2.value
offset2.value = normalizeOffset(touchStartOffset2.value - delta, loopWidth2.value)
}
function onTouchEnd2() {
resumeTimer2 = setTimeout(() => {
isTouching2.value = false
resumeTimer2 = null
}, RESUME_DELAY)
}
function tickRow1(ts: number) {
if (!lastTs1) lastTs1 = ts
const dt = ts - lastTs1
lastTs1 = ts
if (!isTouching1.value) {
offset1.value = normalizeOffset(offset1.value + (PX_PER_SEC_1 * dt) / 1000, loopWidth1.value)
}
raf1 = requestAnimationFrame(tickRow1)
}
function tickRow2(ts: number) {
if (!lastTs2) lastTs2 = ts
const dt = ts - lastTs2
lastTs2 = ts
if (!isTouching2.value) {
offset2.value = normalizeOffset(offset2.value + (PX_PER_SEC_2 * dt) / 1000, loopWidth2.value)
}
raf2 = requestAnimationFrame(tickRow2)
}
const topCategories = computed(() => categories.value.slice(0, 4))
// 点击处理(列表页菜谱 id 来自 store,不放在 URL query
const handleItemClick = (item: CategoryItem) => {
@@ -238,123 +82,46 @@ function navigateTo(url: string) {
uni.navigateTo({ url })
}
function measureLoopWidths() {
const instance = getCurrentInstance()
if (!instance) return
const query = uni.createSelectorQuery().in(instance.proxy)
query.select('.row1-group-a').boundingClientRect()
query.select('.row2-group-a').boundingClientRect()
query.exec((res: Array<{ width?: number } | null>) => {
const w1 = Number(res?.[0]?.width || 0)
const w2 = Number(res?.[1]?.width || 0)
loopWidth1.value = w1 > 0 ? w1 : 0
loopWidth2.value = w2 > 0 ? w2 : 0
})
function handleMoreClick() {
navigateTo('/pages-store/pages/list/index')
}
function ensureMeasuredWidths(retry = 0) {
measureLoopWidths()
if (retry >= 8) return
setTimeout(() => {
if (loopWidth1.value > 0 && loopWidth2.value > 0) return
ensureMeasuredWidths(retry + 1)
}, 220)
}
onMounted(() => {
nextTick(() => {
ensureMeasuredWidths()
})
raf1 = requestAnimationFrame(tickRow1)
raf2 = requestAnimationFrame(tickRow2)
})
watch(
() => categories.value.length,
() => {
nextTick(() => {
ensureMeasuredWidths()
})
}
)
onUnmounted(() => {
if (raf1) cancelAnimationFrame(raf1)
if (raf2) cancelAnimationFrame(raf2)
if (resumeTimer1) {
clearTimeout(resumeTimer1)
resumeTimer1 = null
}
if (resumeTimer2) {
clearTimeout(resumeTimer2)
resumeTimer2 = null
}
})
</script>
<style lang="scss" scoped>
.class-bullet-container {
width: 100%;
}
.category-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 12rpx;
}
.category-item {
min-width: 0;
min-height: 108rpx;
padding: 8rpx 4rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8rpx;
}
.category-icon {
width: 54rpx;
height: 54rpx;
flex-shrink: 0;
}
.category-text {
font-size: 22rpx;
color: #333;
line-height: 1.2;
max-width: 100%;
white-space: nowrap;
overflow: hidden;
.scroll-row {
margin-bottom: 12rpx;
overflow: hidden;
&:last-child {
margin-bottom: 0;
}
}
.marquee-viewport {
width: 100%;
overflow: hidden;
}
.marquee-track {
display: flex;
flex-wrap: nowrap;
width: max-content;
will-change: transform;
}
.track-group {
display: flex;
flex-wrap: nowrap;
}
.category-item {
display: flex;
align-items: center;
justify-content: center;
min-width: 120rpx;
height: 60rpx;
margin-right: 20rpx;
padding: 0 20rpx;
background: #fff;
border: none;
border-radius: 30rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.06);
cursor: pointer;
transition: all 0.2s ease;
flex-shrink: 0;
&:active {
transform: translateY(0) scale(0.96);
}
.category-icon {
width: 32rpx;
height: 32rpx;
margin-right: 8rpx;
flex-shrink: 0;
}
.category-text {
font-size: 28rpx;
color: #333;
white-space: nowrap;
}
}
text-overflow: ellipsis;
}
</style>
@@ -79,14 +79,14 @@ function subtitleLine(item: any): string {
<image
:src="getCardImages(item)[0]"
class="featured-card__media-half featured-card__media-half--top"
mode="aspectFill"
mode="scaleToFill"
/>
</template>
<image
v-else-if="getCardImages(item).length >= 1"
:src="getCardImages(item)[0]"
class="featured-card__media-single"
mode="aspectFill"
mode="scaleToFill"
/>
<view v-else class="featured-card__media-single featured-card__media-placeholder" />
</view>
@@ -148,9 +148,9 @@ function subtitleLine(item: any): string {
flex-shrink: 0;
display: flex;
flex-direction: row;
width: 620rpx;
height: 256rpx;
min-height: 256rpx;
width: 660rpx;
height: 306rpx;
min-height: 306rpx;
margin-left: 16rpx;
background: #fff;
border-radius: 24rpx;
@@ -162,7 +162,7 @@ function subtitleLine(item: any): string {
}
.featured-card__media {
width: 232rpx;
width: 323rpx;
flex-shrink: 0;
background: #f0f0f0;
display: flex;
@@ -204,23 +204,17 @@ function subtitleLine(item: any): string {
}
.featured-card__name {
display: block;
width: 100%;
font-size: 28rpx;
line-height: 34rpx;
font-weight: 600;
color: #1a1a1a;
}
.featured-card__name--primary {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
white-space: normal;
word-break: break-word;
}
.featured-card__name--secondary {
font-size: 28rpx;
line-height: 34rpx;
color: #1a1a1a;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
}
.featured-card__media-placeholder {
@@ -2,17 +2,17 @@
<view class="home-skeleton">
<!-- 顶部头部 home-top-header 对齐 -->
<view class="header-section">
<view class="header-left">
<view class="header-toolbar">
<view class="brand-row">
<view class="logo-skeleton skeleton-item"></view>
<view class="app-title-skeleton skeleton-item"></view>
</view>
<view class="delivery-info-skeleton skeleton-item"></view>
</view>
<view class="header-right">
<view class="location-pill-skeleton skeleton-item"></view>
<view class="cart-btn-skeleton skeleton-item"></view>
<view class="header-right">
<view class="location-pill-skeleton skeleton-item"></view>
<view class="cart-btn-skeleton skeleton-item"></view>
</view>
</view>
<view class="notice-row-skeleton skeleton-item"></view>
</view>
<!-- 搜索栏 + 消息按钮 -->
@@ -121,19 +121,22 @@
.header-section {
padding: 18rpx 24rpx 0;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12rpx;
flex-direction: column;
gap: 10rpx;
.header-left {
flex: 1;
min-width: 0;
.header-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12rpx;
}
.brand-row {
display: flex;
align-items: center;
gap: 14rpx;
flex: 1;
min-width: 0;
}
.logo-skeleton {
@@ -149,10 +152,9 @@
border-radius: 8rpx;
}
.delivery-info-skeleton {
margin-top: 12rpx;
width: 300rpx;
height: 30rpx;
.notice-row-skeleton {
width: 100%;
height: 32rpx;
border-radius: 8rpx;
}
@@ -160,6 +162,7 @@
display: flex;
align-items: center;
gap: 10rpx;
flex-shrink: 0;
}
.location-pill-skeleton {
@@ -1,50 +1,81 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const props = defineProps<{
defineProps<{
appName: string
locationText: string
appointmentTimeShow?: string
cartBadgeTotal?: number
isLogin?: boolean
}>()
const emit = defineEmits<{
(e: 'clickAddress'): void
(e: 'clickLocation'): void
(e: 'clickCart'): void
}>()
const noticeTexts = computed(() => [
t('pages.home.noticeBar.thursday'),
t('pages.home.noticeBar.sunday'),
t('pages.home.noticeBar.freshCatch'),
])
/** 横向无缝滚动:多条拼接为一条,重复一遍避免循环时跳变 */
const noticeScrollText = computed(() => {
const items = noticeTexts.value.filter(Boolean)
if (!items.length) return ''
const once = items.join('  ')
return `${once}  ${once}`
})
</script>
<template>
<view class="home-top-header px-24rpx pt-12rpx pb-8rpx">
<view class="flex items-center justify-between gap-12rpx">
<view class="min-w-0 flex-1">
<view class="flex items-center gap-14rpx min-w-0">
<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">{{ appName }}</text>
</view>
<view @click="emit('clickAddress')" class="home-delivery-row mt-10rpx flex items-center min-w-0 text-26rpx lh-32rpx text-#00A76D">
<text v-if="appointmentTimeShow">{{ t('pages.address.appTime') }}: {{ 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>
<view class="home-top-header__toolbar 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%"
/>
<text class="text-32rpx lh-36rpx text-#1a1a1a font-bold tracking-tight line-clamp-1">
{{ appName }}
</text>
</view>
<view class="flex items-center gap-10rpx shrink-0">
<view class="home-loc-pill" @click="emit('clickLocation')">
<text class="home-loc-pill__text line-clamp-1">{{ locationText || 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>
<text class="home-loc-pill__text line-clamp-1">
{{ locationText || 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"
/>
</view>
<view class="home-cart-btn" @click="emit('clickCart')">
<view class="i-carbon:shopping-cart text-36rpx text-#14181b"></view>
<view v-if="isLogin && (cartBadgeTotal || 0) > 0" class="home-cart-badge">
<view class="i-carbon:shopping-cart text-36rpx text-#14181b" />
<view
v-if="isLogin && (cartBadgeTotal || 0) > 0"
class="home-cart-badge"
>
{{ (cartBadgeTotal || 0) > 99 ? '99+' : cartBadgeTotal }}
</view>
</view>
</view>
</view>
<view class="home-notice-wrap mt-10rpx">
<wd-notice-bar
:text="noticeScrollText"
:delay="1"
:speed="60"
color="#00A76D"
background-color="transparent"
custom-class="home-notice-bar"
/>
</view>
</view>
</template>
@@ -53,6 +84,30 @@ const emit = defineEmits<{
background: #f2f2f2;
}
.home-notice-wrap {
width: 100%;
:deep(.home-notice-bar) {
padding: 0;
font-size: 26rpx;
line-height: 32rpx;
border-radius: 0;
background: transparent !important;
}
:deep(.wd-notice-bar__wrap) {
height: 32rpx;
line-height: 32rpx;
overflow: hidden;
}
:deep(.wd-notice-bar__content) {
font-size: 26rpx;
line-height: 32rpx;
white-space: nowrap;
}
}
.home-loc-pill {
display: flex;
align-items: center;
@@ -102,4 +157,3 @@ const emit = defineEmits<{
border-radius: 999rpx;
}
</style>
@@ -1,9 +1,6 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useUserStore } from "@/store";
import { useUserStore } from '@/store';
const userStore = useUserStore();
const expanded = ref(true)
const touchStartX = ref(0)
function navigateTo(url: string) {
if(userStore.checkLogin()) {
@@ -13,46 +10,19 @@ function navigateTo(url: string) {
}
}
function togglePanel() {
expanded.value = !expanded.value
}
function onFabClick() {
if (!expanded.value) {
expanded.value = true
return
}
navigateTo('/pages/ai/recommend/index')
}
function onHandleTouchStart(e: any) {
touchStartX.value = Number(e?.touches?.[0]?.clientX || 0)
}
function onHandleTouchEnd(e: any) {
const endX = Number(e?.changedTouches?.[0]?.clientX || 0)
const delta = endX - touchStartX.value
if (delta > 20) {
expanded.value = true
} else if (delta < -20) {
expanded.value = false
}
}
</script>
<template>
<!-- AI 跳转做右侧球体悬浮 -->
<view
class="ai-floating"
:class="{ 'is-collapsed': !expanded }"
@touchstart.stop="onHandleTouchStart"
@touchend.stop="onHandleTouchEnd"
@touchcancel.stop="onHandleTouchEnd"
>
<view v-if="expanded" class="ai-close" @click.stop="expanded = false">×</view>
<view class="ai-fab" @click="onFabClick">
<image src="@img/chef/115.png" class="ai-icon"></image>
<image src="@/static/app/images/aidiancan.gif" class="ai-icon"></image>
</view>
</view>
</template>
@@ -72,38 +42,9 @@ function onHandleTouchEnd(e: any) {
transform: translateY(-50%) translateX(55rpx);
}
.ai-close {
position: absolute;
left: -18rpx;
top: -15rpx;
width: 34rpx;
height: 34rpx;
z-index: 2;
border-radius: 999rpx;
background: #ffffff;
border: 1rpx solid #e5e7eb;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
justify-content: center;
color: #7b8794;
font-size: 24rpx;
line-height: 1;
}
.ai-fab {
width: 92rpx;
height: 92rpx;
border-radius: 50%;
background: #e2e7eb;
box-shadow: 0 8rpx 20rpx rgba(0, 0, 0, 0.18);
display: flex;
align-items: center;
justify-content: center;
}
.ai-icon {
width: 44rpx;
height: 44rpx;
width: 260rpx;
height: 260rpx;
margin-right:-70rpx !important;
}
</style>
@@ -1,5 +1,9 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const props = defineProps({
/** 外部列表(菜谱页等);首页不传则使用固定五项 */
list: {
type: Array,
default: () => [],
@@ -21,77 +25,105 @@ const props = defineProps({
default: 'logoUrl',
},
})
const emit = defineEmits(['changeType']);
watchEffect(() => {
if (props.currentId) {
selectedIndex.value = props.currentId;
}
});
const selectedIndex = ref();
const emit = defineEmits(['changeType'])
const { t } = useI18n()
function selectTab(item: any) {
selectedIndex.value = item[props.valueKey];
// 触发父组件事件
console.log('selectTab', item[props.valueKey]);
emit('changeType', item[props.valueKey]);
const fixedTabs = [
{
id: 'member-zone',
nameKey: 'pages.home.quickTabs.memberZone',
logoUrl: '/static/app/images/home/huiyuanzhuanqu.png',
},
{
id: 'live-seafood-air',
nameKey: 'pages.home.quickTabs.liveSeafoodAir',
logoUrl: '/static/app/images/home/kongyunhaixian.png',
},
{
id: 'must-eat-list',
nameKey: 'pages.home.quickTabs.mustEatList',
logoUrl: '/static/app/images/home/bichibang.png',
},
{
id: 'new-calendar',
nameKey: 'pages.home.quickTabs.newCalendar',
logoUrl: '/static/app/images/home/shangxinrili.png',
},
{
id: 'fresh-seafood-today',
nameKey: 'pages.home.quickTabs.freshSeafoodToday',
logoUrl: '/static/app/images/home/xiandahaixian.png',
},
]
const useFixedTabs = computed(() => !props.list || props.list.length === 0)
function selectTab(item: Record<string, unknown>) {
emit('changeType', item[props.valueKey])
}
function imageWidthByIndex(index: number) {
return (index + 1) % 2 === 1 ? '104rpx' : '210rpx'
}
function getTabName(item: Record<string, unknown>) {
if (useFixedTabs.value && item.nameKey) {
return t(String(item.nameKey))
}
return item[props.labelKey]
}
function getImage(item: Record<string, unknown>) {
return String(item[props.imgKey] ?? '')
}
const displayList = computed(() =>
useFixedTabs.value ? fixedTabs : props.list,
)
</script>
<template>
<scroll-view :scroll-x="true">
<view class="flex items-center">
<view class="shrink-0 w-30rpx"></view>
<template v-for="(item, index) in list" :key="index">
<view
:class="[index === 0 ? '' : 'ml-40rpx']"
class="w-112rpx flex flex-col items-center"
@click="selectTab(item)"
>
<view
:class="['img-wrap', selectedIndex == item[props.valueKey] ? 'img-selected' : '']"
>
<image
class="tab-img rounded-50% overflow-hidden bg-common"
:src="item[props.imgKey]"
mode="aspectFill"
></image>
</view>
<text
:class="selectedIndex == item[props.valueKey] ? 'text-#CE7138' : 'text-#333'"
class="line-clamp-1 text-20rpx lh-20rpx mt-12rpx font-500"
>{{ item[props.labelKey] }}</text
>
</view>
</template>
<view
v-for="(item, index) in displayList"
:key="String((item as Record<string, unknown>)[valueKey] ?? (item as Record<string, unknown>).id ?? index)"
:class="[index === 0 ? '' : 'ml-40rpx']"
class="tab-item shrink-0 flex flex-col items-center"
@click="selectTab(item as Record<string, unknown>)"
>
<image
class="tab-img"
:src="getImage(item as Record<string, unknown>)"
mode="scaleToFill"
:style="{ width: imageWidthByIndex(index) }"
/>
<text class="tab-label line-clamp-1">{{ getTabName(item as Record<string, unknown>) }}</text>
</view>
<view class="shrink-0 w-30rpx op-0">1</view>
</view>
</scroll-view>
</template>
<style scoped lang="scss">
.img-wrap {
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.2s;
width: 102rpx;
height: 102rpx;
border-radius: 50%;
background: #fff;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.07);
.tab-item {
min-width: 94rpx;
}
.img-selected {
border: 4rpx solid #ce7138;
border-radius: 50%;
overflow: hidden;
box-sizing: border-box;
.tab-label {
margin-top: 10rpx;
font-size: 22rpx;
line-height: 28rpx;
color: #333;
white-space: nowrap;
}
.tab-img {
width: 98rpx;
height: 98rpx;
border-radius: 50%;
transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
height: 144rpx;
width: auto;
display: block;
margin-bottom: 10rpx;
}
</style>
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import useEventEmit from "@/hooks/useEventEmit";
import {CollectionType, EventEnum} from "@/constant/enums";
import { useConfigStore, useUserStore } from "@/store";
@@ -17,7 +19,6 @@ import HomeSkeleton from "./components/home-skeleton.vue";
import {
appMarketActivityListPost,
appMerchantCartListMerchantPost,
appMerchantCategoryListGet,
appMerchantFeaturedListPost,
appMerchantLabelListGet,
appMerchantNearbyListPost, appMerchantRecommendListPost,
@@ -26,6 +27,10 @@ import {
} from "@/service";
import usePage from "@/hooks/usePage";
import {getFeaturedDishList} from "@/pages-store/service";
import {
buildQuickTopicUrl,
isQuickTopicSlug,
} from '@/pages-store/pages/dishes/utils/quick-topic-route'
import { formatSalesCount } from "@/utils/utils";
const configStore = useConfigStore();
const userStore = useUserStore();
@@ -34,7 +39,35 @@ const props = defineProps<{
price?: Array<string> | string;
}>();
const emit = defineEmits(["toggleScore", "togglePrice", "toggleNotOpen"]);
const { t } = useI18n();
const { t, locale } = useI18n();
/** 首页运营图:按语言切换(中文 / 英文) */
const HOME_PROMO_BANNERS = {
memberUpgrade: {
zh: 'https://www.howhowfresh.com/minio/ruoyi/2026/06/03/0b9a7f865aba442e84e51034a3ec900c.png',
en: 'https://www.howhowfresh.com/minio/ruoyi/2026/06/03/c5b9df3a922d4d17ae1b6eb8c1d7524a.png',
},
deliveryTime: {
zh: 'https://www.howhowfresh.com/minio/ruoyi/2026/06/03/c5673a8874594755bdde7ed7fcbd1982.jpg',
en: 'https://www.howhowfresh.com/minio/ruoyi/2026/06/03/1da02f1e0af34cea91a4f643247176be.png',
},
} as const
const isEnglishLocale = computed(() =>
String(locale.value || uni.getLocale() || '').toLowerCase().startsWith('en'),
)
const memberUpgradeBannerSrc = computed(() =>
isEnglishLocale.value
? HOME_PROMO_BANNERS.memberUpgrade.en
: HOME_PROMO_BANNERS.memberUpgrade.zh,
)
const deliveryTimeBannerSrc = computed(() =>
isEnglishLocale.value
? HOME_PROMO_BANNERS.deliveryTime.en
: HOME_PROMO_BANNERS.deliveryTime.zh,
)
const loading = ref(false)
@@ -49,12 +82,6 @@ function getDishPromoLabel(item: Record<string, unknown>): string {
return typeof raw === 'string' && raw.trim() ? raw.trim() : ''
}
function getFeaturedDishDisplayPrice(item: Record<string, any>) {
const firstSpecPrice = item?.merchantSideDishVoList?.[0]?.merchantSideDishItemVoList?.[0]?.actualSalePrice
if (firstSpecPrice != null && String(firstSpecPrice) !== '') return firstSpecPrice
if (item?.actualSalePrice != null && String(item.actualSalePrice) !== '') return item.actualSalePrice
return item?.discountPrice ?? 0
}
function navigateTo(url: string) {
if(userStore.checkLogin()) {
@@ -64,17 +91,15 @@ function navigateTo(url: string) {
}
}
const swiperList = ref([]);
const currentSwiper = ref(0);
const swiperList = ref<any[]>([])
async function initData() {
// 只在首次加载时显示骨架屏,避免切换时的白屏
if(featuredList.value.length === 0) {
loading.value = true
}
appMarketActivityList()
getAppMarketActivityList()
getAppMerchantLabelList()
getAppMerchantCategoryList()
getAppFeaturedList()
getAppNearbyListPost()
// 获取当前用户购物车信息
@@ -104,12 +129,21 @@ defineExpose({
init: getIndexList,
});
// 获取轮播图列表
function appMarketActivityList() {
appMarketActivityListPost({}).then(res=> {
console.log('互动列表', res)
swiperList.value = res.data
})
/** 首页轮播:POST /app/marketActivity/list */
function getAppMarketActivityList() {
appMarketActivityListPost({})
.then((res) => {
const list = Array.isArray(res?.data) ? res.data : []
swiperList.value = list
.filter((item) => item?.activityImageUrl || item?.activityImage)
.map((item) => ({
...item,
activityImage: item.activityImageUrl || item.activityImage,
}))
})
.catch(() => {
swiperList.value = []
})
}
// 滚动信息获取 APP-商家标签分类控制器(用户端首页上面左右滚动的)
@@ -124,15 +158,7 @@ function getAppMerchantLabelList() {
// 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([])
@@ -208,11 +234,24 @@ function handleItemClick(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()
function tabsTypeChange(id: string | number) {
const topic = String(id)
if (!isQuickTopicSlug(topic)) {
return
}
currentCategory.value = topic
const titleKeys: Record<string, string> = {
'member-zone': 'pages.home.quickTabs.memberZone',
'live-seafood-air': 'pages.home.quickTabs.liveSeafoodAir',
'must-eat-list': 'pages.home.quickTabs.mustEatList',
'new-calendar': 'pages.home.quickTabs.newCalendar',
'fresh-seafood-today': 'pages.home.quickTabs.freshSeafoodToday',
}
navigateTo(
buildQuickTopicUrl(topic, {
categoryName: t(titleKeys[topic]),
}),
)
}
// 是否展示精选商家和附近商家 true 显示 false隐藏
@@ -253,6 +292,9 @@ function onRefresh() {
console.log('手动触发下拉刷新了')
merchantLabelId.value = ''
currentCategory.value = ''
getAppMarketActivityList()
getAppFeaturedList()
getAppNearbyListPost()
paging.value.refresh()
}
@@ -268,9 +310,14 @@ function handleClickSwiper(item: any) {
case 3: // 会员
navigateTo('/pages-user/pages/member/index')
break
// case 4:
// navigateTo('/pages/ai/chat/index')
// break
}
}
const walletUrl = "pages-user/pages/balance/index";
function navigateToDishes(item: any) {
uni.navigateTo({
url: '/pages-store/pages/store/dishes?id=' + item.id + '&storeId=' + item.merchantId,
@@ -313,10 +360,8 @@ const debouncedEmit = debounce(1300, (isCollected: boolean, id: string, type: Co
<home-top-header
:app-name="Config.appName"
:location-text="userStore.userLocation.location"
:appointment-time-show="userStore.appointmentTimeShow"
:cart-badge-total="cartBadgeTotal"
:is-login="userStore.isLogin"
@click-address="navigateTo('/pages/address/index')"
@click-location="navigateTo('/pages-user/pages/search-address/index')"
@click-cart="goCart"
/>
@@ -330,17 +375,17 @@ const debouncedEmit = debounce(1300, (isCollected: boolean, id: string, type: Co
<view class="px-24rpx pt-12rpx pb-8rpx">
<search />
<msg-box />
<!-- 分类标签双行横滑 -->
<!-- 分类标签 -->
<view class="mt-28rpx" v-if="appMerchantLabelList.length > 0">
<class-bullet :categories="appMerchantLabelList" @itemClick="handleItemClick" />
</view>
</view>
<!-- 轮播图/app/marketActivity/list -->
<swiper
class="home-promo-swiper card-swiper"
:circular="true"
:autoplay="true"
previous-margin="48rpx"
next-margin="48rpx"
v-if="swiperList.length > 0"
class="card-swiper"
:circular="swiperList.length > 1"
:autoplay="swiperList.length > 1"
>
<swiper-item
v-for="(item, sIdx) in swiperList"
@@ -349,21 +394,37 @@ const debouncedEmit = debounce(1300, (isCollected: boolean, id: string, type: Co
>
<image
:src="item.activityImage"
class="swiper-item-content w-full h-100% rounded-32rpx bg-common"
class="swiper-item-content w-full h-100%"
mode="scaleToFill"
></image>
</swiper-item>
</swiper>
<!-- 快捷入口圆形分类 -->
<tabs-type @changeType="tabsTypeChange" v-if="appMerchantCategoryList.length > 0" :list="appMerchantCategoryList" :currentId="currentCategory" class="mt-28rpx home-tabs-quick" />
<!-- 快捷入口固定五项跳转精选菜品专题页 -->
<tabs-type
:current-id="currentCategory"
class="mt-28rpx mb-24rpx home-tabs-quick"
@change-type="tabsTypeChange"
/>
<!-- 筛选工具 -->
<!-- <filtrate-tool class="mt-32rpx" @togglePickup="togglePickup" @toggleDiscount="toggleDiscount" @toggleScore="toggleScore" @togglePrice="togglePrice" />-->
<image
:src="memberUpgradeBannerSrc"
class="w-100% h-[340rpx]"
mode="widthFix"
@click="navigateTo('/pages-user/pages/member/index')"
/>
<image
:src="deliveryTimeBannerSrc"
class="w-100% h-[200rpx] rounded-24rpx mt-4rpx"
mode="widthFix"
/>
<!-- 精选商家和附近商家 -->
<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">
<view class="mb-24rpx px-6rpx text-28rpx lh-36rpx text-#1a1a1a">
{{ t('pages.home.featured-on') }}
</view>
<featured-on :list="featuredList" />
@@ -371,7 +432,7 @@ const debouncedEmit = debounce(1300, (isCollected: boolean, id: string, type: Co
<!-- 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 ">
<view class="mb-24rpx px-6rpx text-28rpx lh-36rpx text-#1a1a1a ">
{{ t('pages.home.nearby-merchants') }}
</view>
<nearby-merchants :list="nearbyList" />
@@ -380,7 +441,7 @@ const debouncedEmit = debounce(1300, (isCollected: boolean, id: string, type: Co
<!-- List 精选菜品瀑布流浅底 + 白卡片 + 阴影结构对齐设计稿 -->
<view class="featured-dishes-section mt-36rpx px-24rpx pb-40rpx">
<view class="mb-24rpx px-6rpx text-32rpx lh-44rpx text-#1a1a1a "
<view class="mb-24rpx px-6rpx text-28rpx lh-36rpx text-#1a1a1a "
>{{ t('pages.home.featured-dishes') }}</view>
<view class="waterfall-row flex gap-16rpx items-start">
<view
@@ -492,10 +553,6 @@ const debouncedEmit = debounce(1300, (isCollected: boolean, id: string, type: Co
padding-left: 2rpx;
}
.home-promo-swiper {
margin-top: 8rpx;
}
.home-tabs-quick {
padding-left: 8rpx;
padding-right: 8rpx;
@@ -511,19 +568,14 @@ const debouncedEmit = debounce(1300, (isCollected: boolean, id: string, type: Co
}
.card-swiper {
height: 400rpx;
height: 420rpx;
}
.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,
@@ -42,16 +42,6 @@ const {paging, dataList, loading, queryList, firstLoaded} = usePage<MerchantOrde
}
})
function fillI18nParams(template: string, params: Record<string, string | number>) {
let text = template
Object.keys(params).forEach((key) => {
const value = String(params[key] ?? '')
text = text.replace(new RegExp(`\\{${key}\\}`, 'g'), value)
text = text.replace(new RegExp(`\\$\\{${key}\\}`, 'g'), value)
})
return text
}
function normalizeTimestamp(input: unknown): number | null {
if (input == null || input === '') return null
@@ -112,7 +102,7 @@ function getTotalDishCount(item: MerchantOrderVo) {
}
function getTotalDishCountText(item: MerchantOrderVo) {
return fillI18nParams(t('pages.order.totalItemCount'), {
return t('pages.order.totalItemCount', {
count: getTotalDishCount(item),
})
}