shinnosuke
4 天以前 5ce0d76154bf4c1d70543476cdfad9a30195b0f8
rencaika/pages/stay-benefits/stay-benefits.uvue
@@ -1,9 +1,78 @@
<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>
      <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 class="scene-item" v-for="(item,index) in scenes" :key="item.id" :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="service-tabs"><view :class="activeTab == 'hotel' ? 'active' : ''" @tap="switchTab('hotel')"><text>酒店服务</text><view></view></view><view :class="activeTab == 'apartment' ? 'active' : ''" @tap="switchTab('apartment')"><text>公寓租赁服务</text><view></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>{{activeTab == 'hotel' ? '暂无相关酒店服务' : '暂无相关公寓租赁服务'}}</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="discount-tag">{{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="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="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 :show-scrollbar="false" class="page-scroll" scroll-y lower-threshold="80" @scroll="handleScroll" @scrolltolower="loadMore">
         <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 :show-scrollbar="false"
               scroll-with-animation :scroll-left="sceneScrollLeft" @scrollend="handleSceneScrollEnd">
               <view class="scene-row" :style="'width:' + sceneTrackWidth + 'rpx'">
                  <view class="scene-pad"></view>
                  <view class="scene-item" v-for="(item,index) in scenes" :key="item.id"
                     :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="service-tabs">
               <view :class="activeTab == 'hotel' ? 'active' : ''" @tap="switchTab('hotel')"><text>酒店服务</text>
                  <view></view>
               </view>
               <view :class="activeTab == 'apartment' ? 'active' : ''" @tap="switchTab('apartment')">
                  <text>公寓租赁服务</text>
                  <view></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>{{activeTab == 'hotel' ? '暂无相关酒店服务' : '暂无相关公寓租赁服务'}}</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="discount-tags"><text v-for="tag in item.discountTags" :key="tag" :class="tag == '券' ? 'coupon-tag' : 'discount-tag'">{{tag}}</text></view><text v-else class="discount-tag">已下架</text>
                     <view v-if="item.merchantType != 'apartment'" 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,41 +82,462 @@
   import { getStayTopic, getStayTopicMerchants } from '../../services/portal.uts'
   import { resolveApiUrl } from '../../services/http.uts'
   import { safeBack } from '../../utils/navigation.uts'
   import { resolveTalentId } from '../../utils/talent-id.uts'
   const navTop = ref(28); const navHeight = ref(32); const navWidth = ref(88); const navRight = ref(10)
   const isSolidNav = ref(false); const windowWidth = ref(375); const fallbackImage = '/static/stay-assets/hotel-vienna.jpg'
   const isSolidNav = ref(false); const windowWidth = ref(375); const fallbackImage = ''
   const activeIndex = ref(-1); const sceneScrollLeft = ref(0); const sceneTrackWidth = ref(644); const scenes = ref<any[]>([]); const currentScene = ref({ id: 'fallback', name: '', image: fallbackImage }); const topicDescription = ref('')
   const activeTab = ref('hotel'); const visibleServices = ref<any[]>([]); const initialLoading = ref(true); const initialError = ref(false); const loadingMore = ref(false); const loadMoreError = ref(false); const hasLoaded = ref(false); const hasMore = ref(false); const currentPage = ref(0); const listRequestVersion = ref(0); const failInitialOnce = ref(false); const failLoadMoreOnce = ref(false)
   const talentId = ref('')
   onLoad((options: any) => {
   onLoad((options : any) => {
      // #ifdef MP-WEIXIN
      const capsule = uni.getMenuButtonBoundingClientRect(); const info = uni.getSystemInfoSync(); windowWidth.value = info.windowWidth; navTop.value = capsule.top; navHeight.value = capsule.height; navWidth.value = capsule.width; navRight.value = info.windowWidth - capsule.right
      // #endif
      if (options != null && options['previewState'] != null) { const state = options['previewState'] as string; failInitialOnce.value = state == 'initialError'; failLoadMoreOnce.value = state == 'loadMoreError' }
      talentId.value = resolveTalentId()
      loadStayTopic(); loadInitialServices()
   })
   const readText = (primary : any, fallback : any) : string => {
      if (primary != null && primary != '') return primary as string
      if (fallback != null && fallback != '') return fallback as string
      return ''
   }
   const readAvailable = (item : any) : boolean => {
      if (item.available != null) return item.available as boolean
      if (item.status != null) return Number(item.status) == 1
      return true
   }
   const loadStayTopic = () => {
      if (!isApiConfigured()) return
      getStayTopic((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 != '') next.push({ id: '' + item.id, name: item.name != null ? item.name as string : '', image: resolveApiUrl(item.imageUrl as string) }) }; scenes.value = next; sceneTrackWidth.value = 644 + next.length * 137; if (next.length > 0) selectScene(Math.min(2, next.length - 1)) }, () => { scenes.value = []; topicDescription.value = '' })
      getStayTopic((data : any) => {
         topicDescription.value = data != null ? readText(data.description, data.intro) : ''
         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]
            const imageUrl = readText(item.imageUrl, item['image_url'])
            if (imageUrl == '') continue
            next.push({
               id: '' + item.id,
               name: readText(item.name, item['item_name']),
               image: resolveApiUrl(imageUrl)
            })
         }
         scenes.value = next
         sceneTrackWidth.value = 644 + next.length * 137
         if (next.length > 0) selectScene(Math.min(2, next.length - 1))
      }, () => {
         scenes.value = []
         topicDescription.value = ''
      })
   }
   const loadInitialServices = () => { listRequestVersion.value++; currentPage.value = 0; visibleServices.value = []; hasMore.value = false; initialLoading.value = true; initialError.value = false; loadMoreError.value = false; hasLoaded.value = false; loadStayServices(false) }
   const retryInitialLoad = () => loadInitialServices(); const retryLoadMore = () => loadMore()
   const selectScene = (index: number) => { if (index < 0 || index >= scenes.value.length) return; activeIndex.value = index; currentScene.value = scenes.value[index]; sceneScrollLeft.value = index * 137 * windowWidth.value / 750 }
   const handleSceneScrollEnd = (event: any) => { let index = Math.round(event.detail.scrollLeft * 750 / windowWidth.value / 137); if (index < 0) index = 0; if (index >= scenes.value.length) index = scenes.value.length - 1; selectScene(index) }
   const handleScroll = (event: any) => { isSolidNav.value = event.detail.scrollTop > 64 }
   const switchTab = (tab: string) => { if (activeTab.value == tab && hasLoaded.value) return; activeTab.value = tab; loadInitialServices() }
   const loadStayServices = (append: boolean) => {
      if (!isApiConfigured()) { initialLoading.value = false; loadingMore.value = false; initialError.value = true; return }
   const selectScene = (index : number) => { if (index < 0 || index >= scenes.value.length) return; activeIndex.value = index; currentScene.value = scenes.value[index]; sceneScrollLeft.value = index * 137 * windowWidth.value / 750 }
   const handleSceneScrollEnd = (event : any) => { let index = Math.round(event.detail.scrollLeft * 750 / windowWidth.value / 137); if (index < 0) index = 0; 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 switchTab = (tab : string) => { if (activeTab.value == tab && hasLoaded.value) return; activeTab.value = tab; loadInitialServices() }
   const loadStayServices = (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 }
      getStayTopicMerchants(activeTab.value, currentPage.value + 1, 4, (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 : activeTab.value, 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 }) }; visibleServices.value = next; currentPage.value++; 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 })
      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
      }
      getStayTopicMerchants(activeTab.value, 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]
            let coverUrl = readText(item.coverUrl, item['cover_url'])
            if (coverUrl == '') coverUrl = readText(item.logoUrl, item['logo_url'])
            const merchantType = readText(item.merchantType, item['merchant_type'])
            const merchantName = readText(item.merchantName, item['merchant_name'])
            const discountText = readText(item.discountText, item['discount_text'])
            const hasConfiguredTags = item.benefitTags instanceof Array
            const configuredTags: any[] = hasConfiguredTags ? (item.benefitTags as any[]) : []
            const displayTags: string[] = []
            for (let tagIndex = 0; tagIndex < configuredTags.length; tagIndex++) {
               const tag = String(configuredTags[tagIndex]).trim()
               if (tag != '' && tag != '人才专享' && displayTags.indexOf(tag) < 0) displayTags.push(tag)
            }
            const hasCoupon = item.couponAvailable === true || Number(item.couponAvailable) == 1
            if (!hasConfiguredTags) {
               if (hasCoupon) displayTags.push('券')
               if (discountText != '' && discountText != '人才专享') displayTags.push(discountText)
            }
            next.push({
               id: '' + item.id,
               merchantType: merchantType != '' ? merchantType : activeTab.value,
               name: merchantName != '' ? merchantName : '\u4f4f\u5bbf\u670d\u52a1',
               discount: discountText != '' ? discountText : '\u4eba\u624d\u4e13\u4eab',
               discountTags: displayTags,
               address: readText(item.address, item['merchant_address']),
               image: coverUrl != '' ? resolveApiUrl(coverUrl) : fallbackImage,
               available: readAvailable(item)
            })
         }
         visibleServices.value = next
         currentPage.value++
         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 loadMore = () => { if (initialLoading.value || loadingMore.value || !hasMore.value) return; loadMoreError.value = false; loadingMore.value = true; loadStayServices(true) }
   const replaceHeroImage = () => { currentScene.value.image = fallbackImage }; const replaceSceneImage = (index: number) => { if (index >= 0 && index < scenes.value.length) scenes.value[index].image = fallbackImage }; const replaceServiceImage = (item: any) => { item.image = fallbackImage }
   const goBack = () => safeBack('/pages/benefits/benefits'); const openService = (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) }) }
   const replaceHeroImage = () => { currentScene.value.image = fallbackImage }; const replaceSceneImage = (index : number) => { if (index >= 0 && index < scenes.value.length) scenes.value[index].image = fallbackImage }; const replaceServiceImage = (item : any) => { item.image = fallbackImage }
   const goBack = () => safeBack('/pages/benefits/benefits'); const openService = (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'); }.page,.page-scroll{width:100%;height:100vh;background:#FFF}.custom-nav{position:fixed;z-index:20;left:0;right:0;top:0;padding-bottom:20rpx}.nav-bg{position:absolute;inset:0;opacity:0;background:#F26A38}.custom-nav.is-solid .nav-bg{opacity:1}.safe-space,.nav-row{position:relative;z-index:1}.safe-space{height:28px}.nav-row{display:flex;height:32px;box-sizing:border-box;padding-left:24rpx;padding-right:10px;align-items:center;justify-content:space-between}.back-button{display:flex;width:54rpx;height:54rpx;border-radius:50%;background:rgba(45,45,45,.52);align-items:center;justify-content:center}.custom-nav.is-solid .back-button{background:transparent}.back-button image{width:38rpx;height:38rpx}.nav-title{position:absolute;left:0;right:0;color:#FFF;font-size:31rpx;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:#1D315D}.hero-image{position:absolute;inset:0;width:100%;height:730rpx}.hero-shade{position:absolute;left:0;right:0;top:0;height:360rpx;background:linear-gradient(180deg,rgba(0,0,0,.58),transparent)}.scene-name{position:absolute;z-index:2;left:38rpx;top:300rpx;color:#FFF;font-family:'FoodCalligraphy';font-size:48rpx;text-shadow:0 3rpx 10rpx rgba(0,0,0,.55)}.no-gallery{position:absolute;z-index:2;top:480rpx;left:0;right:0;color:#FFF;text-align:center}.scene-scroll{position:absolute;z-index:4;left:0;right:0;top:469rpx;height:150rpx;white-space:nowrap}.scene-row{display:flex;height:150rpx;align-items:center}.scene-pad{width:322rpx;flex-shrink:0}.scene-item{width:106rpx;height:106rpx;margin-right:31rpx;overflow:hidden;border:3rpx solid rgba(255,255,255,.9);border-radius:8rpx;flex-shrink:0}.scene-item image{width:100%;height:100%}.scene-item.active{border:4rpx solid #FFD329;transform:scale(1.28)}.topic-summary{margin-top:-66rpx;position:relative;z-index:4}.intro-card{margin:0 31rpx;padding:25rpx 31rpx;border-radius:26rpx;background:linear-gradient(90deg,#FF5320,#FF8A22)}.intro-card text{color:#FFF;font-size:23rpx;line-height:34rpx}.service-section{padding:48rpx 31rpx 0}.service-tabs{display:flex;height:72rpx}.service-tabs>view{display:flex;margin-right:42rpx;flex-direction:column;align-items:center}.service-tabs text{color:#8A8D92;font-size:31rpx;font-weight:600}.service-tabs .active text{color:#363636}.service-tabs view view{width:0;height:7rpx;margin-top:12rpx;border-radius:4rpx;background:linear-gradient(90deg,#FF4D36,#FFBF3A)}.service-tabs .active view{width:61rpx}.service-item{display:flex;min-height:205rpx;margin-bottom:20rpx}.is-unavailable{opacity:.56}.service-image{width:184rpx;height:184rpx;border-radius:17rpx;flex-shrink:0;background:#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}.discount-tag{display:block;width:fit-content;padding:6rpx 9rpx;border-radius:6rpx;background:#FFF1EC;color:#FF653D;font-size:21rpx}.address{display:flex;min-width:0;padding:7rpx 10rpx;border-radius:7rpx;background:#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;align-items:center;justify-content: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{to{transform:rotate(360deg)}}.bottom-space{height:calc(30rpx + env(safe-area-inset-bottom))}
   @font-face {
      font-family: 'StayCalligraphy';
      src: url('/static/fonts/food-calligraphy-subset.ttf') format('truetype');
      font-weight: normal;
      font-style: normal;
   }
   .page,
   .page-scroll {
      width: 100%;
      height: 100vh;
      background-color: #FFFFFF;
      font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
   }
   .custom-nav {
      position: fixed;
      z-index: 20;
      left: 0;
      right: 0;
      top: 0;
      padding-bottom: 10rpx;
   }
   .nav-bg {
      position: absolute;
      inset: 0;
      opacity: 0;
      background-color: #F26A38;
      transition: opacity 160ms ease-out;
   }
   .custom-nav.is-solid .nav-bg { opacity: 1; }
   .safe-space,
   .nav-row { position: relative; z-index: 1; }
   .safe-space { height: 28px; }
   .nav-row {
      display: flex;
      height: 32px;
      box-sizing: border-box;
      padding-left: 20rpx;
      padding-right: 10px;
      flex-direction: row;
      align-items: center;
      justify-content: space-between;
   }
   .back-button {
      display: flex;
      width: 54rpx;
      height: 54rpx;
      position: relative;
      z-index: 3;
      border-radius: 50%;
      background-color: rgba(27, 38, 62, .34);
      align-items: center;
      justify-content: center;
   }
   .custom-nav.is-solid .back-button { background-color: transparent; }
   .back-button-active { opacity: .62; }
   .back-button image { width: 36rpx; height: 36rpx; pointer-events: none; }
   .nav-title {
      position: absolute;
      z-index: 1;
      left: 0;
      right: 0;
      color: #FFFFFF;
      font-size: 31rpx;
      font-weight: 500;
      line-height: 42rpx;
      text-align: center;
      text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, .32);
   }
   .capsule-space { width: 88px; height: 100%; flex-shrink: 0; }
   .hero {
      height: 590rpx;
      position: relative;
      overflow: visible;
      background-color: #1D315D;
   }
   .hero-image {
      position: absolute;
      inset: 0;
      width: 100%;
      height: 590rpx;
   }
   /* 主图轮播:与下方缩略图带同高同位,盖在原来的静态主图上。 */
   .hero-swiper {
      position: absolute;
      left: 0;
      top: 0;
      width: 100%;
      height: 590rpx;
   }
   .hero-shade {
      position: absolute;
      left: 0;
      right: 0;
      top: 0;
      height: 300rpx;
      background-image: linear-gradient(180deg, rgba(10, 22, 51, .55), rgba(10, 22, 51, 0));
   }
   .scene-name {
      position: absolute;
      z-index: 2;
      left: 38rpx;
      top: 245rpx;
      max-width: 650rpx;
      color: #FFFFFF;
      font-family: 'FoodCalligraphyFull', 'StayCalligraphy', 'STXingkai', 'KaiTi', cursive;
      font-size: 48rpx;
      font-weight: normal;
      font-style: normal;
      line-height: 66rpx;
      letter-spacing: 1rpx;
      text-shadow: 0 3rpx 10rpx rgba(0, 0, 0, .55);
   }
   .no-gallery {
      position: absolute;
      z-index: 2;
      top: 390rpx;
      left: 0;
      right: 0;
      color: rgba(255,255,255,.9);
      font-size: 23rpx;
      text-align: center;
   }
   .scene-scroll {
      position: absolute;
      z-index: 4;
      left: 0;
      right: 0;
      top: 385rpx;
      height: 132rpx;
      white-space: nowrap;
   }
   .scene-row {
      display: flex;
      height: 132rpx;
      flex-direction: row;
      align-items: center;
   }
   .scene-pad { width: 322rpx; height: 1rpx; flex-shrink: 0; }
   .scene-item {
      display: flex;
      width: 106rpx;
      height: 106rpx;
      margin-right: 31rpx;
      box-sizing: border-box;
      overflow: hidden;
      position: relative;
      z-index: 1;
      border: 3rpx solid rgba(255,255,255,.92);
      border-radius: 8rpx;
      flex-shrink: 0;
      transition: transform 160ms ease-out;
   }
   .scene-item image { width: 100%; height: 100%; }
   .scene-item.active {
      z-index: 3;
      border-width: 4rpx;
      border-color: #FFD329;
      transform: scale(1.13);
      box-shadow: 0 8rpx 18rpx rgba(0,0,0,.28);
   }
   .topic-summary {
      margin-top: -65rpx;
      position: relative;
      z-index: 5;
   }
   .intro-card {
      display: flex;
      min-height: 110rpx;
      margin: 0 32rpx;
      box-sizing: border-box;
      padding: 18rpx 24rpx;
      border-radius: 16rpx;
      background-image: linear-gradient(90deg, #FF5425 0%, #FF861D 100%);
      box-shadow: 0 9rpx 22rpx rgba(255, 104, 28, .26);
      align-items: center;
   }
   .intro-card text {
      color: #FFFFFF;
      font-size: 23rpx;
      line-height: 34rpx;
   }
   .service-section { padding: 20rpx 32rpx 0; }
   .service-tabs {
      display: flex;
      height: 70rpx;
      flex-direction: row;
      align-items: flex-start;
   }
   .service-tabs > view {
      display: flex;
      margin-right: 44rpx;
      flex-direction: column;
      align-items: center;
   }
   .service-tabs text {
      color: #8A8D92;
      font-size: 31rpx;
      font-weight: 600;
      line-height: 42rpx;
   }
   .service-tabs .active text { color: #303238; font-weight: 700; }
   .service-tabs view view {
      width: 0;
      height: 5rpx;
      margin-top: 7rpx;
      border-radius: 4rpx;
      background-image: linear-gradient(90deg, #FF4D36, #FFBF3A);
      transition: width 160ms ease-out;
   }
   .service-tabs .active view { width: 48rpx; }
   .service-item {
      display: flex;
      min-height: 156rpx;
      margin-bottom: 18rpx;
      flex-direction: row;
      align-items: flex-start;
   }
   .is-unavailable { opacity: .56; }
   .service-image {
      width: 136rpx;
      height: 136rpx;
      border-radius: 14rpx;
      flex-shrink: 0;
      background-color: #EEF0F2;
   }
   .service-info {
      display: flex;
      min-width: 0;
      min-height: 136rpx;
      box-sizing: border-box;
      padding: 0 0 0 20rpx;
      flex: 1;
      flex-direction: column;
      align-items: flex-start;
      justify-content: space-between;
   }
   .service-name {
      display: block;
      width: 100%;
      color: #292B30;
      font-size: 29rpx;
      font-weight: 700;
      line-height: 40rpx;
   }
   .discount-tag {
      display: block;
      width: fit-content;
      box-sizing: border-box;
      padding: 3rpx 8rpx;
      border-radius: 5rpx;
      background-color: #FFF5E7;
      color: #FF7C2B;
      font-size: 21rpx;
      line-height: 29rpx;
   }
   .coupon-tag {
      display: block;
      width: fit-content;
      box-sizing: border-box;
      padding: 3rpx 8rpx;
      border-radius: 5rpx;
      background-color: #FFF0EC;
      color: #FF5D37;
      font-size: 21rpx;
      line-height: 29rpx;
   }
   .discount-tags { display:flex; flex-direction:row; flex-wrap:wrap; gap:8rpx; }
   .address {
      display: flex;
      max-width: 100%;
      box-sizing: border-box;
      padding: 3rpx 7rpx;
      border-radius: 5rpx;
      background-color: #F3F4F5;
      flex-direction: row;
      align-items: center;
   }
   .address image { width: 21rpx; height: 21rpx; margin-right: 5rpx; flex-shrink: 0; }
   .address text {
      min-width: 0;
      color: #7D838E;
      font-size: 21rpx;
      line-height: 31rpx;
      white-space: nowrap;
      overflow: hidden;
      text-overflow: ellipsis;
   }
   .list-state,
   .no-more {
      display: flex;
      height: 132rpx;
      color: #989DA5;
      font-size: 23rpx;
      align-items: center;
      justify-content: center;
   }
   .list-state.compact { height: 82rpx; }
   .error-state { flex-direction: column; }
   .retry-text,
   .load-error text { margin-top: 16rpx; 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 { to { transform: rotate(360deg); } }
   .bottom-space { height: calc(30rpx + env(safe-area-inset-bottom)); }
</style>