shinnosuke
2026-07-31 82141e1b9e46a154418295aa95b34cb842c001c3
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
<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">
                <image 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 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 { 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 = '/static/home-assets/top.jpg'
    const fallbackScenes = [{ id: 'fallback-fuel', name: '人才加油服务', image: '/static/home-assets/top.jpg' }, { id: 'fallback-parking', name: '人才停车服务', image: '/static/home-assets/city-intro.jpg' }]
    const topicTitle = ref('行--畅行无忧'); const topicDescription = ref('整合加油、停车、洗车等出行服务资源,为人才提供专属折扣、停车优惠和便捷洗车服务,全方位保障人才出行便捷。'); const topicError = ref(false)
    const activeIndex = ref(0); const sceneScrollLeft = ref(0); const sceneTrackWidth = ref(644 + fallbackScenes.length * 137); const scenes = ref<any[]>(fallbackScenes); const currentScene = ref(fallbackScenes[0])
    const serviceTabs = [{ key: 'fuel', label: '加油服务' }, { key: 'parking', label: '停车服务' }, { key: 'carWash', label: '自助洗车服务' }]
    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' }
        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 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, 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 : activeTab.value, 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++; 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') { uni.navigateTo({ url: '/pages/car-wash-merchant/car-wash-merchant?merchantId=' + encodeURIComponent(item.id) + '&name=' + encodeURIComponent(item.name) }); 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-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>