shinnosuke
2026-08-05 25729fa1ae7b630cbd4a9547a5693acd03c09c2a
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
<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 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>
        </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 { 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 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()
        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 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) => {
            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 })
            }
            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: 1; } .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; }
    .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-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; }
    .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)); }
</style>