shinnosuke
2026-08-27 8dbcc40964ee315fc35abad757ae9a368f298bf9
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
<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 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 v-if="!item.available || item.hasCoupon" class="discount-tag">{{item.available ? '券' : '已下架'}}</text><view class="address"><image src="/static/benefits-assets/map-pin.svg" mode="aspectFit"></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 { getEntertainmentTopic, getEntertainmentTopicMerchants } 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 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'
        }
        loadEntertainmentTopic()
        loadInitialServices()
    })
 
    const loadEntertainmentTopic = () => {
        if (!isApiConfigured()) return
        getEntertainmentTopic((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(1, 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
        loadServices(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 loadServices = (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 }
        getEntertainmentTopicMerchants(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 hasCoupon = item.couponAvailable === true || Number(item.couponAvailable) == 1 || String(item.discountText).trim() == '券'
                next.push({ id: '' + item.id, merchantType: item.merchantType != null ? item.merchantType as string : 'cinema', name: item.merchantName != null ? item.merchantName as string : '观影服务', discount: hasCoupon ? '券' : '', hasCoupon: hasCoupon, 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 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 }
        const cinemaTypes = ['cinema', 'theatre', '观影服务', '电影院', '影院', '影城', '剧院']
        if (cinemaTypes.indexOf(item.merchantType) < 0) { uni.showToast({ title: '剧院服务详情暂未开放', icon: 'none' }); return }
        uni.navigateTo({ url: '/pages/cinema-merchant/cinema-merchant?merchantId=' + encodeURIComponent(item.id) + '&name=' + encodeURIComponent(item.name) + '&image=' + encodeURIComponent(item.image) + '&address=' + encodeURIComponent(item.address) + '&discount=' + encodeURIComponent(item.discount) })
    }
</script>
 
<style>
    .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 0%, #EE542B 100%); 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: 20rpx; padding-right: 10px; position: relative; flex-direction: row; align-items: center; justify-content: space-between; }.back-button { display: flex; width: 72rpx; height: 64rpx; position: relative; z-index: 4; margin-left: -4rpx; border-radius: 50%; background-color: rgba(45,45,45,.52); justify-content: center; align-items: center; }.back-button-active { opacity: .55; }.custom-nav.is-solid .back-button { background-color: rgba(45,45,45,0); }.back-button image { width: 38rpx; height: 38rpx; pointer-events: none; }.nav-title { position: absolute; z-index: 2; 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); pointer-events: none; }.nav-bg { pointer-events: none; }.capsule-space { width: 88px; height: 100%; flex-shrink: 0; }
    .hero { height: 730rpx; position: relative; overflow: visible; background-color: #241C2A; }.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) 0%, rgba(0,0,0,0) 100%); }.scene-name { position: absolute; z-index: 2; left: 38rpx; top: 300rpx; color: #FFFFFF; font-size: 47rpx; line-height: 62rpx; font-weight: 500; 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; width: 100%; height: 150rpx; white-space: nowrap; }.scene-row { display: flex; height: 150rpx; padding: 0; 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-width: 3rpx; border-style: solid; border-color: 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-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 0%, #FF8A22 100%); box-shadow: 0 10rpx 22rpx rgba(255,104,28,.24); }.intro-card text { color: #FFFFFF; font-size: 23rpx; line-height: 34rpx; }
    .service-section { padding: 32rpx 31rpx 0; }.section-heading { display: flex; width: 132rpx; height: 72rpx; flex-direction: column; align-items: center; }.section-heading text { display: block; color: #363636; font-size: 32rpx; font-weight: 700; }.section-heading view { width: 61rpx; height: 7rpx; margin: 12rpx 0 18rpx; border-radius: 4rpx; background-image: linear-gradient(90deg, #FF4D36 0%, #FFBF3A 100%); }.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; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }.discount-tag { display: block; width: fit-content; padding: 6rpx 9rpx; border-radius: 6rpx; background-color: #FFF0EC; color: #FF5D37; font-size: 21rpx; }.address { 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) 0%, rgba(242,243,245,.76) 58%, rgba(255,255,255,0) 100%); flex-direction: row; align-items: flex-start; }.address image { width: 27rpx; height: 27rpx; margin-right: 8rpx; flex-shrink: 0; }.address text { min-width: 0; flex: 1; color: #7E8490; font-size: 21rpx; line-height: 31rpx; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
    .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: entertainment-loading 720ms linear infinite; }@keyframes entertainment-loading { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }.bottom-space { height: calc(30rpx + env(safe-area-inset-bottom)); }
</style>