<template>
|
<view class="page">
|
<view class="custom-nav" :class="isSolidNav ? 'is-solid' : ''">
|
<view class="custom-nav-bg"></view>
|
<view class="safe-space" :style="'height:' + navTop + 'px'"></view>
|
<view class="nav-row" :style="'height:' + navHeight + 'px;padding-right:' + navRight + 'px'">
|
<view class="back-button" @tap="goBack"><image src="/static/food-assets/arrow-left-white.svg"></image></view>
|
<text class="nav-title">吃——暖心食享</text>
|
<view class="capsule-space" :style="'width:' + navWidth + 'px'"></view>
|
</view>
|
</view>
|
|
<scroll-view class="page-scroll" scroll-y lower-threshold="80" @scroll="handlePageScroll" @scrolltolower="loadMoreMerchants">
|
<view class="hero">
|
<!-- 主图跟着图册左右滑:滑动的主图与下方缩略图互相联动(点缩略图 = 主图滑过去)。 -->
|
<swiper v-if="gallery.length > 0" class="hero-swiper" :current="activeIndex < 0 ? 0 : activeIndex" :circular="gallery.length > 1" :duration="240" @change="handleHeroChange">
|
<swiper-item v-for="(item, index) in gallery" :key="item.id">
|
<image class="hero-image" :src="item.image" mode="aspectFill" @error="replaceGalleryImage(index)"></image>
|
</swiper-item>
|
</swiper>
|
<image v-else class="hero-image" :src="currentImage" mode="aspectFill" @error="replaceTopicImage"></image>
|
<view class="hero-top-shade"></view>
|
<view class="hero-bottom-shade"></view>
|
|
<text v-if="currentName.length > 0" class="dish-name">{{currentName}}</text>
|
<text v-if="gallery.length == 0" class="no-gallery-text">暂无图册内容</text>
|
<scroll-view v-if="gallery.length > 0" class="thumb-scroll" scroll-x :show-scrollbar="false" scroll-with-animation :scroll-left="thumbScrollLeft" @scrollend="handleThumbScrollEnd">
|
<view class="thumb-row" :style="'width:' + thumbTrackWidth + 'rpx'">
|
<view class="thumb-spacer"></view>
|
<view class="thumb-item" v-for="(item, index) in gallery" :key="item.id" :class="activeIndex == index ? 'active' : ''" @tap="selectGallery(index)">
|
<image :src="item.image" mode="aspectFill" @error="replaceGalleryImage(index)"></image>
|
</view>
|
<view class="thumb-spacer"></view>
|
</view>
|
</scroll-view>
|
|
</view>
|
<view v-if="topicDescription.length > 0" class="topic-summary"><view class="description-card"><text>{{topicDescription}}</text></view></view>
|
<view class="service-section">
|
<view class="section-heading"><view class="heading-title-wrap"><text>餐饮服务</text><view class="heading-line"></view></view></view>
|
<view v-if="initialLoading" class="merchant-empty"><view class="loading-spinner"></view><text>正在加载餐饮服务</text></view>
|
<view v-else-if="initialError" class="merchant-empty error-state"><text>餐饮服务加载失败</text><text class="retry-text" @tap="retryInitialLoad">重新加载</text></view>
|
<view v-else-if="hasLoaded && visibleMerchants.length == 0" class="merchant-empty"><text>暂无相关餐饮服务</text></view>
|
<view v-else-if="hasLoaded" class="merchant-list">
|
<view class="merchant-item" :class="item.available ? '' : 'is-unavailable'" v-for="item in visibleMerchants" :key="item.id" @tap="openMerchant(item)">
|
<image class="merchant-image" :src="item.image" mode="aspectFill" @error="replaceMerchantImage(item)"></image>
|
<view class="merchant-info">
|
<text class="merchant-name">{{item.name}}</text>
|
<view v-if="!item.available" class="tag-row"><text class="discount-tag">已下架</text></view>
|
<view v-else-if="item.tags.length > 0" class="tag-row"><text v-for="tag in item.tags" :key="tag" :class="tag == '券' ? 'coupon-tag' : 'discount-tag'">{{tag}}</text></view>
|
<view class="address-row"><image src="/static/benefits-assets/map-pin.svg"></image><text>{{item.address}}</text></view>
|
</view>
|
</view>
|
</view>
|
<view v-if="loadingMore" class="loading-row"><view class="loading-spinner"></view><text>正在加载</text></view>
|
<view v-else-if="loadMoreError" class="loading-row load-error" @tap="retryLoadMore"><text>加载失败,点击重试</text></view>
|
<view v-else-if="hasLoaded && !hasMore && visibleMerchants.length > 0" class="no-more"><view></view><text>已经到底了</text><view></view></view>
|
</view>
|
<view class="bottom-space"></view>
|
</scroll-view>
|
</view>
|
</template>
|
|
<script setup lang="uts">
|
import { isApiConfigured } from '../../config/api.uts'
|
import { getFoodTopic, getFoodTopicMerchants } from '../../services/portal.uts'
|
import { resolveApiUrl } from '../../services/http.uts'
|
import { resolveTalentId } from '../../utils/talent-id.uts'
|
import { safeBack } from '../../utils/navigation.uts'
|
|
const navTop = ref(28)
|
const navHeight = ref(32)
|
const navWidth = ref(88)
|
const navRight = ref(10)
|
const isSolidNav = ref(false)
|
const activeIndex = ref(-1)
|
const windowWidth = ref(375)
|
const thumbScrollLeft = ref(0)
|
const fallbackFoodImage = '/static/food-assets/food-1.jpg'
|
const topicDescription = ref('')
|
|
const gallery = ref<any[]>([])
|
const thumbTrackWidth = ref(644)
|
const currentImage = ref(fallbackFoodImage)
|
const currentName = ref('')
|
const visibleMerchants = ref<any[]>([])
|
const initialLoading = ref(true)
|
const loadingMore = ref(false)
|
const hasLoaded = ref(false)
|
const hasMore = ref(false)
|
const initialError = ref(false)
|
const loadMoreError = ref(false)
|
const failInitialOnce = ref(false)
|
const failLoadMoreOnce = ref(false)
|
const currentPage = ref(0)
|
const listRequestVersion = ref(0)
|
const talentId = ref('')
|
const readListTags = (item: any, hasCoupon: boolean, discount: string): string[] => {
|
const tags: string[] = []
|
if (item != null && item.benefitTags instanceof Array) {
|
const records: any[] = item.benefitTags as any[]
|
for (let index = 0; index < records.length; index++) {
|
const tag = String(records[index]).trim()
|
if (tag != '' && tag != '人才专享' && tags.indexOf(tag) < 0) tags.push(tag)
|
}
|
return tags
|
}
|
if (hasCoupon) tags.push('券')
|
if (discount != '' && discount != '人才专享') tags.push(discount)
|
return tags
|
}
|
|
onLoad((options: any) => {
|
// #ifdef MP-WEIXIN
|
const capsule = uni.getMenuButtonBoundingClientRect()
|
const systemInfo = uni.getSystemInfoSync()
|
windowWidth.value = systemInfo.windowWidth
|
navTop.value = capsule.top
|
navHeight.value = capsule.height
|
navWidth.value = capsule.width
|
navRight.value = systemInfo.windowWidth - capsule.right
|
// #endif
|
if (options != null && options['previewState'] != null) {
|
const previewState = options['previewState'] as string
|
failInitialOnce.value = previewState == 'initialError'
|
failLoadMoreOnce.value = previewState == 'loadMoreError'
|
}
|
talentId.value = resolveTalentId()
|
loadFoodTopic()
|
loadInitialMerchants()
|
})
|
const loadFoodTopic = () => {
|
if (!isApiConfigured()) return
|
getFoodTopic((data: any) => {
|
topicDescription.value = data != null && data.description != null ? data.description as string : ''
|
const records: any[] = data != null && data.images instanceof Array ? data.images as any[] : []
|
const next: any[] = []
|
for (let index = 0; index < records.length; index++) {
|
const item = records[index]
|
if (item.imageUrl == null || item.imageUrl == '') continue
|
next.push({ id: '' + item.id, name: item.name != null ? item.name as string : '', image: resolveApiUrl(item.imageUrl as string) })
|
}
|
gallery.value = next
|
thumbTrackWidth.value = 644 + next.length * 137
|
if (next.length > 0) selectGallery(Math.min(2, next.length - 1))
|
}, () => {
|
gallery.value = []
|
topicDescription.value = ''
|
})
|
}
|
const loadInitialMerchants = () => {
|
listRequestVersion.value = listRequestVersion.value + 1
|
currentPage.value = 0
|
visibleMerchants.value = []
|
hasMore.value = false
|
initialLoading.value = true
|
initialError.value = false
|
loadMoreError.value = false
|
hasLoaded.value = false
|
loadFoodMerchants(false)
|
}
|
const retryInitialLoad = () => loadInitialMerchants()
|
const retryLoadMore = () => loadMoreMerchants()
|
|
const centerActiveThumb = (index: number) => {
|
const targetRpx = index * 137
|
thumbScrollLeft.value = targetRpx * windowWidth.value / 750
|
}
|
const selectGallery = (index: number) => {
|
if (index < 0 || index >= gallery.value.length) return
|
activeIndex.value = index
|
currentImage.value = gallery.value[index].image
|
currentName.value = gallery.value[index].name
|
centerActiveThumb(index)
|
}
|
const handleThumbScrollEnd = (event: any) => {
|
const scrollRpx = event.detail.scrollLeft * 750 / windowWidth.value
|
let index = Math.round(scrollRpx / 137)
|
if (index < 0) index = 0
|
if (index >= gallery.value.length) index = gallery.value.length - 1
|
selectGallery(index)
|
}
|
/** 主图左右滑动:滑动落点同步到缩略图、菜名和描述卡。 */
|
const handleHeroChange = (event: any) => {
|
const current = event != null && event.detail != null && event.detail.current != null ? Number(event.detail.current) : -1
|
if (current < 0) return
|
selectGallery(current)
|
}
|
const handlePageScroll = (event: any) => {
|
const shouldShow = event.detail.scrollTop > 64
|
if (isSolidNav.value != shouldShow) isSolidNav.value = shouldShow
|
}
|
const loadFoodMerchants = (append: boolean) => {
|
if (!isApiConfigured()) {
|
initialLoading.value = false
|
loadingMore.value = false
|
initialError.value = true
|
return
|
}
|
const requestVersion = listRequestVersion.value
|
if (!append && failInitialOnce.value) {
|
failInitialOnce.value = false
|
initialLoading.value = false
|
initialError.value = true
|
return
|
}
|
if (append && failLoadMoreOnce.value) {
|
failLoadMoreOnce.value = false
|
loadingMore.value = false
|
loadMoreError.value = true
|
return
|
}
|
getFoodTopicMerchants(currentPage.value + 1, 4, talentId.value, (data: any) => {
|
if (requestVersion != listRequestVersion.value) return
|
const records: any[] = data != null && data.records instanceof Array ? data.records as any[] : []
|
const next = append ? visibleMerchants.value.slice() : []
|
for (let index = 0; index < records.length; index++) {
|
const item = records[index]
|
const hasCoupon = item.couponAvailable === true || Number(item.couponAvailable) == 1
|
const discount = item.discountText != null && item.discountText != '人才专享' ? item.discountText as string : ''
|
next.push({
|
id: '' + item.id,
|
name: item.merchantName != null ? item.merchantName as string : '餐饮商户',
|
merchantType: item.merchantType != null ? item.merchantType as string : 'food',
|
discount: discount,
|
hasCoupon: hasCoupon,
|
tags: readListTags(item, hasCoupon, discount),
|
address: item.address != null ? item.address as string : '',
|
image: item.coverUrl != null && item.coverUrl != '' ? resolveApiUrl(item.coverUrl as string) : fallbackFoodImage,
|
available: item.available !== false
|
})
|
}
|
visibleMerchants.value = next
|
currentPage.value = currentPage.value + 1
|
hasMore.value = data != null && data.total != null ? next.length < data.total : false
|
initialLoading.value = false
|
loadingMore.value = false
|
hasLoaded.value = true
|
}, () => {
|
if (requestVersion != listRequestVersion.value) return
|
initialLoading.value = false
|
loadingMore.value = false
|
if (append) loadMoreError.value = true
|
else initialError.value = true
|
})
|
}
|
const loadMoreMerchants = () => {
|
if (initialLoading.value || loadingMore.value || !hasMore.value) return
|
loadMoreError.value = false
|
loadingMore.value = true
|
loadFoodMerchants(true)
|
}
|
const replaceTopicImage = () => { currentImage.value = fallbackFoodImage }
|
const replaceGalleryImage = (index: number) => {
|
if (index >= 0 && index < gallery.value.length) gallery.value[index].image = fallbackFoodImage
|
}
|
const replaceMerchantImage = (item: any) => { item.image = fallbackFoodImage }
|
const goBack = () => safeBack('/pages/benefits/benefits')
|
const openMerchant = (item: any) => {
|
if (!item.available) {
|
uni.showToast({ title: '该商户已下架', icon: 'none' })
|
return
|
}
|
uni.navigateTo({
|
url: '/pages/merchant/merchant?merchantType=' + encodeURIComponent(item.merchantType)
|
+ '&merchantId=' + encodeURIComponent(item.id)
|
+ '&name=' + encodeURIComponent(item.name)
|
+ '&image=' + encodeURIComponent(item.image)
|
+ '&address=' + encodeURIComponent(item.address)
|
+ '&discount=' + encodeURIComponent(item.discount)
|
})
|
}
|
</script>
|
|
<style>
|
@font-face { font-family: 'FoodCalligraphy'; src: url('/static/fonts/food-calligraphy-subset.ttf') format('truetype'); font-weight: normal; font-style: normal; }
|
.page { width: 100%; height: 100vh; background-color: #FFFFFF; }
|
.page-scroll { width: 100%; height: 100vh; background-color: #FFFFFF; }
|
.hero { height: 730rpx; position: relative; z-index: 2; overflow: visible; background-color: #332F29; }
|
.hero-swiper { position: absolute; left: 0; top: 0; width: 100%; height: 730rpx; }
|
.hero-image { position: absolute; left: 0; top: 0; width: 100%; height: 730rpx; }
|
.hero-top-shade { position: absolute; left: 0; right: 0; top: 0; height: 230rpx; background-image: linear-gradient(180deg, rgba(0,0,0,.62) 0%, rgba(0,0,0,.18) 65%, rgba(0,0,0,0) 100%); }
|
.hero-bottom-shade { position: absolute; left: 0; right: 0; bottom: 0; height: 230rpx; background-image: linear-gradient(180deg, rgba(0,0,0,0) 0%, rgba(0,0,0,.45) 100%); }
|
.custom-nav { position: fixed; z-index: 20; left: 0; right: 0; top: 0; padding-bottom: 20rpx; }
|
.custom-nav-bg { position: absolute; z-index: 0; left: 0; right: 0; top: 0; bottom: 0; opacity: 0; background-image: linear-gradient(180deg, #F26A38 0%, #EE542B 100%); transition: opacity 280ms ease-out; }
|
.custom-nav.is-solid .custom-nav-bg { opacity: 1; }
|
.safe-space { height: 28px; position: relative; z-index: 2; }
|
.nav-row { display: flex; height: 32px; box-sizing: border-box; padding-left: 24rpx; padding-right: 10px; position: relative; z-index: 2; flex-direction: row; align-items: center; justify-content: space-between; }
|
.back-button { display: flex; width: 54rpx; height: 54rpx; position: relative; z-index: 2; border-radius: 50%; background-color: rgba(45,45,45,.52); transition: background-color 280ms ease-out; justify-content: center; align-items: center; }
|
.custom-nav.is-solid .back-button { background-color: rgba(45,45,45,0); }
|
.back-button image { width: 38rpx; height: 38rpx; }
|
.nav-title { position: absolute; z-index: 1; left: 0; right: 0; color: #FFFFFF; font-size: 31rpx; font-weight: 500; text-align: center; text-shadow: 0 2rpx 8rpx rgba(0,0,0,.35); }
|
.capsule-space { width: 88px; height: 100%; flex-shrink: 0; }
|
.dish-name { position: absolute; z-index: 3; left: 38rpx; top: 322rpx; color: #FFFFFF; font-family: 'FoodCalligraphy'; font-size: 48rpx; line-height: 66rpx; text-shadow: 0 3rpx 10rpx rgba(0,0,0,.55); }
|
.no-gallery-text { position: absolute; z-index: 3; left: 0; right: 0; top: 484rpx; color: rgba(255,255,255,.86); font-size: 25rpx; text-align: center; }
|
.thumb-scroll { position: absolute; z-index: 4; left: 0; right: 0; top: 469rpx; width: 100%; height: 150rpx; white-space: nowrap; }
|
.thumb-scroll::-webkit-scrollbar { display: none; width: 0; height: 0; color: transparent; }
|
.thumb-scroll ::-webkit-scrollbar { display: none; width: 0; height: 0; color: transparent; }
|
.thumb-row { display: flex; height: 150rpx; box-sizing: border-box; padding: 0; flex-direction: row; align-items: center; }
|
.thumb-spacer { width: 322rpx; height: 1rpx; flex-shrink: 0; }
|
.thumb-item { display: flex; width: 106rpx; height: 106rpx; margin-right: 31rpx; box-sizing: border-box; overflow: hidden; position: relative; z-index: 1; border-width: 3rpx; border-style: solid; border-color: rgba(255,255,255,.9); border-radius: 8rpx; flex-shrink: 0; transition: transform 180ms ease-out; }
|
.thumb-item image { width: 100%; height: 100%; }
|
.thumb-item.active { z-index: 3; border-width: 4rpx; border-color: #FFD329; transform: scale(1.28); box-shadow: 0 8rpx 18rpx rgba(0,0,0,.28); }
|
.topic-summary { position: relative; z-index: 4; margin-top: -66rpx; }
|
.description-card { display: flex; min-height: 154rpx; margin: 0 31rpx; box-sizing: border-box; padding: 25rpx 31rpx; border-radius: 26rpx; background-image: linear-gradient(90deg, #FF5320 0%, #FF8A22 100%); box-shadow: 0 10rpx 22rpx rgba(255,104,28,.24); justify-content: center; }
|
.description-card text { color: #FFFFFF; font-size: 23rpx; line-height: 34rpx; }
|
.service-section { position: relative; z-index: 1; padding: 48rpx 31rpx 0; background-color: #FFFFFF; }
|
.section-heading { display: flex; height: 72rpx; flex-direction: row; align-items: flex-start; }
|
.heading-title-wrap { display: flex; width: 132rpx; flex-direction: column; align-items: center; }
|
.heading-title-wrap text { color: #363636; font-size: 32rpx; font-weight: 700; }
|
.heading-line { width: 61rpx; height: 7rpx; margin-top: 12rpx; border-radius: 4rpx; background-image: linear-gradient(90deg, #FF4D36 0%, #FFBF3A 100%); }
|
.merchant-list { padding-top: 9rpx; }
|
.merchant-item { display: flex; min-height: 215rpx; margin-bottom: 22rpx; flex-direction: row; align-items: flex-start; }
|
.merchant-item.is-unavailable { opacity: .56; }
|
.merchant-image { width: 184rpx; height: 184rpx; border-radius: 17rpx; flex-shrink: 0; background-color: #EEEEEE; }
|
.merchant-info { display: flex; min-width: 0; min-height: 184rpx; flex: 1; box-sizing: border-box; padding: 4rpx 0 4rpx 24rpx; flex-direction: column; justify-content: space-between; }
|
.merchant-name { color: #292929; font-size: 29rpx; line-height: 40rpx; font-weight: 700; }
|
.tag-row { display: flex; flex-direction: row; align-items: center; }
|
.tag-row text:first-child { margin-left: 0; }
|
.coupon-tag { padding: 6rpx 8rpx; border-radius: 6rpx; background-color: #FFF0EC; color: #FF5D37; font-size: 21rpx; }
|
.discount-tag { margin-left: 11rpx; padding: 6rpx 9rpx; border-radius: 6rpx; background-color: #FFF5E7; color: #FF7C2B; font-size: 21rpx; }
|
.address-row { display: flex; min-width: 0; box-sizing: border-box; padding: 7rpx 10rpx; border-radius: 7rpx; background-image: linear-gradient(90deg, rgba(239,240,242,.98) 0%, rgba(242,243,245,.76) 58%, rgba(255,255,255,0) 100%); flex-direction: row; align-items: flex-start; }
|
.address-row image { width: 27rpx; height: 27rpx; margin-right: 8rpx; flex-shrink: 0; }
|
.address-row text { min-width: 0; flex: 1; color: #7E8490; font-size: 21rpx; line-height: 31rpx; }
|
.merchant-empty { display: flex; height: 310rpx; justify-content: center; align-items: center; }
|
.merchant-empty text { color: #A5A8AD; font-size: 25rpx; }
|
.error-state { flex-direction: column; }
|
.error-state .retry-text { margin-top: 22rpx; color: #F26A38; }
|
.loading-row { display: flex; height: 90rpx; flex-direction: row; justify-content: center; align-items: center; }
|
.loading-spinner { width: 28rpx; height: 28rpx; margin-right: 13rpx; box-sizing: border-box; border-width: 3rpx; border-style: solid; border-color: #FFD6C4; border-top-color: #FF6C2B; border-radius: 50%; animation: merchant-loading 720ms linear infinite; }
|
.loading-row text { color: #989DA5; font-size: 22rpx; }
|
.load-error text { color: #F26A38; }
|
.no-more { display: flex; height: 90rpx; flex-direction: row; justify-content: center; align-items: center; }
|
.no-more view { width: 70rpx; height: 1rpx; background-color: #E7E7E7; }
|
.no-more text { margin: 0 18rpx; color: #B1B3B7; font-size: 21rpx; }
|
@keyframes merchant-loading { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
.bottom-space { height: calc(24rpx + env(safe-area-inset-bottom)); }
|
</style>
|