shinnosuke
5 天以前 e52934f6527027bd6008c2ade61c3083159efd4a
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
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
<template>
    <view class="page">
        <view class="nav-shell">
            <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/news-assets/arrow-left.svg"></image></view>
                <view class="capsule-space" :style="'width:' + navWidth + 'px'"></view>
            </view>
            <view class="search-wrap">
                <view class="search-field">
                    <image class="search-icon" src="/static/benefits-assets/search.svg"></image>
                    <input v-model="keyword" class="search-input" placeholder="搜索" placeholder-class="search-placeholder" confirm-type="search" maxlength="30" @confirm="performSearch" />
                    <view v-if="keyword.length > 0" class="clear-keyword" @tap="clearKeyword"><text>×</text></view>
                </view>
                <view class="search-button" @tap="performSearch"><text>搜索</text></view>
            </view>
        </view>
 
        <scroll-view class="page-scroll" scroll-y="true" :show-scrollbar="false" lower-threshold="200" @scrolltolower="loadMoreMerchants">
            <view v-if="!isAllMode && !hasSearched" class="history-section">
                <view class="history-head">
                    <text>搜索历史</text>
                    <view class="delete-button" @tap="clearHistory"><image src="/static/benefits-assets/trash.png"></image></view>
                </view>
                <view v-if="histories.length > 0" class="history-list">
                    <view class="history-chip" v-for="item in histories" :key="item" @tap="searchHistory(item)"><text>{{item}}</text></view>
                </view>
                <view v-else class="empty-history"><text>暂无搜索历史</text></view>
            </view>
            <view v-else>
                <view class="list-heading">
                    <text class="list-title">{{hasSearched ? '搜索结果' : '全部商家'}}</text>
                    <text v-if="!hasSearched" class="sort-note">按排序值排序</text>
                </view>
                <view v-if="initialLoading" class="result-empty"><view class="loading-spinner"></view><text>正在加载商家</text></view>
                <view v-else-if="initialError" class="result-empty error-state"><text>商家列表加载失败</text><text class="retry-text" @tap="retryInitialLoad">重新加载</text></view>
                <view v-else-if="hasLoaded && visibleMerchants.length > 0" class="result-list">
                    <view class="result-item" v-for="item in visibleMerchants" :key="item.id" @tap="openMerchant(item)">
                        <image class="result-image" :src="item.image" mode="aspectFill" @error="replaceImage(item)"></image>
                        <view class="result-info">
                            <text class="result-name">{{item.name}}</text>
                            <view v-if="item.tags != null && item.tags.length > 0" class="result-tags"><text v-for="tag in item.tags" :key="tag" :class="tag == '券' ? 'coupon-tag' : 'benefit-tag'">{{tag}}</text></view>
                            <view class="result-address"><image src="/static/benefits-assets/map-pin.svg"></image><text>{{item.address}}</text></view>
                        </view>
                    </view>
                </view>
                <view v-else-if="hasLoaded" class="result-empty"><text>暂未搜索到相关店铺</text></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="load-more-action" @tap="loadMoreMerchants"><text>加载更多商家</text></view>
                <view v-else-if="hasLoaded && !hasMore && visibleMerchants.length > 0" class="no-more"><view></view><text>已经到底了</text><view></view></view>
                <view class="list-bottom"></view>
            </view>
        </scroll-view>
    </view>
</template>
 
<script setup lang="uts">
    import { apiEnvironment, isApiConfigured } from '../../config/api.uts'
    import { searchMerchants } from '../../services/portal.uts'
    import { resolveApiUrl } from '../../services/http.uts'
    import { resolveTalentId } from '../../utils/talent-id.uts'
    import { safeBack } from '../../utils/navigation.uts'
    import { openMerchantDetail } from '../../utils/merchant-navigation.uts'
 
    const navTop = ref(28)
    const navHeight = ref(32)
    const navWidth = ref(88)
    const navRight = ref(10)
    const fallbackImage = ''
    const replaceImage = (item: any) => { if (item.image != '') item.image = '' }
    const keyword = ref('')
    const hasSearched = ref(false)
    const isAllMode = ref(false)
    const pageSize = 10
    const allModePageSize = 100
    const talentId = ref('')
    const merchantPool = ref([])
    const visibleMerchants = ref([])
    const loadedCount = ref(0)
    const initialLoading = ref(false)
    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 listRequestVersion = ref(0)
    const currentPage = ref(0)
    const historyStorageKey = 'benefit-search-history'
    const histories = ref<string[]>([])
    // 演示数据已按 sort 从小到大排列;后端接入后由接口返回同序分页结果。
    const allMerchants = [
        { id: 101, name: '乡菜郎·攀枝花特色延边菜', category: '暖心食享', merchantType: 'food', discount: '6折', address: '西区格里坪镇清香坪东街78号', image: '/static/common/image-placeholder.png', sort: 10 },
        { id: 102, name: '攀枝花希尔顿欢朋酒店', category: '舒心安居', merchantType: 'hotel', discount: '6折', address: '东区机场路10号', image: '/static/common/image-placeholder.png', sort: 20 },
        { id: 103, name: '攀枝花万达影城', category: '怡乐生活', merchantType: 'cinema', discount: '免费券', address: '东区攀枝花大道东段699号', image: '/static/common/image-placeholder.png', sort: 30 },
        { id: 104, name: '攀枝花中国三线建设博物馆', category: '悦览花城', merchantType: 'scenic', discount: '优惠票', address: '仁和区花城新区干坝塘村', image: '/static/common/image-placeholder.png', sort: 40 },
        { id: 105, name: '金府酒楼(西区店)', category: '暖心食享', merchantType: 'food', discount: '6折', address: '西区清香坪北街51号', image: '/static/common/image-placeholder.png', sort: 50 },
        { id: 106, name: '中国石油东区新福路加油站', category: '畅行无忧', merchantType: 'fuel', discount: '9.4折', address: '东区新福路20号附3-21号', image: '/static/common/image-placeholder.png', sort: 60 },
        { id: 107, name: '渡口公司管辖停车场', category: '畅行无忧', merchantType: 'parking', discount: '免费', address: '攀枝花市东区指定停车场', image: '/static/common/image-placeholder.png', sort: 70 },
        { id: 108, name: '攀商未来城', category: '惠享好物', merchantType: 'shopping', discount: '购房优惠3%', address: '东区榕树街55号', image: '/static/common/image-placeholder.png', sort: 80 },
        { id: 109, name: '攀枝花市中心医院', category: '臻享礼遇', merchantType: 'medical', discount: '免费体检', address: '东区攀枝花大道中段益康街34号', image: '/static/common/image-placeholder.png', sort: 90 },
        { id: 110, name: '人才金融服务中心', category: '臻享礼遇', merchantType: 'finance', discount: '专属金融方案', address: '东区人民街中心广场', image: '/static/common/image-placeholder.png', sort: 100 },
        { id: 111, name: '东区人才自助洗车服务点', category: '畅行无忧', merchantType: 'carWash', discount: '专属优惠', address: '东区机场路人才服务站', image: '/static/common/image-placeholder.png', sort: 110 },
        { id: 112, name: '攀枝花维也纳国际酒店', category: '舒心安居', merchantType: 'hotel', discount: '免费5天', address: '东区攀枝花大道中段', image: '/static/common/image-placeholder.png', sort: 120 },
        { id: 113, name: '百杏厨房(中环天地店)', category: '暖心食享', merchantType: 'food', discount: '8.8折', address: '东区炳三区新福路20号', image: '/static/common/image-placeholder.png', sort: 130 },
        { id: 114, name: '攀枝花大剧院', category: '怡乐生活', merchantType: 'cinema', discount: '演出优惠', address: '东区机场路文化艺术中心', image: '/static/common/image-placeholder.png', sort: 140 }
    ]
    const formatRemoteDiscount = (item: any): string => {
        const sale = item.salePrice != null ? Number(item.salePrice) : 0
        const original = item.originalPrice != null ? Number(item.originalPrice) : 0
        if (sale > 0 && original > 0 && sale < original) return '' + Math.round(sale / original * 100) / 10 + '折'
        return '人才专享'
    }
    const mapRemoteMerchants = (records: any[]): any[] => {
        const mapped: any[] = []
        for (let index = 0; index < records.length; index++) {
            const item: any = records[index]
            const rawType = item.merchantType != null ? item.merchantType as string : 'food'
            const hasConfiguredTags = item.benefitTags instanceof Array
            const configuredTags: any[] = hasConfiguredTags ? item.benefitTags as any[] : []
            const tags: string[] = []
            for (let tagIndex = 0; tagIndex < configuredTags.length; tagIndex++) {
                const tag = String(configuredTags[tagIndex]).trim()
                if (tag != '' && tags.indexOf(tag) < 0) tags.push(tag)
            }
            const discount = item.discountText != null && String(item.discountText).trim() != ''
                ? String(item.discountText).trim()
                : formatRemoteDiscount(item)
            if (!hasConfiguredTags && discount != '') tags.push(discount)
            mapped.push({
                id: '' + item.id,
                name: item.merchantName != null ? item.merchantName as string : '人才权益商户',
                category: item.categoryName != null ? item.categoryName as string : '',
                merchantType: rawType,
                discount: discount,
                tags: tags,
                address: item.address != null ? item.address as string : '',
                image: item.coverUrl != null && item.coverUrl != '' ? resolveApiUrl(item.coverUrl as string) : '',
                sort: item.sort != null ? Number(item.sort) : 0,
                detailType: item.detailType != null ? item.detailType as string : 'MERCHANT',
                detailBusinessId: item.detailBusinessId != null ? '' + item.detailBusinessId : '' + item.id,
                targetAction: item.targetAction != null ? item.targetAction as string : 'MERCHANT'
            })
        }
        return mapped
    }
    const loadRemoteMerchants = (append: boolean) => {
        const requestVersion = listRequestVersion.value + 1
        listRequestVersion.value = requestVersion
        if (append) {
            loadingMore.value = true
            loadMoreError.value = false
        } else {
            initialLoading.value = true
            initialError.value = false
            loadMoreError.value = false
            hasLoaded.value = false
            visibleMerchants.value = []
            currentPage.value = 0
        }
        const requestSize = isAllMode.value && !hasSearched.value ? allModePageSize : pageSize
        searchMerchants(keyword.value.trim(), '', isAllMode.value ? 'all' : 'search', talentId.value, currentPage.value + 1, requestSize, (data: any) => {
            if (requestVersion != listRequestVersion.value) return
            const records: any[] = data != null && data.records instanceof Array ? data.records as any[] : []
            const mapped = mapRemoteMerchants(records)
            const next = append ? visibleMerchants.value.slice() : []
            for (let index = 0; index < mapped.length; index++) next.push(mapped[index])
            visibleMerchants.value = next
            merchantPool.value = next
            loadedCount.value = next.length
            const responsePage = data != null && data.page != null ? Number(data.page) : currentPage.value + 1
            const total = data != null && data.total != null ? Number(data.total) : -1
            currentPage.value = responsePage > 0 ? responsePage : currentPage.value + 1
            hasMore.value = records.length > 0 && (total >= 0 ? next.length < total : records.length >= requestSize)
            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
        })
    }
 
    onLoad((options: any) => {
        // #ifdef MP-WEIXIN
        const capsule = uni.getMenuButtonBoundingClientRect()
        const systemInfo = uni.getSystemInfoSync()
        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'
        }
        loadHistories()
        talentId.value = resolveTalentId()
        if (options != null && options['mode'] == 'all') {
            isAllMode.value = true
            if (isApiConfigured()) loadRemoteMerchants(false)
            else if (apiEnvironment == 'development') resetMerchantList(allMerchants)
        }
    })
 
    const goBack = () => safeBack('/pages/benefits/benefits')
    const clearKeyword = () => {
        keyword.value = ''
        hasSearched.value = false
        if (isAllMode.value) {
            if (isApiConfigured()) loadRemoteMerchants(false)
            else if (apiEnvironment == 'development') resetMerchantList(allMerchants)
            else { initialError.value = true; hasLoaded.value = false }
        }
    }
    const loadHistories = () => {
        const stored: any = uni.getStorageSync(historyStorageKey)
        if (!(stored instanceof Array)) return
        const next: string[] = []
        for (let index = 0; index < stored.length && next.length < 10; index++) {
            const item = stored[index]
            if (typeof item == 'string' && item.length > 0 && next.indexOf(item) < 0) next.push(item)
        }
        histories.value = next
    }
    const saveHistories = () => uni.setStorageSync(historyStorageKey, histories.value)
    const resetMerchantList = (source: any[]) => {
        const requestVersion = listRequestVersion.value + 1
        listRequestVersion.value = requestVersion
        initialLoading.value = true
        initialError.value = false
        loadMoreError.value = false
        hasLoaded.value = false
        loadingMore.value = false
        merchantPool.value = source
        const firstPage = []
        const end = Math.min(pageSize, source.length)
        let index = 0
        while (index < end) {
            firstPage.push(source[index])
            index++
        }
        setTimeout(() => {
            if (requestVersion != listRequestVersion.value) return
            if (failInitialOnce.value) {
                failInitialOnce.value = false
                initialLoading.value = false
                initialError.value = true
                return
            }
            visibleMerchants.value = firstPage
            loadedCount.value = end
            hasMore.value = end < source.length
            initialLoading.value = false
            hasLoaded.value = true
        }, 400)
    }
    const retryInitialLoad = () => {
        if (isApiConfigured()) loadRemoteMerchants(false)
        else if (apiEnvironment == 'development') resetMerchantList(merchantPool.value)
        else { initialError.value = true; hasLoaded.value = false }
    }
    const retryLoadMore = () => loadMoreMerchants()
    const performSearch = () => {
        const value = keyword.value.trim()
        if (value.length == 0) {
            uni.showToast({ title: '请输入搜索内容', icon: 'none' })
            return
        }
        const next: string[] = []
        next.push(value)
        histories.value.forEach((item: string) => {
            if (item != value && next.length < 10) next.push(item)
        })
        histories.value = next
        saveHistories()
        if (isApiConfigured()) {
            hasSearched.value = true
            loadRemoteMerchants(false)
            return
        }
        if (apiEnvironment != 'development') { hasSearched.value = true; initialError.value = true; hasLoaded.value = false; return }
        const matches = []
        let index = 0
        while (index < allMerchants.length) {
            const item = allMerchants[index]
            if (item.name.indexOf(value) >= 0 || item.address.indexOf(value) >= 0 || item.category.indexOf(value) >= 0 || item.discount.indexOf(value) >= 0) matches.push(item)
            index++
        }
        resetMerchantList(matches)
        hasSearched.value = true
    }
    const searchHistory = (value: string) => {
        keyword.value = value
        performSearch()
    }
    const openMerchant = (item: any) => openMerchantDetail(item)
    const loadMoreMerchants = () => {
        if (initialLoading.value || loadingMore.value || !hasMore.value) return
        const requestVersion = listRequestVersion.value
        loadMoreError.value = false
        loadingMore.value = true
        if (isApiConfigured()) {
            loadRemoteMerchants(true)
            return
        }
        if (apiEnvironment != 'development') { loadingMore.value = false; loadMoreError.value = true; return }
        setTimeout(() => {
            if (requestVersion != listRequestVersion.value) return
            if (failLoadMoreOnce.value) {
                failLoadMoreOnce.value = false
                loadingMore.value = false
                loadMoreError.value = true
                return
            }
            const next = visibleMerchants.value.slice()
            const end = Math.min(loadedCount.value + pageSize, merchantPool.value.length)
            let index = loadedCount.value
            while (index < end) {
                next.push(merchantPool.value[index])
                index++
            }
            visibleMerchants.value = next
            loadedCount.value = end
            hasMore.value = end < merchantPool.value.length
            loadingMore.value = false
        }, 500)
    }
    const clearHistory = () => {
        if (histories.value.length == 0) return
        uni.showModal({
            title: '清空搜索历史',
            content: '确定清空全部搜索记录吗?',
            confirmColor: '#FF742A',
            success: (result) => {
                if (result.confirm) {
                    histories.value = []
                    uni.removeStorageSync(historyStorageKey)
                }
            }
        })
    }
</script>
 
<style>
    .page { display: flex; width: 100%; height: 100vh; overflow: hidden; background-color: #FFFFFF; flex-direction: column; }
    .nav-shell { flex-shrink: 0; padding-bottom: 26rpx; background-image: linear-gradient(180deg, #FFF0B8 0%, #FFF7DC 62%, #FFFFFF 100%); }
    .page-scroll { min-height: 0; flex: 1; background-color: #FFFFFF; }
    .safe-space { height: 28px; }
    .nav-row { display: flex; height: 32px; box-sizing: border-box; padding-left: 26rpx; padding-right: 10px; flex-direction: row; align-items: center; justify-content: space-between; }
    .back-button { display: flex; width: 64rpx; height: 64rpx; justify-content: center; align-items: center; }
    .back-button image { width: 38rpx; height: 38rpx; }
    .capsule-space { width: 88px; height: 100%; flex-shrink: 0; }
    .search-wrap { display: flex; height: 68rpx; margin: 28rpx 32rpx 0; box-sizing: border-box; padding: 3rpx; overflow: hidden; border-width: 2rpx; border-style: solid; border-color: #FF9B69; border-radius: 35rpx; background-color: #FFFFFF; flex-direction: row; align-items: center; }
    .search-field { display: flex; height: 60rpx; min-width: 0; flex: 1; padding-left: 24rpx; flex-direction: row; align-items: center; }
    .search-icon { width: 27rpx; height: 27rpx; margin-right: 14rpx; flex-shrink: 0; }
    .search-input { height: 60rpx; min-width: 0; flex: 1; color: #333333; font-size: 23rpx; }
    .search-placeholder { color: #A7A7A7; font-size: 23rpx; }
    .clear-keyword { display: flex; width: 48rpx; height: 56rpx; justify-content: center; align-items: center; }
    .clear-keyword text { color: #A7A7A7; font-size: 32rpx; }
    .search-button { display: flex; width: 124rpx; height: 58rpx; border-radius: 30rpx; background-image: linear-gradient(90deg, #FF9A32 0%, #FF6A20 100%); justify-content: center; align-items: center; flex-shrink: 0; }
    .search-button text { color: #FFFFFF; font-size: 24rpx; }
    .history-section { padding: 10rpx 32rpx 0; }
    .history-head { display: flex; height: 70rpx; flex-direction: row; align-items: center; justify-content: space-between; }
    .history-head > text { flex: 1; color: #555C69; font-size: 27rpx; font-weight: 500; }
    .delete-button { display: flex; width: 58rpx; height: 58rpx; flex-shrink: 0; justify-content: center; align-items: center; }
    .delete-button image { width: 29rpx; height: 29rpx; }
    .history-list { display: flex; flex-direction: row; flex-wrap: wrap; align-items: flex-start; }
    .history-chip { display: flex; width: auto; height: 48rpx; margin: 0 18rpx 16rpx 0; padding: 0 22rpx; border-radius: 25rpx; background-color: #F4F4F4; flex-shrink: 0; justify-content: center; align-items: center; }
    .history-chip text { max-width: 210rpx; color: #333333; font-size: 21rpx; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
    .empty-history { display: flex; height: 150rpx; justify-content: center; align-items: center; }
    .empty-history text { color: #B0B0B0; font-size: 23rpx; }
    .list-heading { display: flex; height: 80rpx; box-sizing: border-box; padding: 13rpx 32rpx 0; flex-direction: row; align-items: center; justify-content: space-between; }
    .list-title { color: #333943; font-size: 29rpx; line-height: 42rpx; font-weight: 700; }
    .sort-note { color: #A0A6AF; font-size: 21rpx; line-height: 32rpx; }
    .result-list { padding: 0 31rpx; }
    .result-item { display: flex; height: 178rpx; margin-bottom: 18rpx; flex-direction: row; align-items: center; }
    .result-image { width: 138rpx; height: 138rpx; border-radius: 15rpx; flex-shrink: 0; background-color: #EEEEEE; }
    .result-info { display: flex; height: 138rpx; min-width: 0; flex: 1; box-sizing: border-box; padding: 1rpx 0 1rpx 22rpx; flex-direction: column; justify-content: space-between; }
    .result-name { color: #292929; font-size: 26rpx; line-height: 36rpx; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
    .result-tags { display: flex; flex-direction: row; align-items: center; }
    .result-tags text { margin-right: 10rpx; padding: 5rpx 8rpx; border-radius: 5rpx; font-size: 19rpx; white-space: nowrap; }
    .coupon-tag { background-color: #FFF0EC; color: #FF5D37; }
    .benefit-tag { max-width: 220rpx; overflow: hidden; text-overflow: ellipsis; background-color: #FFF5E7; color: #FF7C2B; }
    .result-address { display: flex; min-width: 0; flex-direction: row; align-items: center; }
    .result-address image { width: 25rpx; height: 25rpx; margin-right: 7rpx; flex-shrink: 0; }
    .result-address text { min-width: 0; flex: 1; color: #7E8490; font-size: 19rpx; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
    .result-empty { display: flex; height: 430rpx; justify-content: center; align-items: center; }
    .result-empty text { color: #A0A4AA; font-size: 26rpx; }
    .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; }
    .load-more-action { display: flex; height: 90rpx; justify-content: center; align-items: center; }
    .load-more-action text { color: #F26A38; font-size: 23rpx; }
    .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; }
    .list-bottom { height: 30rpx; height: calc(30rpx + env(safe-area-inset-bottom)); }
    @keyframes merchant-loading { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
</style>