<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" 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>
|
|
<script setup lang="uts">
|
import { isApiConfigured } from '../../config/api.uts'
|
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 = ''
|
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) => {
|
// #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 ? 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 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, 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) }) }
|
</script>
|
|
<style>
|
@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>
|