shinnosuke
2026-09-16 c5a63165aca6468157e0b4807d511e828de2076a
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
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
<template>
    <view class="page">
        <view class="custom-nav" :class="isSolidNav ? 'is-solid' : ''">
            <view class="custom-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="handlePageScroll" @scrolltolower="loadMoreMerchants">
            <view class="hero">
                <image class="hero-image" :src="currentImage" mode="aspectFill" @error="replaceTopicImage"></image>
                <view class="hero-top-shade"></view>
                <view class="hero-bottom-shade"></view>
 
                <text v-if="currentName.length > 0" class="dish-name">{{currentName}}</text>
                <text v-if="gallery.length == 0" class="no-gallery-text">暂无图册内容</text>
                <scroll-view v-if="gallery.length > 0" class="thumb-scroll" scroll-x :show-scrollbar="false" scroll-with-animation :scroll-left="thumbScrollLeft" @scrollend="handleThumbScrollEnd">
                    <view class="thumb-row" :style="'width:' + thumbTrackWidth + 'rpx'">
                        <view class="thumb-spacer"></view>
                        <view class="thumb-item" v-for="(item, index) in gallery" :key="item.id" :class="activeIndex == index ? 'active' : ''" @tap="selectGallery(index)">
                            <image :src="item.image" mode="aspectFill" @error="replaceGalleryImage(index)"></image>
                        </view>
                        <view class="thumb-spacer"></view>
                    </view>
                </scroll-view>
 
            </view>
            <view v-if="topicDescription.length > 0" class="topic-summary"><view class="description-card"><text>{{topicDescription}}</text></view></view>
            <view class="service-section">
                <view class="section-heading"><view class="heading-title-wrap"><text>餐饮服务</text><view class="heading-line"></view></view></view>
                <view v-if="initialLoading" class="merchant-empty"><view class="loading-spinner"></view><text>正在加载餐饮服务</text></view>
                <view v-else-if="initialError" class="merchant-empty error-state"><text>餐饮服务加载失败</text><text class="retry-text" @tap="retryInitialLoad">重新加载</text></view>
                <view v-else-if="hasLoaded && visibleMerchants.length == 0" class="merchant-empty"><text>暂无相关餐饮服务</text></view>
                <view v-else-if="hasLoaded" class="merchant-list">
                    <view class="merchant-item" :class="item.available ? '' : 'is-unavailable'" v-for="item in visibleMerchants" :key="item.id" @tap="openMerchant(item)">
                        <image class="merchant-image" :src="item.image" mode="aspectFill" @error="replaceMerchantImage(item)"></image>
                        <view class="merchant-info">
                            <text class="merchant-name">{{item.name}}</text>
                            <view v-if="!item.available" class="tag-row"><text class="discount-tag">已下架</text></view>
                            <view v-else-if="item.tags.length > 0" class="tag-row"><text v-for="tag in item.tags" :key="tag" :class="tag == '券' ? 'coupon-tag' : 'discount-tag'">{{tag}}</text></view>
                            <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 && visibleMerchants.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 { getFoodTopic, getFoodTopicMerchants } 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 activeIndex = ref(-1)
    const windowWidth = ref(375)
    const thumbScrollLeft = ref(0)
    const fallbackFoodImage = '/static/food-assets/food-1.jpg'
    const topicDescription = ref('')
 
    const gallery = ref<any[]>([])
    const thumbTrackWidth = ref(644)
    const currentImage = ref(fallbackFoodImage)
    const currentName = ref('')
    const visibleMerchants = ref<any[]>([])
    const initialLoading = ref(true)
    const loadingMore = ref(false)
    const hasLoaded = ref(false)
    const hasMore = ref(false)
    const initialError = ref(false)
    const loadMoreError = ref(false)
    const failInitialOnce = ref(false)
    const failLoadMoreOnce = ref(false)
    const currentPage = ref(0)
    const listRequestVersion = ref(0)
    const talentId = ref('')
    const readListTags = (item: any, hasCoupon: boolean, discount: string): string[] => {
        const tags: string[] = []
        if (item != null && item.benefitTags instanceof Array) {
            const records: any[] = item.benefitTags as any[]
            for (let index = 0; index < records.length; index++) {
                const tag = String(records[index]).trim()
                if (tag != '' && tag != '人才专享' && tags.indexOf(tag) < 0) tags.push(tag)
            }
            return tags
        }
        if (hasCoupon) tags.push('券')
        if (discount != '' && discount != '人才专享') tags.push(discount)
        return tags
    }
 
    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 previewState = options['previewState'] as string
            failInitialOnce.value = previewState == 'initialError'
            failLoadMoreOnce.value = previewState == 'loadMoreError'
        }
        talentId.value = resolveTalentId()
        loadFoodTopic()
        loadInitialMerchants()
    })
    const loadFoodTopic = () => {
        if (!isApiConfigured()) return
        getFoodTopic((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) })
            }
            gallery.value = next
            thumbTrackWidth.value = 644 + next.length * 137
            if (next.length > 0) selectGallery(Math.min(2, next.length - 1))
        }, () => {
            gallery.value = []
            topicDescription.value = ''
        })
    }
    const loadInitialMerchants = () => {
        listRequestVersion.value = listRequestVersion.value + 1
        currentPage.value = 0
        visibleMerchants.value = []
        hasMore.value = false
        initialLoading.value = true
        initialError.value = false
        loadMoreError.value = false
        hasLoaded.value = false
        loadFoodMerchants(false)
    }
    const retryInitialLoad = () => loadInitialMerchants()
    const retryLoadMore = () => loadMoreMerchants()
 
    const centerActiveThumb = (index: number) => {
        const targetRpx = index * 137
        thumbScrollLeft.value = targetRpx * windowWidth.value / 750
    }
    const selectGallery = (index: number) => {
        if (index < 0 || index >= gallery.value.length) return
        activeIndex.value = index
        currentImage.value = gallery.value[index].image
        currentName.value = gallery.value[index].name
        centerActiveThumb(index)
    }
    const handleThumbScrollEnd = (event: any) => {
        const scrollRpx = event.detail.scrollLeft * 750 / windowWidth.value
        let index = Math.round(scrollRpx / 137)
        if (index < 0) index = 0
        if (index >= gallery.value.length) index = gallery.value.length - 1
        selectGallery(index)
    }
    const handlePageScroll = (event: any) => {
        const shouldShow = event.detail.scrollTop > 64
        if (isSolidNav.value != shouldShow) isSolidNav.value = shouldShow
    }
    const loadFoodMerchants = (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
        }
        getFoodTopicMerchants(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 ? visibleMerchants.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 : ''
                next.push({
                    id: '' + item.id,
                    name: item.merchantName != null ? item.merchantName as string : '餐饮商户',
                    merchantType: item.merchantType != null ? item.merchantType as string : 'food',
                    discount: discount,
                    hasCoupon: hasCoupon,
                    tags: readListTags(item, hasCoupon, discount),
                    address: item.address != null ? item.address as string : '',
                    image: item.coverUrl != null && item.coverUrl != '' ? resolveApiUrl(item.coverUrl as string) : fallbackFoodImage,
                    available: item.available !== false
                })
            }
            visibleMerchants.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 loadMoreMerchants = () => {
        if (initialLoading.value || loadingMore.value || !hasMore.value) return
        loadMoreError.value = false
        loadingMore.value = true
        loadFoodMerchants(true)
    }
    const replaceTopicImage = () => { currentImage.value = fallbackFoodImage }
    const replaceGalleryImage = (index: number) => {
        if (index >= 0 && index < gallery.value.length) gallery.value[index].image = fallbackFoodImage
    }
    const replaceMerchantImage = (item: any) => { item.image = fallbackFoodImage }
    const goBack = () => safeBack('/pages/benefits/benefits')
    const openMerchant = (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'); font-weight: normal; font-style: normal; }
    .page { width: 100%; height: 100vh; background-color: #FFFFFF; }
    .page-scroll { width: 100%; height: 100vh; background-color: #FFFFFF; }
    .hero { height: 730rpx; position: relative; z-index: 2; overflow: visible; background-color: #332F29; }
    .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,.62) 0%, rgba(0,0,0,.18) 65%, 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,.45) 100%); }
    .custom-nav { position: fixed; z-index: 20; left: 0; right: 0; top: 0; padding-bottom: 20rpx; }
    .custom-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 .custom-nav-bg { opacity: 1; }
    .safe-space { height: 28px; position: relative; z-index: 2; }
    .nav-row { display: flex; height: 32px; box-sizing: border-box; padding-left: 24rpx; padding-right: 10px; position: relative; z-index: 2; 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); transition: background-color 280ms ease-out; 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 { width: 88px; height: 100%; flex-shrink: 0; }
    .dish-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-text { position: absolute; z-index: 3; left: 0; right: 0; top: 484rpx; color: rgba(255,255,255,.86); font-size: 25rpx; text-align: center; }
    .thumb-scroll { position: absolute; z-index: 4; left: 0; right: 0; top: 469rpx; width: 100%; height: 150rpx; white-space: nowrap; }
    .thumb-scroll::-webkit-scrollbar { display: none; width: 0; height: 0; color: transparent; }
    .thumb-scroll ::-webkit-scrollbar { display: none; width: 0; height: 0; color: transparent; }
    .thumb-row { display: flex; height: 150rpx; box-sizing: border-box; padding: 0; flex-direction: row; align-items: center; }
    .thumb-spacer { width: 322rpx; height: 1rpx; flex-shrink: 0; }
    .thumb-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; }
    .thumb-item image { width: 100%; height: 100%; }
    .thumb-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; }
    .description-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; }
    .description-card text { color: #FFFFFF; font-size: 23rpx; line-height: 34rpx; }
    .service-section { position: relative; z-index: 1; padding: 48rpx 31rpx 0; background-color: #FFFFFF; }
    .section-heading { display: flex; height: 72rpx; flex-direction: row; align-items: flex-start; }
    .heading-title-wrap { display: flex; width: 132rpx; flex-direction: column; align-items: center; }
    .heading-title-wrap text { color: #363636; font-size: 32rpx; font-weight: 700; }
    .heading-line { width: 61rpx; height: 7rpx; margin-top: 12rpx; border-radius: 4rpx; background-image: linear-gradient(90deg, #FF4D36 0%, #FFBF3A 100%); }
    .merchant-list { padding-top: 9rpx; }
    .merchant-item { display: flex; min-height: 215rpx; margin-bottom: 22rpx; flex-direction: row; align-items: flex-start; }
    .merchant-item.is-unavailable { opacity: .56; }
    .merchant-image { width: 184rpx; height: 184rpx; border-radius: 17rpx; flex-shrink: 0; background-color: #EEEEEE; }
    .merchant-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; }
    .merchant-name { color: #292929; font-size: 29rpx; line-height: 40rpx; font-weight: 700; }
    .tag-row { display: flex; flex-direction: row; align-items: center; }
    .tag-row text:first-child { margin-left: 0; }
    .coupon-tag { padding: 6rpx 8rpx; border-radius: 6rpx; background-color: #FFF0EC; color: #FF5D37; font-size: 21rpx; }
    .discount-tag { margin-left: 11rpx; 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; }
    .merchant-empty { display: flex; height: 310rpx; justify-content: center; align-items: center; }
    .merchant-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: merchant-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; }
    @keyframes merchant-loading { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
    .bottom-space { height: calc(24rpx + env(safe-area-inset-bottom)); }
</style>