<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>
|