shinnosuke
2026-09-27 35c0810b268661c79f776fe922f7e3834cba4186
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
<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">{{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="loadMoreServices">
            <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-top-shade"></view>
                <view class="hero-bottom-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 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="replaceSceneImage(index)"></image>
                        </view>
                        <view class="scene-pad"></view>
                    </view>
                </scroll-view>
            </view>
 
            <view v-if="topicError" class="topic-error"><text>专题配置加载失败</text><text @tap="loadTransportTopic">重新加载</text></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 v-for="tab in serviceTabs" :key="tab.key" class="service-tab" :class="tab.key == activeTab ? 'active' : ''" @tap="selectTab(tab.key)"><text>{{tab.label}}</text><view class="tab-line"></view></view>
                </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 class="service-item" :class="item.available ? '' : 'is-unavailable'" v-for="item in visibleServices" :key="item.id" @tap="openMerchant(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.benefitTagVisible" class="service-discount">{{item.available ? item.discount : '已下架'}}</text>
                            <view class="address-row"><image src="/static/benefits-assets/map-pin.svg"></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>
    </view>
</template>
 
<script setup lang="uts">
    import { isApiConfigured } from '../../config/api.uts'
    import { getTransportTopic, getTransportTopicMerchants } 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 fallbackScenes: any[] = []
    const topicTitle = ref('行--畅行无忧'); const topicDescription = ref('整合加油、停车、洗车等出行服务资源,为人才提供专属折扣、停车优惠和便捷洗车服务,全方位保障人才出行便捷。'); const topicError = ref(false)
    const activeIndex = ref(0); const sceneScrollLeft = ref(0); const sceneTrackWidth = ref(644); const scenes = ref<any[]>(fallbackScenes); const currentScene = ref({ id: '', name: '', image: '' })
    const serviceTabs = [{ key: 'fuel', label: '加油服务' }, { key: 'parking', label: '停车服务' }, { key: 'carWash', label: '自助洗车服务' }]
    const talentId = ref('')
    const activeTab = ref('fuel'); 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' }
        talentId.value = resolveTalentId()
        selectScene(0); loadTransportTopic(); loadInitialServices()
    })
    const loadTransportTopic = () => {
        if (!isApiConfigured()) { topicError.value = true; return }
        getTransportTopic((data: any) => {
            topicError.value = false
            if (data != null && data.title != null && data.title != '') topicTitle.value = '行--' + data.title as string
            if (data != null && data.description != null) topicDescription.value = 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(activeIndex.value, next.length - 1))
        }, () => { topicError.value = true })
    }
    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 = () => loadMoreServices()
    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 selectTab = (tab: string) => { if (activeTab.value == tab && hasLoaded.value) return; activeTab.value = tab; loadInitialServices() }
    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 }
        getTransportTopicMerchants(activeTab.value, talentId.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 merchantType = item.merchantType != null ? item.merchantType as string : activeTab.value; const discount = merchantType == 'fuel' ? '' : (item.discountText != null ? item.discountText as string : ''); next.push({ id: '' + item.id, merchantType: merchantType, name: item.merchantName != null ? item.merchantName as string : '行服务', discount: discount, benefitTagVisible: merchantType == 'fuel' ? false : (merchantType == 'carWash' ? item.benefitTagVisible === true : discount != ''), address: item.address != null ? item.address as string : '', image: item.coverUrl != null && item.coverUrl != '' ? resolveApiUrl(item.coverUrl as string) : fallbackImage, available: item.available !== false, externalLinkType: item.externalLinkType != null ? item.externalLinkType as string : '' }) }
            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 loadMoreServices = () => { 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 openMerchant = (item: any) => { if (!item.available) { uni.showToast({ title: '该服务已下架', icon: 'none' }); return }; if (item.merchantType == 'carWash' || item.merchantType == '自助洗车') { if (item.externalLinkType == 'miniProgram' || item.externalLinkType == 'web') { uni.navigateTo({ url: '/pages/car-wash-merchant/car-wash-merchant?merchantId=' + encodeURIComponent(item.id) + '&name=' + encodeURIComponent(item.name) }); return }; uni.navigateTo({ url: '/pages/merchant/merchant?merchantType=carWash&merchantId=' + encodeURIComponent(item.id) + '&name=' + encodeURIComponent(item.name) + '&image=' + encodeURIComponent(item.image) + '&address=' + encodeURIComponent(item.address) + '&discount=' + encodeURIComponent(item.discount) }); return }; const action = item.merchantType == 'parking' && item.discount.indexOf('人才码') >= 0 ? 'talentCode' : ''; uni.navigateTo({ url: '/pages/merchant/merchant?type=transport&merchantType=' + encodeURIComponent(item.merchantType) + '&merchantId=' + encodeURIComponent(item.id) + '&name=' + encodeURIComponent(item.name) + '&image=' + encodeURIComponent(item.image) + '&address=' + encodeURIComponent(item.address) + '&discount=' + encodeURIComponent(item.discount) + '&action=' + encodeURIComponent(action) }) }
</script>
 
<style>
    @font-face { font-family: 'FoodCalligraphy'; src: url('/static/fonts/food-calligraphy-subset.ttf') format('truetype'); font-weight: normal; font-style: normal; }
    .page { width: 100%; height: 100vh; background-color: #FFFFFF; }
    .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; z-index: 0; 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 { position: relative; z-index: 2; height: 28px; }
    .nav-row { display: flex; position: relative; z-index: 2; 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: rgba(45,45,45,0); }
    .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; z-index: 2; overflow: visible; background-color: #6A563E; }
    .hero-image { position: absolute; left: 0; top: 0; width: 100%; height: 730rpx; }
    /* 主图轮播:与下方缩略图带同高同位,盖在原来的静态主图上。 */
    .hero-swiper { position: absolute; left: 0; top: 0; width: 100%; height: 730rpx; }
    .hero-top-shade { position: absolute; left: 0; right: 0; top: 0; height: 230rpx; background-image: linear-gradient(180deg, rgba(0,0,0,.60) 0%, rgba(0,0,0,.18) 68%, rgba(0,0,0,0) 100%); }
    .hero-bottom-shade { position: absolute; left: 0; right: 0; bottom: 0; height: 230rpx; background-image: linear-gradient(180deg, rgba(0,0,0,0) 0%, rgba(0,0,0,.46) 100%); }
    .scene-name { position: absolute; z-index: 3; left: 38rpx; top: 322rpx; color: #FFFFFF; font-family: 'FoodCalligraphy'; font-size: 48rpx; line-height: 66rpx; text-shadow: 0 3rpx 10rpx rgba(0,0,0,.55); }
    .no-gallery { position: absolute; z-index: 3; left: 0; right: 0; top: 482rpx; 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; 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; }
    .topic-error { display: flex; position: relative; z-index: 5; margin: -48rpx 31rpx 0; color: #FFFFFF; font-size: 22rpx; flex-direction: row; justify-content: center; }
    .topic-error text:last-child { margin-left: 18rpx; text-decoration: underline; }
    .intro-card { display: flex; min-height: 154rpx; 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); justify-content: center; }
    .intro-card text { color: #FFFFFF; font-size: 23rpx; line-height: 34rpx; }
    .service-section { position: relative; z-index: 1; padding: 48rpx 31rpx 0; background-color: #FFFFFF; }
    .service-tabs { display: flex; height: 72rpx; flex-direction: row; align-items: flex-start; }
    .service-tab { display: flex; margin-right: 42rpx; flex-direction: column; align-items: center; }
    .service-tab text { color: #8A8D92; font-size: 31rpx; 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 0%, #FFBF3A 100%); 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: .56; }
    .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; }
    .service-discount { align-self: flex-start; padding: 6rpx 9rpx; border-radius: 6rpx; background-color: #FFF5E7; color: #FF7C2B; font-size: 21rpx; }
    .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) 0%, rgba(242,243,245,.76) 58%, rgba(255,255,255,0) 100%); 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; 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 { 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-width: 3rpx; border-style: solid; border-color: #FFD6C4; border-top-color: #FF6C2B; border-radius: 50%; animation: transport-loading 720ms linear infinite; }
    .loading-row text { color: #989DA5; font-size: 22rpx; }
    .load-error text { color: #F26A38; }
    .no-more { display: flex; height: 90rpx; flex-direction: row; justify-content: center; align-items: center; }
    .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(24rpx + env(safe-area-inset-bottom)); }
    @keyframes transport-loading { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
</style>