| | |
| | | <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" mode="aspectFit"></image></view><text class="nav-title">享—{{topicTitle}}</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="activeScene.image" mode="aspectFill" @error="replaceImage(activeScene)"></image><view class="hero-top-shade"></view><view class="hero-bottom-shade"></view><text class="scene-name">{{activeScene.name}}</text><scroll-view 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 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="replaceImage(item)"></image></view><view class="scene-pad"></view></view></scroll-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="replaceImage(item)"></image></swiper-item></swiper><image v-else class="hero-image" :src="activeScene.image" mode="aspectFill" @error="replaceImage(activeScene)"></image><view class="hero-top-shade"></view><view class="hero-bottom-shade"></view><text class="scene-name">{{activeScene.name}}</text><scroll-view class="scene-scroll" scroll-x :show-scrollbar="false" scroll-with-animation :scroll-left="sceneScrollLeft" @scroll="handleSceneScroll"><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="replaceImage(item)"></image></view><view class="scene-pad"></view></view></scroll-view></view> |
| | | <view v-if="topicError" class="topic-error"><text>专题配置加载失败</text><text @tap="loadPremiumTopic">重新加载</text></view> |
| | | <view v-if="topicDescription != ''" class="topic-summary"><view class="intro-card"><text>{{topicDescription}}</text></view></view> |
| | | <view class="service-section"><scroll-view class="tab-scroll" scroll-x :show-scrollbar="false"><view class="service-tabs" :style="'width:' + Math.max(750, serviceTabs.length * 190) + 'rpx'"><view v-for="tab in serviceTabs" :key="tab.id" class="service-tab" :class="tab.id == activeTab ? 'active' : ''" @tap="selectTab(tab.id)"><text>{{tab.name}}</text><view class="tab-line"></view></view></view></scroll-view> |
| | | <view v-if="initialLoading" class="service-empty"><view class="loading-spinner"></view><text>正在加载臻享服务</text></view><view v-else-if="initialError" class="service-empty error-state"><text>臻享服务加载失败</text><text class="retry-text" @tap="retryInitialLoad">重新加载</text></view><view v-else-if="hasLoaded && visibleServices.length == 0" class="service-empty"><text>暂无相关臻享服务</text></view><view v-else-if="hasLoaded" class="service-list"><view v-for="item in visibleServices" :key="item.id" class="service-item" :class="item.available ? '' : 'is-unavailable'" @tap="openService(item)"><image class="service-image" :src="item.image" mode="aspectFill" @error="replaceImage(item)"></image><view class="service-info"><text class="service-name">{{item.name}}</text><text class="service-benefit">{{item.available ? item.benefit : '已停用'}}</text><view class="address-row"><image src="/static/benefits-assets/map-pin.svg" mode="aspectFit"></image><text>{{item.address}}</text></view></view></view></view> |
| | | <view class="service-section"><scroll-view class="tab-scroll" scroll-x :show-scrollbar="false"><view class="service-tabs" :style="'width:' + Math.max(750, serviceTabs.length * 234) + 'rpx'"><view v-for="tab in serviceTabs" :key="tab.id" class="service-tab" :class="tab.id == activeTab ? 'active' : ''" @tap="selectTab(tab.id)"><text>{{tab.name}}</text><view class="tab-line"></view></view></view></scroll-view> |
| | | <view v-if="initialLoading" class="service-empty"><view class="loading-spinner"></view><text>正在加载臻享服务</text></view><view v-else-if="initialError" class="service-empty error-state"><text>臻享服务加载失败</text><text class="retry-text" @tap="retryInitialLoad">重新加载</text></view><view v-else-if="hasLoaded && visibleServices.length == 0" class="service-empty"><text>暂无相关臻享服务</text></view><view v-else-if="hasLoaded" class="service-list"><view v-for="item in visibleServices" :key="item.id" class="service-item" :class="item.available ? '' : 'is-unavailable'" @tap="openService(item)"><image class="service-image" :src="item.image" mode="aspectFill" @error="replaceImage(item)"></image><view class="service-info"><text class="service-name">{{item.name}}</text><view v-if="!item.available || item.benefitTagVisible" class="service-benefit-row"><text class="service-benefit">{{item.available ? item.benefit : '已停用'}}</text><text v-if="item.available && item.discount != ''" class="service-benefit service-benefit-extra">{{item.discount}}</text></view><view class="address-row"><image src="/static/benefits-assets/map-pin.svg" mode="aspectFit"></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 && visibleServices.length > 0" class="no-more"><view></view><text>已经到底了</text><view></view></view> |
| | | </view><view class="bottom-space"></view> |
| | | </scroll-view> |
| | |
| | | const replaceImage = (item: any) => { if (item.image != fallbackImage) item.image = fallbackImage } |
| | | const centerActiveScene = (index: number) => { sceneScrollLeft.value = index * 137 * windowWidth.value / 750 } |
| | | const selectScene = (index: number) => { if (index < 0 || index >= scenes.value.length) return; activeIndex.value = index; activeScene.value = scenes.value[index]; centerActiveScene(index) } |
| | | 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) } |
| | | // scroll-view 的 scrollend 只在 Skyline 渲染下触发,WebView 下改用 scroll 停止滚动后判定居中图片 |
| | | const sceneIndexFromScroll = (scrollLeft: number): number => { let index = Math.round(scrollLeft * 750 / windowWidth.value / 137); if (index < 0) index = 0; if (index >= scenes.value.length) index = scenes.value.length - 1; return index } |
| | | let sceneSettleTimer = 0 |
| | | const handleSceneScroll = (event: any) => { const index = sceneIndexFromScroll(event.detail.scrollLeft as number); if (sceneSettleTimer != 0) clearTimeout(sceneSettleTimer); sceneSettleTimer = setTimeout(() => { sceneSettleTimer = 0; selectScene(index) }, 200) } |
| | | /** 主图左右滑动:滑动落点同步到缩略图、名称与描述。 */ |
| | | 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 loadPremiumTopic = () => { |
| | | if (!isApiConfigured()) { topicError.value = true; initialLoading.value = false; initialError.value = true; return } |
| | |
| | | const loadServices = (append: boolean) => { |
| | | if (!isApiConfigured() || activeTab.value == '') { 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 } |
| | | getPremiumTopicServices(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, businessType: item.businessType != null ? item.businessType as string : 'MERCHANT', merchantType: item.merchantType != null ? item.merchantType as string : 'service', name: item.merchantName != null ? item.merchantName as string : '臻享服务', benefit: activeTab.value == '免费体检' || activeTab.value == '配偶推荐就业' ? '免费' : (item.benefitText != null ? item.benefitText 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 }) |
| | | getPremiumTopicServices(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]; const tagVisible = item.benefitTagVisible !== false; next.push({ id: '' + item.id, businessType: item.businessType != null ? item.businessType as string : 'MERCHANT', merchantType: item.merchantType != null ? item.merchantType as string : 'service', name: item.merchantName != null ? item.merchantName as string : '臻享服务', benefit: tagVisible ? (item.benefitText != null ? item.benefitText as string : '') : '', benefitTagVisible: tagVisible, discount: item.discountText != null && item.discountText != item.benefitText ? 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 }) |
| | | } |
| | | const loadMore = () => { if (initialLoading.value || loadingMore.value || !hasMore.value) return; loadMoreError.value = false; loadingMore.value = true; loadServices(true) } |
| | | const goBack = () => safeBack('/pages/benefits/benefits') |
| | |
| | | if (item.businessType == 'CHILD_ENTRUSTMENT') { uni.navigateTo({ url: '/pages/children-entrustment/children-entrustment?serviceId=' + encodeURIComponent(item.id) }); 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.benefit) }) |
| | | } |
| | | onUnload(() => { if (sceneSettleTimer != 0) { clearTimeout(sceneSettleTimer); sceneSettleTimer = 0 } }) |
| | | </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-color:#FFFFFF; }.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-image:linear-gradient(180deg,#F26A38,#EE542B); 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; }.nav-row { display:flex; box-sizing:border-box; padding-left:24rpx; 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); justify-content:center; align-items:center; }.custom-nav.is-solid .back-button { background-color:transparent; }.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 { height:100%; flex-shrink:0; } |
| | | .hero { height:730rpx; position:relative; overflow:visible; background-color:#7296B5; }.hero-image { position:absolute; left:0; top:0; width:100%; height:730rpx; }.hero-top-shade { position:absolute; left:0; right:0; top:0; height:245rpx; background-image:linear-gradient(180deg,rgba(0,0,0,.48),rgba(0,0,0,0)); }.hero-bottom-shade { position:absolute; left:0; right:0; bottom:0; height:230rpx; background-image:linear-gradient(180deg,rgba(0,0,0,0),rgba(0,0,0,.40)); }.scene-name { position:absolute; z-index:3; left:38rpx; top:318rpx; color:#FFFFFF; font-family:'FoodCalligraphy'; font-size:48rpx; line-height:66rpx; text-shadow:0 3rpx 10rpx rgba(0,0,0,.55); }.scene-scroll { position:absolute; z-index:4; left:0; right:0; top:469rpx; width:100%; height:150rpx; white-space:nowrap; }.scene-row { display:flex; height:150rpx; 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,.9); border-radius:8rpx; flex-shrink:0; transition:transform 180ms ease-out; }.scene-item image { width:100%; height:100%; }.scene-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-error { display:flex; height:76rpx; padding:0 31rpx; background-color:#FFF8F4; flex-direction:row; justify-content:center; align-items:center; }.topic-error text { color:#A48A7A; font-size:22rpx; }.topic-error text + text { margin-left:22rpx; color:#F26A38; }.topic-summary { position:relative; z-index:4; margin-top:-66rpx; }.intro-card { margin:0 31rpx; box-sizing:border-box; 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; }.tab-scroll { width:100%; height:78rpx; white-space:nowrap; }.service-tabs { display:flex; height:72rpx; flex-direction:row; align-items:flex-start; }.service-tab { display:flex; width:148rpx; margin-right:42rpx; flex-direction:column; align-items:center; flex-shrink:0; }.service-tab text { color:#8A8D92; font-size:28rpx; font-weight:600; }.service-tab.active text { color:#363636; font-weight:700; }.tab-line { width:0; height:7rpx; margin-top:12rpx; border-radius:4rpx; background-image:linear-gradient(90deg,#FF4D36,#FFBF3A); transition:width 180ms ease-out; }.service-tab.active .tab-line { width:61rpx; } |
| | | .topic-error { display:flex; height:76rpx; padding:0 31rpx; background-color:#FFF8F4; flex-direction:row; justify-content:center; align-items:center; }.topic-error text { color:#A48A7A; font-size:22rpx; }.topic-error text + text { margin-left:22rpx; color:#F26A38; }.topic-summary { position:relative; z-index:4; margin-top:-66rpx; }.intro-card { margin:0 31rpx; box-sizing:border-box; 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; }.tab-scroll { width:100%; height:78rpx; white-space:nowrap; }.service-tabs { display:flex; height:72rpx; flex-direction:row; align-items:flex-start; }.service-tab { display:flex; width:210rpx; margin-right:24rpx; flex-direction:column; align-items:center; flex-shrink:0; }.service-tab text { color:#8A8D92; font-size:28rpx; font-weight:600; white-space:nowrap; }.service-tab.active text { color:#363636; font-weight:700; }.tab-line { width:0; height:7rpx; margin-top:12rpx; border-radius:4rpx; background-image:linear-gradient(90deg,#FF4D36,#FFBF3A); transition:width 180ms ease-out; }.service-tab.active .tab-line { width:61rpx; } |
| | | .service-list { padding-top:10rpx; }.service-item { display:flex; min-height:215rpx; margin-bottom:22rpx; flex-direction:row; align-items:flex-start; }.service-item.is-unavailable { opacity:.55; }.service-image { width:184rpx; height:184rpx; border-radius:17rpx; flex-shrink:0; background-color:#EEEEEE; }.service-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; }.service-name { color:#292929; font-size:29rpx; line-height:40rpx; font-weight:700; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }.service-benefit { align-self:flex-start; max-width:400rpx; padding:6rpx 9rpx; border-radius:6rpx; background-color:#FFF5E7; color:#FF7C2B; font-size:21rpx; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }.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),rgba(242,243,245,.76) 58%,rgba(255,255,255,0)); 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; max-height:62rpx; overflow:hidden; color:#7E8490; font-size:21rpx; line-height:31rpx; }.service-empty { display:flex; height:310rpx; justify-content:center; align-items:center; }.service-empty text { color:#A5A8AD; font-size:25rpx; }.error-state { flex-direction:column; }.error-state .retry-text { margin-top:22rpx; color:#F26A38; }.loading-row,.no-more { 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:3rpx solid #FFD6C4; border-top-color:#FF6C2B; border-radius:50%; animation:premium-loading 720ms linear infinite; }.loading-row text { color:#989DA5; font-size:22rpx; }.load-error text { color:#F26A38; }.no-more view { width:70rpx; height:1rpx; background-color:#E7E7E7; }.no-more text { margin:0 18rpx; color:#B1B3B7; font-size:21rpx; }.bottom-space { height:calc(30rpx + env(safe-area-inset-bottom)); }@keyframes premium-loading { from { transform:rotate(0deg); } to { transform:rotate(360deg); } } |
| | | /* 标签与折扣并排显示:标签是后台配的文字(如「卓越人才卡」),折扣是另一个字段(如「8折」)。 */ |
| | | .service-benefit-row { display:flex; flex-direction:row; align-items:center; } |
| | | .service-benefit-row .service-benefit + .service-benefit { margin-left:10rpx; } |
| | | /* 主图轮播:与下方缩略图带同高同位,盖在原来的静态主图上。 */ |
| | | .hero-swiper { position:absolute; left:0; top:0; width:100%; height:730rpx; } |
| | | </style> |