shinnosuke
4 天以前 5ce0d76154bf4c1d70543476cdfad9a30195b0f8
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
<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.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 :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 :show-scrollbar="false" 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><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>
 
<script setup lang="uts">
    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)
    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 talentId = ref('')
    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)
 
    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 state = options['previewState'] as string
            failInitialOnce.value = state == 'initialError'
            failLoadMoreOnce.value = state == 'loadMoreError'
        }
        loadTravelTopic()
        talentId.value = resolveTalentId()
        loadInitialServices()
    })
 
    const loadTravelTopic = () => {
        if (!isApiConfigured()) return
        getTravelTopic((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) })
            }
            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 = listRequestVersion.value + 1
        currentPage.value = 0
        visibleServices.value = []
        hasMore.value = false
        initialLoading.value = true
        initialError.value = false
        loadMoreError.value = false
        hasLoaded.value = false
        loadTravelServices(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) => {
        const scrollRpx = event.detail.scrollLeft * 750 / windowWidth.value
        let index = Math.round(scrollRpx / 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 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, 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]
                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
            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; loadTravelServices(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: '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-color: #F26A38; transition: opacity 280ms ease-out; }
    .custom-nav.is-solid .nav-bg { opacity: 1; }
    .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; 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; }
    .scene-scroll { position: absolute; z-index: 4; left: 0; right: 0; top: 469rpx; height: 150rpx; white-space: nowrap; } .scene-row { display: flex; height: 150rpx; flex-direction: row; align-items: center; } .scene-pad { width: 322rpx; flex-shrink: 0; }
    .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-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>