shinnosuke
2026-09-27 35c0810b268661c79f776fe922f7e3834cba4186
rencaika/pages/travel-benefits/travel-benefits.uvue
@@ -1,10 +1,10 @@
<template>
   <view class="page">
      <view class="custom-nav" :class="isSolidNav ? 'is-solid' : ''"><view class="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>
      <view class="custom-nav" :class="isSolidNav ? 'is-solid' : ''"><view class="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" hover-class="back-button-active" @tap.stop="goBack"><image src="/static/food-assets/arrow-left-white.svg" mode="aspectFit"></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="handleScroll" @scrolltolower="loadMore">
         <view class="hero"><image class="hero-image" :src="currentScene.image" mode="aspectFill" @error="replaceHeroImage"></image><view class="hero-shade"></view><text v-if="currentScene.name != ''" class="scene-name">{{currentScene.name}}</text><text v-if="scenes.length == 0" class="no-gallery">暂无图册内容</text><scroll-view v-else class="scene-scroll" scroll-x scroll-with-animation :scroll-left="sceneScrollLeft" @scrollend="handleSceneScrollEnd"><view class="scene-row" :style="'width:' + sceneTrackWidth + 'rpx'"><view class="scene-pad"></view><view v-for="(item, index) in scenes" :key="item.id" class="scene-item" :class="index == activeIndex ? 'active' : ''" @tap="selectScene(index)"><image :src="item.image" mode="aspectFill" @error="replaceSceneImage(index)"></image></view><view class="scene-pad"></view></view></scroll-view></view>
         <view class="hero"><swiper v-if="scenes.length > 0" class="hero-swiper" :current="activeIndex < 0 ? 0 : activeIndex" :circular="scenes.length > 1" :duration="240" @change="handleHeroChange"><swiper-item v-for="(item, index) in scenes" :key="item.id"><image class="hero-image" :src="item.image" mode="aspectFill" @error="replaceSceneImage(index)"></image></swiper-item></swiper><image v-else class="hero-image" :src="currentScene.image" mode="aspectFill" @error="replaceHeroImage"></image><view class="hero-shade"></view><text v-if="currentScene.name != ''" class="scene-name">{{currentScene.name}}</text><text v-if="scenes.length == 0" class="no-gallery">暂无图册内容</text><scroll-view v-else class="scene-scroll" scroll-x scroll-with-animation :scroll-left="sceneScrollLeft" @scrollend="handleSceneScrollEnd"><view class="scene-row" :style="'width:' + sceneTrackWidth + 'rpx'"><view class="scene-pad"></view><view v-for="(item, index) in scenes" :key="item.id" class="scene-item" :class="index == activeIndex ? 'active' : ''" @tap="selectScene(index)"><image :src="item.image" mode="aspectFill" @error="replaceSceneImage(index)"></image></view><view class="scene-pad"></view></view></scroll-view></view>
         <view v-if="topicDescription != ''" class="topic-summary"><view class="intro-card"><text>{{topicDescription}}</text></view></view>
         <view class="service-section"><view class="section-heading"><text>景区服务</text><view></view></view><view v-if="initialLoading" class="list-state"><view class="loading-spinner"></view><text>正在加载景区服务</text></view><view v-else-if="initialError" class="list-state error-state"><text>景区服务加载失败</text><text class="retry-text" @tap="retryInitialLoad">重新加载</text></view><view v-else-if="hasLoaded && visibleServices.length == 0" class="list-state"><text>暂无相关景区服务</text></view><view v-else-if="hasLoaded"><view class="service-item" :class="item.available ? '' : 'is-unavailable'" v-for="item in visibleServices" :key="item.id" @tap="openService(item)"><image class="service-image" :src="item.image" mode="aspectFill" @error="replaceServiceImage(item)"></image><view class="service-info"><text class="service-name">{{item.name}}</text><text class="service-discount">{{item.available ? item.discount : '已下架'}}</text><view class="address"><image src="/static/benefits-assets/map-pin.svg"></image><text>{{item.address}}</text></view></view></view></view><view v-if="loadingMore" class="list-state compact"><view class="loading-spinner"></view><text>正在加载</text></view><view v-else-if="loadMoreError" class="list-state compact load-error" @tap="retryLoadMore"><text>加载失败,点击重试</text></view><view v-else-if="hasLoaded && !hasMore && visibleServices.length > 0" class="no-more"><text>已经到底了</text></view></view><view class="bottom-space"></view>
         <view class="service-section"><view class="section-heading"><text>景区服务</text><view></view></view><view v-if="initialLoading" class="list-state"><view class="loading-spinner"></view><text>正在加载景区服务</text></view><view v-else-if="initialError" class="list-state error-state"><text>景区服务加载失败</text><text class="retry-text" @tap="retryInitialLoad">重新加载</text></view><view v-else-if="hasLoaded && visibleServices.length == 0" class="list-state"><text>暂无相关景区服务</text></view><view v-else-if="hasLoaded"><view class="service-item" :class="item.available ? '' : 'is-unavailable'" v-for="item in visibleServices" :key="item.id" @tap="openService(item)"><image class="service-image" :src="item.image" mode="aspectFill" @error="replaceServiceImage(item)"></image><view class="service-info"><text class="service-name">{{item.name}}</text><view v-if="!item.available" class="service-tags"><text class="service-discount">已下架</text></view><view v-else-if="item.tags.length > 0" class="service-tags"><text v-for="tag in item.tags" :key="tag" :class="tag == '券' ? 'coupon-tag' : 'service-discount'">{{tag}}</text></view><view class="address"><image src="/static/benefits-assets/map-pin.svg"></image><text>{{item.address}}</text></view></view></view></view><view v-if="loadingMore" class="list-state compact"><view class="loading-spinner"></view><text>正在加载</text></view><view v-else-if="loadMoreError" class="list-state compact load-error" @tap="retryLoadMore"><text>加载失败,点击重试</text></view><view v-else-if="hasLoaded && !hasMore && visibleServices.length > 0" class="no-more"><text>已经到底了</text></view></view><view class="bottom-space"></view>
      </scroll-view>
   </view>
</template>
@@ -13,6 +13,7 @@
   import { isApiConfigured } from '../../config/api.uts'
   import { getTravelTopic, getTravelTopicMerchants } 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)
@@ -28,6 +29,7 @@
   const scenes = ref<any[]>([])
   const currentScene = ref({ id: 'fallback', name: '', image: fallbackImage })
   const topicDescription = ref('')
   const talentId = ref('')
   const visibleServices = ref<any[]>([])
   const initialLoading = ref(true)
   const initialError = ref(false)
@@ -56,6 +58,7 @@
         failLoadMoreOnce.value = state == 'loadMoreError'
      }
      loadTravelTopic()
      talentId.value = resolveTalentId()
      loadInitialServices()
   })
@@ -101,19 +104,38 @@
      if (index >= scenes.value.length) index = scenes.value.length - 1
      selectScene(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
      selectScene(current)
   }
   const handleScroll = (event: any) => { isSolidNav.value = event.detail.scrollTop > 64 }
   const loadTravelServices = (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 }
      getTravelTopicMerchants(currentPage.value + 1, 4, (data: any) => {
      getTravelTopicMerchants(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 ? visibleServices.value.slice() : []
         for (let index = 0; index < records.length; index++) {
            const item = records[index]
            next.push({ id: '' + item.id, merchantType: item.merchantType != null ? item.merchantType as string : 'scenic', name: item.merchantName != null ? item.merchantName as string : '景区服务', discount: item.discountText != null ? item.discountText as string : '人才专享', address: item.address != null ? item.address as string : '', image: item.coverUrl != null && item.coverUrl != '' ? resolveApiUrl(item.coverUrl as string) : fallbackImage, available: item.available !== false })
            const hasCoupon = item.couponAvailable === true || Number(item.couponAvailable) == 1
            const discount = item.discountText != null && item.discountText != '人才专享' ? item.discountText as string : ''
            const tags: string[] = []
            if (item.benefitTags instanceof Array) {
               const tagRecords: any[] = item.benefitTags as any[]
               for (let tagIndex = 0; tagIndex < tagRecords.length; tagIndex++) {
                  const tag = String(tagRecords[tagIndex]).trim()
                  if (tag != '' && tag != '人才专享' && tags.indexOf(tag) < 0) tags.push(tag)
               }
            } else {
               if (hasCoupon) tags.push('券')
               if (discount != '') tags.push(discount)
            }
            next.push({ id: '' + item.id, merchantType: item.merchantType != null ? item.merchantType as string : 'scenic', name: item.merchantName != null ? item.merchantName as string : '景区服务', discount: discount, tags: tags, address: item.address != null ? item.address as string : '', image: item.coverUrl != null && item.coverUrl != '' ? resolveApiUrl(item.coverUrl as string) : fallbackImage, available: item.available !== false })
         }
         visibleServices.value = next
         currentPage.value = currentPage.value + 1
@@ -146,9 +168,9 @@
   .custom-nav { position: fixed; z-index: 20; left: 0; right: 0; top: 0; padding-bottom: 20rpx; }
   .nav-bg { position: absolute; left: 0; right: 0; top: 0; bottom: 0; opacity: 0; background-color: #F26A38; transition: opacity 280ms ease-out; }
   .custom-nav.is-solid .nav-bg { opacity: 1; }
   .safe-space, .nav-row { position: relative; z-index: 1; } .safe-space { height: 28px; }
   .safe-space, .nav-row { position: relative; z-index: 2; } .safe-space { height: 28px; }
   .nav-row { display: flex; height: 32px; box-sizing: border-box; padding-left: 24rpx; padding-right: 10px; flex-direction: row; align-items: center; justify-content: space-between; }
   .back-button { display: flex; width: 54rpx; height: 54rpx; border-radius: 50%; background-color: rgba(45,45,45,.52); justify-content: center; align-items: center; } .custom-nav.is-solid .back-button { background-color: transparent; } .back-button image { width: 38rpx; height: 38rpx; }
   .back-button { display: flex; width: 54rpx; height: 54rpx; position: relative; z-index: 3; border-radius: 50%; background-color: rgba(45,45,45,.52); justify-content: center; align-items: center; } .custom-nav.is-solid .back-button { background-color: transparent; } .back-button-active { opacity: .62; } .back-button image { width: 38rpx; height: 38rpx; pointer-events: none; }
   .nav-title { position: absolute; 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; }
   .hero { height: 730rpx; position: relative; overflow: visible; background-color: #332F29; } .hero-image { position: absolute; left: 0; top: 0; width: 100%; height: 730rpx; } .hero-shade { position: absolute; left: 0; right: 0; top: 0; height: 360rpx; background-image: linear-gradient(180deg,rgba(0,0,0,.6),rgba(0,0,0,0)); }
   .scene-name { position: absolute; z-index: 2; left: 38rpx; top: 300rpx; color: #FFFFFF; font-family: 'FoodCalligraphy'; font-size: 48rpx; text-shadow: 0 3rpx 10rpx rgba(0,0,0,.55); } .no-gallery { position: absolute; z-index: 2; left: 0; right: 0; top: 480rpx; color: #FFFFFF; font-size: 25rpx; text-align: center; }
@@ -156,6 +178,12 @@
   .scene-item { width: 106rpx; height: 106rpx; margin-right: 31rpx; box-sizing: border-box; overflow: hidden; border: 3rpx solid rgba(255,255,255,.9); border-radius: 8rpx; flex-shrink: 0; transition: transform 180ms ease-out; } .scene-item image { width: 100%; height: 100%; } .scene-item.active { border-width: 4rpx; border-color: #FFD329; transform: scale(1.28); box-shadow: 0 8rpx 18rpx rgba(0,0,0,.28); }
   .topic-summary { margin-top: -66rpx; position: relative; z-index: 4; } .intro-card { margin: 0 31rpx; padding: 25rpx 31rpx; border-radius: 26rpx; background-image: linear-gradient(90deg,#FF5320,#FF8A22); box-shadow: 0 10rpx 22rpx rgba(255,104,28,.24); } .intro-card text { color: #FFFFFF; font-size: 23rpx; line-height: 34rpx; }
   .service-section { padding: 48rpx 31rpx 0; } .section-heading { display: flex; width: 132rpx; height: 72rpx; flex-direction: column; align-items: center; } .section-heading text { color: #363636; font-size: 32rpx; font-weight: 700; } .section-heading view { width: 61rpx; height: 7rpx; margin-top: 12rpx; border-radius: 4rpx; background-image: linear-gradient(90deg,#FF4D36,#FFBF3A); }
   .service-item { display: flex; min-height: 205rpx; margin-bottom: 20rpx; flex-direction: row; } .service-item.is-unavailable { opacity: .56; } .service-image { width: 184rpx; height: 184rpx; border-radius: 17rpx; flex-shrink: 0; background-color: #EEE; } .service-info { display: flex; min-width: 0; min-height: 184rpx; flex: 1; padding: 4rpx 0 4rpx 24rpx; flex-direction: column; justify-content: space-between; } .service-name { color: #292929; font-size: 29rpx; font-weight: 700; } .service-discount { display: block; width: fit-content; padding: 6rpx 9rpx; border-radius: 6rpx; background-color: #FFF5E7; color: #FF6A34; font-size: 21rpx; } .address { display: flex; min-width: 0; padding: 7rpx 10rpx; border-radius: 7rpx; background-color: #F1F2F3; } .address image { width: 27rpx; height: 27rpx; margin-right: 8rpx; } .address text { min-width: 0; flex: 1; color: #7E8490; font-size: 21rpx; line-height: 31rpx; }
   .service-item { display: flex; min-height: 205rpx; margin-bottom: 20rpx; flex-direction: row; } .service-item.is-unavailable { opacity: .56; } .service-image { width: 184rpx; height: 184rpx; border-radius: 17rpx; flex-shrink: 0; background-color: #EEE; } .service-info { display: flex; min-width: 0; min-height: 184rpx; flex: 1; padding: 4rpx 0 4rpx 24rpx; flex-direction: column; justify-content: space-between; } .service-name { color: #292929; font-size: 29rpx; font-weight: 700; } .service-tags { display: flex; flex-direction: row; align-items: center; } .coupon-tag { padding: 6rpx 8rpx; border-radius: 6rpx; background-color: #FFF0EC; color: #FF5D37; font-size: 21rpx; } .service-discount { display: block; width: fit-content; margin-left: 0; padding: 6rpx 9rpx; border-radius: 6rpx; background-color: #FFF5E7; color: #FF6A34; font-size: 21rpx; } .service-tags .service-discount { margin-left: 11rpx; } .service-tags .coupon-tag + .service-discount { margin-left: 11rpx; } .address { display: flex; min-width: 0; padding: 7rpx 10rpx; border-radius: 7rpx; background-color: #F1F2F3; } .address image { width: 27rpx; height: 27rpx; margin-right: 8rpx; } .address text { min-width: 0; flex: 1; color: #7E8490; font-size: 21rpx; line-height: 31rpx; }
   .list-state, .no-more { display: flex; height: 150rpx; color: #989DA5; font-size: 23rpx; justify-content: center; align-items: center; } .list-state.compact { height: 90rpx; } .error-state { flex-direction: column; } .retry-text, .load-error text { margin-top: 18rpx; color: #F26A38; } .loading-spinner { width: 26rpx; height: 26rpx; margin-right: 12rpx; box-sizing: border-box; border: 3rpx solid #FFD6C4; border-top-color: #FF6C2B; border-radius: 50%; animation: loading 720ms linear infinite; } @keyframes loading { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } .bottom-space { height: calc(30rpx + env(safe-area-inset-bottom)); }
   /* 定位图标与地址必须同一行:uni-app x 的 view 默认纵向排列,这里显式改成横向并做单行省略。 */
   .address { flex-direction: row; align-items: center; }
   .address image { flex-shrink: 0; }
   .address text { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
   /* 主图轮播:与下方缩略图带同高同位,盖在原来的静态主图上。 */
   .hero-swiper { position: absolute; left: 0; top: 0; width: 100%; height: 730rpx; }
</style>