Files
medical-mall/pages/mall/consumer/search.uvue
2026-05-14 15:28:09 +08:00

1935 lines
48 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="search-page">
<view class="search-navbar" :style="navbarStyle">
<view class="back-btn" @click="goBack">
<text class="back-icon"></text>
</view>
<view class="jd-search-box">
<input
class="jd-search-input"
type="text"
:value="searchKeyword"
@input="onInput"
@confirm="onSearch"
:placeholder="(placeholderKeyword != null && placeholderKeyword !== '' && searchKeyword === '') ? placeholderKeyword : '请输入商品名称、店铺'"
placeholder-class="jd-placeholder"
:focus="autoFocus"
confirm-type="search"
/>
<view v-if="searchKeyword != '' && searchKeyword != null" class="clear-wrap" @click.stop="clearSearch">
<text class="clear-icon">×</text>
</view>
<view class="camera-wrap" @tap.stop="handleCameraSearch">
<text class="camera-icon">📷</text>
</view>
<view class="search-submit-btn" @tap.stop="onSearch">
<text class="search-submit-text">搜索</text>
</view>
</view>
</view>
<!-- 错误状态(模拟服务器超时) -->
<view v-if="isError" class="error-state" @click="retryLoad">
<view class="error-content">
<text class="error-icon">⚠️</text>
<text class="error-title">加载服务器超时</text>
<text class="error-desc">请点击屏幕重试</text>
</view>
</view>
<!-- 主内容区域:使用 scroll-view 支持安卓端滚动 -->
<scroll-view
v-else
class="main-content"
direction="vertical"
:show-scrollbar="false"
>
<!-- 初始状态(无搜索词) -->
<view v-if="searchKeyword == '' && showResults == false">
<!-- 搜索历史 -->
<view v-if="searchHistory.length > 0" class="search-history">
<view class="section-header">
<text class="section-title">搜索历史</text>
<view class="header-right">
<view v-if="!isEditMode" @click="toggleHistoryEdit">
<text class="edit-text">编辑</text>
</view>
<view v-else style="display:flex;flex-direction:row;align-items:center;">
<text class="all-clear" @click="clearHistory" style="margin-right:12px;color:#ff4d1a">全部删除</text>
<text class="done" @click="toggleHistoryEdit">完成</text>
</view>
</view>
</view>
<view class="history-tags">
<view
v-for="(item, index) in visibleHistory"
:key="index"
class="history-tag"
@click="handleHistoryTagClick(item)"
>
<text class="history-text">{{ item }}</text>
<view v-if="isEditMode" class="delete-tag-btn" @click.stop="deleteHistoryItemByKeyword(item)">
<text class="delete-icon">×</text>
</view>
</view>
</view>
<view v-if="searchHistory.length > MAX_COLLAPSED_COUNT" style="padding:8px 4px;">
<text class="more-toggle" @click="toggleHistoryExpanded">{{ historyExpanded ? '收起' : '更多 v' }}</text>
</view>
</view>
<!-- 热门搜索 -->
<view class="hot-search">
<view class="section-header">
<text class="section-title">热门搜索</text>
</view>
<view class="hot-tags">
<view
v-for="(item, index) in hotSearchList"
:key="index"
class="hot-tag"
:class="item.hot == true ? 'hot' : ''"
@click="searchFromHot(item.keyword)"
>
<text class="hot-rank" :class="index < 3 ? 'top-three' : ''">{{ index + 1 }}</text>
<text class="hot-text">{{ item.keyword }}</text>
<text v-if="item.hot == true" class="hot-icon">🔥</text>
</view>
</view>
</view>
<!-- 推荐商品(猜你喜欢) -->
<view class="guess-you-like">
<view class="section-header">
<view class="title-with-icon">
<text class="section-icon">✨</text>
<text class="section-title">猜你喜欢</text>
</view>
<text class="refresh-btn" @click="refreshGuessList">换一批</text>
</view>
<view class="guess-grid">
<view
v-for="item in recommendList"
:key="(item.id != null ? item.id : item.name)"
class="guess-item"
@click="viewProductDetail(item)"
>
<image class="guess-img" :src="item.image" mode="aspectFill" />
<view v-if="item.cardTags.length > 0" class="card-tags-row">
<text v-for="(tag, index) in item.cardTags" :key="item.id + '-guess-tag-' + index" class="card-tag">{{ tag }}</text>
</view>
<text class="guess-name" :lines="2">{{ item.name }}</text>
<text v-if="item.highlight !== ''" class="card-highlight">{{ item.highlight }}</text>
<view v-if="item.serviceTags.length > 0" class="service-tags-row">
<text v-for="(tag, index) in item.serviceTags" :key="item.id + '-guess-service-' + index" class="service-tag">{{ tag }}</text>
</view>
<view class="guess-bottom">
<view class="price-stack">
<text class="guess-price">¥{{ item.price }}</text>
<text v-if="item.marketPrice !== ''" class="market-price">¥{{ item.marketPrice }}</text>
</view>
<view class="guess-add-btn" @click.stop="addToCart(item)">
<text class="guess-add-icon">+</text>
</view>
</view>
<text v-if="item.salesText !== ''" class="card-sales-text">{{ item.salesText }}</text>
</view>
</view>
</view>
</view>
<!-- 搜索建议 -->
<view v-if="searchKeyword != '' && showResults == false" class="search-suggestions">
<view class="suggestions-list">
<view
v-for="(suggestion, index) in searchSuggestions"
:key="index"
class="suggestion-item"
@click="selectSuggestion(suggestion)"
>
<view class="suggestion-icon">🔍</view>
<text class="suggestion-text">{{ suggestion }}</text>
</view>
</view>
</view>
<!-- 搜索结果 -->
<view v-if="showResults" class="search-results">
<!-- 店铺搜索结果 -->
<view v-if="searchShopResults.length > 0" class="shop-results-section">
<view class="section-top">
<text class="result-title-sm">相关店铺</text>
</view>
<scroll-view direction="horizontal" class="shop-list-scroll">
<view class="shop-list-row">
<view
v-for="shop in searchShopResults"
:key="shop.id"
class="shop-card"
@click="viewShopDetail(shop)"
>
<image class="shop-logo" :src="shop.logo" mode="aspectFill" />
<view class="shop-info">
<text class="shop-name-txt">{{ shop.name }}</text>
<text class="shop-products-txt">共{{ shop.productCount }}件商品</text>
</view>
</view>
</view>
</scroll-view>
</view>
<view class="results-header">
<text class="results-title">商品结果</text>
<view class="filter-tabs">
<text
class="filter-tab"
:class="{ active: activeSort === 'default' }"
@click="switchSort('default')"
>综合</text>
<text
class="filter-tab"
:class="{ active: activeSort === 'sales' }"
@click="switchSort('sales')"
>销量</text>
<text
class="filter-tab"
:class="{ active: activeSort === 'price' }"
@click="switchSort('price')"
>
价格 {{ activeSort === 'price' ? (priceSortAsc ? '↑' : '↓') : '' }}
</text>
</view>
</view>
<view v-if="searchResults.length > 0" class="results-list">
<view
v-for="product in searchResults"
:key="product.id"
class="result-item"
@click="viewProductDetail(product)"
>
<image class="product-image" :src="product.image" mode="aspectFill" />
<view v-if="product.cardTags.length > 0" class="card-tags-row">
<text v-for="(tag, index) in product.cardTags" :key="product.id + '-result-tag-' + index" class="card-tag">{{ tag }}</text>
</view>
<text class="product-name" :lines="2">{{ product.name }}</text>
<text v-if="product.highlight !== ''" class="card-highlight">{{ product.highlight }}</text>
<view v-if="product.serviceTags.length > 0" class="service-tags-row">
<text v-for="(tag, index) in product.serviceTags" :key="product.id + '-result-service-' + index" class="service-tag">{{ tag }}</text>
</view>
<view class="product-bottom">
<view class="price-stack">
<text class="product-price">¥{{ product.price }}</text>
<text v-if="product.marketPrice !== ''" class="market-price">¥{{ product.marketPrice }}</text>
</view>
<view class="product-add-btn" @click.stop="addToCart(product)">
<text class="add-icon">+</text>
</view>
</view>
<text v-if="product.salesText !== ''" class="card-sales-text">{{ product.salesText }}</text>
</view>
</view>
<!-- 空结果 - 仅在非加载状态且无结果时显示 -->
<view v-if="!loading && searchResults.length === 0" class="empty-result">
<text class="empty-icon">🤔</text>
<text class="empty-text">未找到相关商品</text>
<text class="empty-sub">换个关键词试试吧</text>
</view>
<!-- 加载更多/加载中 - 在加载状态或有更多数据时显示 -->
<view v-if="loading" class="loading-more">
<view class="loading-spinner"></view>
<text class="loading-text">加载中...</text>
</view>
<view v-if="!hasMore && searchResults.length > 0" class="no-more">
<text class="no-more-text">--- 到底了 ---</text>
</view>
<view v-if="searchResults.length > 0 && recommendList.length > 0" class="guess-you-like" style="margin-top: 16rpx;">
<view class="section-header">
<view class="title-with-icon">
<text class="section-icon">✨</text>
<text class="section-title">猜你喜欢</text>
</view>
</view>
<view class="guess-grid">
<view
v-for="item in recommendList"
:key="(item.id != null ? item.id : '') + '_rec'"
class="guess-item"
@click="viewProductDetail(item)"
>
<image class="guess-img" :src="item.image" mode="aspectFill" />
<view v-if="item.cardTags.length > 0" class="card-tags-row">
<text v-for="(tag, index) in item.cardTags" :key="item.id + '-rec-tag-' + index" class="card-tag">{{ tag }}</text>
</view>
<text class="guess-name" :lines="2">{{ item.name }}</text>
<text v-if="item.highlight !== ''" class="card-highlight">{{ item.highlight }}</text>
<view v-if="item.serviceTags.length > 0" class="service-tags-row">
<text v-for="(tag, index) in item.serviceTags" :key="item.id + '-rec-service-' + index" class="service-tag">{{ tag }}</text>
</view>
<view class="guess-bottom">
<view class="price-stack">
<text class="guess-price">¥{{ item.price }}</text>
<text v-if="item.marketPrice !== ''" class="market-price">¥{{ item.marketPrice }}</text>
</view>
<view class="guess-add-btn" @click.stop="addToCart(item)">
<text class="guess-add-icon">+</text>
</view>
</view>
<text v-if="item.salesText !== ''" class="card-sales-text">{{ item.salesText }}</text>
</view>
</view>
</view>
</view>
<!-- 底部安全区域 -->
<view class="safe-area"></view>
</scroll-view>
</view>
</template>
<script setup lang="uts">
import { ref, reactive, onMounted, computed } from 'vue'
import { onPageScroll, onReachBottom } from '@dcloudio/uni-app'
import { supabaseService } from '@/utils/supabaseService.uts'
import type { Product } from '@/utils/supabaseService.uts'
import { goToLogin } from '@/utils/utils.uts'
// 状态定义
const statusBarHeight = ref(0)
const scrollHeight = ref(0)
const searchKeyword = ref('')
const placeholderKeyword = ref('')
const navRightReserve = ref(0)
const navBarTop = ref(0)
const navBarHeight = ref(44)
const showResults = ref(false)
const loading = ref(false)
const hasMore = ref(true)
const isError = ref(false)
const autoFocus = ref(true)
const navbarStyle = computed(() => {
const top = navBarTop.value > 0 ? navBarTop.value : statusBarHeight.value
const h = navBarHeight.value > 0 ? navBarHeight.value : 44
const pr = navRightReserve.value > 0 ? navRightReserve.value : 12
return `padding-top:${top}px;height:${top + h + 8}px;padding-right:${pr}px;padding-left:12px;`
})
const handleCameraSearch = () => {
uni.showToast({ title: '相机搜索暂未开放', icon: 'none' })
}
const activeSort = ref('default') // 当前排序方式: default, sales, price
const priceSortAsc = ref(false) // 价格排序是否为升序
type HotSearchItemType = {
keyword: string
hot: boolean
}
type GuessItemType = {
id: string
name: string
price: number
marketPrice: string
image: string
sales: number
salesText: string
highlight: string
cardTags: string[]
serviceTags: string[]
merchant_id: string
}
type SearchResultType = {
id: string
name: string
image: string
price: number
marketPrice: string
specification: string
tag: string
sales: number
salesText: string
highlight: string
cardTags: string[]
serviceTags: string[]
merchant_id: string
}
const getCardTitle = (product: Product): string => {
if (product.short_title != null && product.short_title !== '') return product.short_title
if (product.name != null && product.name !== '') return product.name
return product.id ?? ''
}
const getCardHighlight = (product: Product): string => {
if (product.selling_points != null && product.selling_points.length > 0 && product.selling_points[0] !== '') {
return product.selling_points[0]
}
if (product.subtitle != null && product.subtitle !== '') return product.subtitle
return ''
}
const getCardTags = (product: Product): string[] => {
if (product.card_tags != null && product.card_tags.length > 0) return product.card_tags.slice(0, 2)
const tags: string[] = []
if (product.is_hot === true) tags.push('热卖')
if (product.is_new === true && tags.length < 2) tags.push('新品')
if (product.is_featured === true && tags.length < 2) tags.push('精选')
return tags
}
const getServiceTags = (product: Product): string[] => {
if (product.service_tags != null && product.service_tags.length > 0) return product.service_tags.slice(0, 3)
return [] as string[]
}
const formatSalesText = (product: Product): string => {
if (product.display_sales_text != null && product.display_sales_text !== '') return product.display_sales_text
const sales = product.sale_count ?? 0
if (sales >= 100000) return '已售10万+'
if (sales >= 10000) return '已售' + (sales / 10000).toFixed(1) + '万件'
if (sales > 0) return '已售' + sales.toString() + '件'
return ''
}
const formatMarketPriceText = (product: Product): string => {
const marketPrice = product.market_price ?? product.original_price ?? 0
const salePrice = product.base_price ?? product.price ?? 0
if (marketPrice > salePrice && marketPrice > 0) return parseFloat(marketPrice.toString()).toFixed(2)
return ''
}
type ShopResultType = {
id: string
name: string
logo: string
productCount: number
}
const getCurrentPageOptions = (): UTSJSONObject => {
const pages = getCurrentPages()
if (pages.length == 0) {
return new UTSJSONObject()
}
const currentPage = pages[pages.length - 1]
const pageOptions = currentPage.options
if (pageOptions == null) {
return new UTSJSONObject()
}
return pageOptions as UTSJSONObject
}
const searchHistory = ref<Array<string>>([])
const hotSearchList = ref<Array<HotSearchItemType>>([])
const guessList = ref<Array<GuessItemType>>([])
const allGuessItems = ref<Array<GuessItemType>>([])
const DEFAULT_HOT_KEYWORDS: Array<string> = [
'大疆neo2',
'iPhone 15 Pro',
'Nike Air Max 270',
'厨具',
'老干妈',
'钢化膜',
'手机壳',
'零食坚果',
'新鲜水果',
'液态硅胶壳',
'充电宝',
'蓝牙耳机'
]
// 推荐商品区(用于 “猜你喜欢/推荐商品”)
const recommendList = ref<Array<GuessItemType>>([])
const recommendPool = ref<Array<GuessItemType>>([])
const recommendPage = ref(0)
const recommendInitialSize = ref(8)
const recommendAppendSize = ref(20)
const loadingRecommend = ref(false)
const searchResults = ref<Array<SearchResultType>>([])
const searchShopResults = ref<Array<ShopResultType>>([])
const SEARCH_HISTORY_KEY = 'consumer_search_history'
const loadSearchHistory = () => {
try {
const historyRaw = uni.getStorageSync(SEARCH_HISTORY_KEY)
if (historyRaw != null && historyRaw !== '') {
const parsed = JSON.parse(historyRaw as string)
if (Array.isArray(parsed)) searchHistory.value = parsed as string[]
}
} catch (e) {
searchHistory.value = []
}
}
const saveSearchHistory = () => {
try {
uni.setStorageSync(SEARCH_HISTORY_KEY, JSON.stringify(searchHistory.value))
} catch (e) {
// ignore
}
}
const addToHistory = (keyword: string) => {
try {
if (keyword == null) return
const kw = keyword.trim()
if (kw === '') return
const index = searchHistory.value.indexOf(kw)
if (index > -1) searchHistory.value.splice(index, 1)
searchHistory.value.unshift(kw)
if (searchHistory.value.length > 50) searchHistory.value.length = 50
saveSearchHistory()
} catch (e) {
// ignore
}
}
const isEditMode = ref(false)
const historyExpanded = ref(false)
const MAX_COLLAPSED_COUNT = 8
const MAX_EXPANDED_COUNT = 24
const clearHistory = () => {
uni.showModal({
title: '提示',
content: '确定清空搜索历史吗?',
success: (res) => {
if (res.confirm) {
searchHistory.value = []
try { uni.removeStorageSync(SEARCH_HISTORY_KEY) } catch (e) {}
}
}
})
}
const deleteHistoryItem = (index: number) => {
if (index >= 0 && index < searchHistory.value.length) {
searchHistory.value.splice(index, 1)
saveSearchHistory()
}
}
const toggleHistoryEdit = () => { isEditMode.value = !isEditMode.value }
const toggleHistoryExpanded = () => { historyExpanded.value = !historyExpanded.value }
import { computed } from 'vue'
const visibleHistory = computed(() => {
if (historyExpanded.value) {
const maxLen = searchHistory.value.length < MAX_EXPANDED_COUNT ? searchHistory.value.length : MAX_EXPANDED_COUNT
return searchHistory.value.slice(0, maxLen)
}
return searchHistory.value.slice(0, MAX_COLLAPSED_COUNT)
})
const deleteHistoryItemByKeyword = (keyword: string) => {
try {
const idx = searchHistory.value.indexOf(keyword)
if (idx > -1) {
searchHistory.value.splice(idx, 1)
saveSearchHistory()
}
} catch (e) {}
}
const refreshGuessListItems = () => {
if (allGuessItems.value.length > 0) {
const arr: Array<GuessItemType> = []
for (let i: number = 0; i < allGuessItems.value.length; i++) {
arr.push(allGuessItems.value[i])
}
for (let i: number = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
const temp = arr[i]
arr[i] = arr[j]
arr[j] = temp
}
const result: Array<GuessItemType> = []
const limit = arr.length < 6 ? arr.length : 6
for (let i: number = 0; i < limit; i++) {
result.push(arr[i])
}
guessList.value = result
}
}
const loadData = async (): Promise<void> => {
isError.value = false
try {
loadSearchHistory()
let hotProducts: Product[] = []
try {
const hotResult = await supabaseService.getHotProducts(30)
hotProducts = hotResult as Product[]
} catch (hotError) {
console.error('获取热销商品失败,使用空列表:', hotError)
hotProducts = []
}
const hotList: Array<HotSearchItemType> = []
const limit1 = hotProducts.length < 12 ? hotProducts.length : 12
for (let i: number = 0; i < limit1; i++) {
const p = hotProducts[i]
hotList.push({ keyword: p.name ?? '', hot: i < 3 })
}
if (hotList.length < 12) {
for (let i: number = 0; i < DEFAULT_HOT_KEYWORDS.length; i++) {
let found = false
for (let j: number = 0; j < hotList.length; j++) {
if (hotList[j].keyword === DEFAULT_HOT_KEYWORDS[i]) {
found = true
break
}
}
if (found === false) {
hotList.push({ keyword: DEFAULT_HOT_KEYWORDS[i], hot: i < 3 })
}
if (hotList.length >= 12) break
}
}
if (searchHistory.value.length > 0) {
const historyFirst: Array<HotSearchItemType> = []
const histSlice = searchHistory.value.slice(0, 5)
for (let i: number = 0; i < histSlice.length; i++) {
historyFirst.push({ keyword: histSlice[i], hot: false })
}
const merged = historyFirst.concat(hotList)
const seen = new Set<string>()
const dedup: Array<HotSearchItemType> = []
for (let i: number = 0; i < merged.length; i++) {
const it = merged[i]
if (seen.has(it.keyword) === false && it.keyword != null && it.keyword !== '') {
dedup.push(it)
seen.add(it.keyword)
}
if (dedup.length >= 12) break
}
hotSearchList.value = dedup
} else {
hotSearchList.value = hotList
}
const pool: Array<GuessItemType> = []
for (let i: number = 0; i < hotProducts.length; i++) {
const p = hotProducts[i]
pool.push({
id: p.id ?? '',
name: getCardTitle(p),
price: p.base_price ?? 0,
marketPrice: formatMarketPriceText(p),
image: p.main_image_url ?? '/static/default.jpg',
sales: p.sale_count ?? 0,
salesText: formatSalesText(p),
highlight: getCardHighlight(p),
cardTags: getCardTags(p),
serviceTags: getServiceTags(p),
merchant_id: p.merchant_id ?? ''
})
}
recommendPool.value = pool
recommendList.value = recommendPool.value.slice(0, recommendInitialSize.value)
recommendPage.value = 1
allGuessItems.value = pool
refreshGuessListItems()
} catch (e) {
console.error('Load data failed', e)
isError.value = false
}
}
const retryLoad = () => {
uni.showLoading({ title: '重新加载中' })
setTimeout(() => {
uni.hideLoading()
loadData()
}, 1000)
}
const searchSuggestions = ref<Array<string>>([])
let suggestTimer: number = 0
const fetchSuggestions = async (kw: string): Promise<void> => {
if (kw == '' || showResults.value) return
try {
const res = await supabaseService.searchProducts(kw.trim(), 1, 5)
if (res.data != null && res.data.length > 0) {
const names: Array<string> = []
for (let i: number = 0; i < res.data.length; i++) {
const p = res.data[i]
let name = ''
if (p instanceof UTSJSONObject) {
name = p.getString('name') ?? ''
} else {
const pObj = p as UTSJSONObject
name = pObj.getString('name') ?? ''
}
let found = false
for (let j: number = 0; j < names.length; j++) {
if (names[j] === name) {
found = true
break
}
}
if (found === false && name !== '') {
names.push(name)
}
}
searchSuggestions.value = names
} else {
searchSuggestions.value = []
}
} catch(e) {
searchSuggestions.value = []
}
}
const currentPage = ref<number>(1)
const performSearch = async (): Promise<void> => {
showResults.value = true
loading.value = true
currentPage.value = 1
const keyword = searchKeyword.value.trim()
if (keyword == '') {
loading.value = false
return
}
console.log('Search execution started for keyword:', keyword)
let sortBy = 'sales'
let ascending = false
if (activeSort.value === 'price') {
sortBy = 'price'
ascending = priceSortAsc.value
} else if (activeSort.value === 'default') {
sortBy = 'default'
}
try {
console.log('Calling searchProducts with params:', keyword, currentPage.value, sortBy, ascending)
const prodResp = await supabaseService.searchProducts(keyword, currentPage.value, 20, sortBy, ascending)
console.log('searchProducts response received:', prodResp.data != null ? prodResp.data.length : 0, 'items')
let shopList: Array<ShopResultType> = []
if (currentPage.value === 1 && activeSort.value === 'default') {
const shopResp = await supabaseService.searchShops(keyword)
if (shopResp.data != null && shopResp.data.length > 0) {
for (let i: number = 0; i < shopResp.data.length; i++) {
const s = shopResp.data[i]
const shopItem: ShopResultType = {
id: s.id ?? '',
name: s.shop_name ?? '',
logo: s.shop_logo ?? '/static/shop_logo_default.png',
productCount: s.product_count ?? 0
}
shopList.push(shopItem)
}
}
}
searchShopResults.value = shopList
const prodData = prodResp.data != null ? prodResp.data : []
const resultList: Array<SearchResultType> = []
for (let i: number = 0; i < prodData.length; i++) {
const p = prodData[i] as Product
let tag = ''
const tagsRaw = p.tags
if (tagsRaw != null) {
try {
const tagsStr = p.tags
if (tagsStr != null) {
const tags = JSON.parse(tagsStr as string)
if (Array.isArray(tags) && tags.length > 0) {
const firstTag = tags[0]
tag = firstTag != null ? (firstTag as string) : ''
}
}
} catch(e) {}
}
const searchItem: SearchResultType = {
id: p.id ?? '',
name: getCardTitle(p),
image: p.main_image_url ?? '/static/default.jpg',
price: p.base_price ?? 0,
marketPrice: formatMarketPriceText(p),
specification: p.specification ?? '标准规格',
tag: tag,
sales: p.sale_count ?? 0,
salesText: formatSalesText(p),
highlight: getCardHighlight(p),
cardTags: getCardTags(p),
serviceTags: getServiceTags(p),
merchant_id: p.merchant_id ?? ''
}
resultList.push(searchItem)
}
searchResults.value = resultList
hasMore.value = prodResp.hasmore
} catch(e) {
console.error('Search failed detailed error:', e)
} finally {
loading.value = false
}
}
const initPage = () => {
try {
const systemInfo = uni.getSystemInfoSync()
statusBarHeight.value = systemInfo.statusBarHeight ?? 0
const windowHeight = systemInfo.windowHeight
scrollHeight.value = windowHeight - (60 + statusBarHeight.value)
navBarTop.value = statusBarHeight.value
navBarHeight.value = 44
navRightReserve.value = 0
// #ifdef MP-WEIXIN
try {
const menuBtn = uni.getMenuButtonBoundingClientRect()
if (menuBtn != null && menuBtn.top != null && menuBtn.top > 0) {
navBarTop.value = menuBtn.top
navBarHeight.value = menuBtn.height
navRightReserve.value = (systemInfo.windowWidth - menuBtn.left) + 8
}
} catch (e) {
navBarTop.value = statusBarHeight.value
navBarHeight.value = 44
navRightReserve.value = 0
}
// #endif
loadData()
loadSearchHistory()
const options = getCurrentPageOptions()
const kwRaw = options.getString('keyword')
const src = options.getString('source')
if (kwRaw != null && kwRaw !== '') {
const decoded = decodeURIComponent(kwRaw)
const keyword = decoded != null ? decoded : kwRaw
if (src === 'placeholder') {
placeholderKeyword.value = keyword
searchKeyword.value = ''
} else {
searchKeyword.value = keyword
}
const typeVal = options.getString('type')
if (typeVal === 'family' || typeVal === 'brand') {
if (typeVal === 'family') {
addToHistory(keyword)
}
showResults.value = true
loading.value = true
performSearch()
}
}
} catch (e) {
console.error('初始化失败', e)
isError.value = true
}
}
onMounted(() => {
initPage()
})
const onInput = (e: UniInputEvent) => {
try {
const val = e.detail.value as string
searchKeyword.value = val
if (val == '') {
showResults.value = false
searchSuggestions.value = []
return
}
if (suggestTimer > 0) clearTimeout(suggestTimer)
suggestTimer = setTimeout(() => {
fetchSuggestions(val)
}, 300)
} catch (err) {
console.error('onInput error:', err)
}
}
const clearSearch = () => {
searchKeyword.value = ''
showResults.value = false
}
const onSearch = () => {
const userInput = searchKeyword.value.trim()
let effective = ''
if (userInput !== '') {
effective = userInput
} else if (placeholderKeyword.value != null && placeholderKeyword.value !== '') {
effective = placeholderKeyword.value
}
if (effective === '') return
addToHistory(effective)
// 如果搜索词来自 placeholder确保输入框仍为空但执行搜索
if (userInput === '') {
// 保持 searchKeyword 为空 but perform search with effective
searchKeyword.value = ''
}
// 将 searchKeyword 临时设置为 effective 以便 performSearch 使用performSearch 使用 searchKeyword
const prev = searchKeyword.value
searchKeyword.value = effective
performSearch()
// 恢复输入框为空状态(如果用户未输入)
if (userInput === '') searchKeyword.value = prev
}
const searchFromHistory = (keyword: string) => {
searchKeyword.value = keyword
performSearch()
}
const searchFromHot = (keyword: string) => {
searchKeyword.value = keyword
addToHistory(keyword)
performSearch()
}
const selectSuggestion = (suggestion: string) => {
searchKeyword.value = suggestion
addToHistory(suggestion)
performSearch()
}
const handleHistoryTagClick = (keyword: string): void => {
if (isEditMode.value) {
return
}
searchFromHistory(keyword)
}
const switchSort = (type: string) => {
if (type === 'price') {
if (activeSort.value === 'price') {
priceSortAsc.value = !priceSortAsc.value
} else {
activeSort.value = 'price'
priceSortAsc.value = true // 默认升序
}
} else {
activeSort.value = type
}
// 重新执行搜索以获取正确排序的数据
performSearch()
}
const loadMore = async (): Promise<void> => {
if (loading.value || hasMore.value == false || searchKeyword.value.trim() == '') return
loading.value = true
currentPage.value++
const keyword = searchKeyword.value.trim()
let sortBy = 'sales'
let ascending = false
if (activeSort.value === 'price') {
sortBy = 'price'
ascending = priceSortAsc.value
} else if (activeSort.value === 'default') {
sortBy = 'default'
}
try {
const response = await supabaseService.searchProducts(keyword, currentPage.value, 20, sortBy, ascending)
const respData = response.data != null ? response.data : []
for (let i: number = 0; i < respData.length; i++) {
const p = respData[i] as Product
let tag = ''
const tagsRaw = p.tags
if (tagsRaw != null) {
try {
const tagsStr = p.tags
if (tagsStr != null) {
const tags = JSON.parse(tagsStr as string)
if (Array.isArray(tags) && tags.length > 0) {
const firstTag = tags[0]
tag = firstTag != null ? (firstTag as string) : ''
}
}
} catch(e) {}
}
const searchItem: SearchResultType = {
id: p.id ?? '',
name: getCardTitle(p),
image: p.main_image_url ?? '/static/default.jpg',
price: p.base_price ?? 0,
marketPrice: formatMarketPriceText(p),
specification: p.specification ?? '标准规格',
tag: tag,
sales: p.sale_count ?? 0,
salesText: formatSalesText(p),
highlight: getCardHighlight(p),
cardTags: getCardTags(p),
serviceTags: getServiceTags(p),
merchant_id: p.merchant_id ?? ''
}
searchResults.value.push(searchItem)
}
hasMore.value = response.hasmore
} catch(e) {
console.error('Load more failed', e)
hasMore.value = false
} finally {
loading.value = false
}
}
async function loadRecommendGoods(appendSize: number): Promise<void> {
if (loadingRecommend.value) return
loadingRecommend.value = true
try {
// 如果后端支持分页接口,可在此调用;当前使用本地 pool
if (appendSize == null || appendSize <= 0) {
// 不追加时确保至少保持初始量
recommendList.value = recommendPool.value.slice(0, recommendInitialSize.value)
recommendPage.value = 1
} else {
const startIndex = recommendList.value.length
const endIndex = startIndex + appendSize
const slice = recommendPool.value.slice(startIndex, endIndex)
// 如果池不够,尝试循环或留空
if (slice.length === 0) {
// 将池随机打乱并继续追加(循环播放)
const arr = recommendPool.value.slice()
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
const t = arr[i]; arr[i] = arr[j]; arr[j] = t
}
recommendPool.value = arr
const more = recommendPool.value.slice(0, appendSize)
recommendList.value = recommendList.value.concat(more)
} else {
recommendList.value = recommendList.value.concat(slice)
}
recommendPage.value += 1
}
} catch (e) {
console.error('加载推荐商品失败', e)
} finally {
loadingRecommend.value = false
}
}
function refreshGuessList(): void {
loadRecommendGoods(recommendAppendSize.value)
}
function handleReachBottom(): void {
if (showResults.value === true) {
loadMore()
return
}
loadRecommendGoods(recommendAppendSize.value)
}
onReachBottom(() => {
handleReachBottom()
})
const viewProductDetail = (item: SearchResultType | GuessItemType) => {
const id = (item as GuessItemType).id
const price = (item as GuessItemType).price
const name = (item as GuessItemType).name
uni.navigateTo({
url: `/pages/mall/consumer/product-detail?productId=${id}&price=${price}&name=${encodeURIComponent(name)}`
})
}
const viewShopDetail = (shop: ShopResultType) => {
uni.navigateTo({
url: `/pages/mall/consumer/shop-detail?id=${shop.id}`
})
}
const addToCart = async (product: SearchResultType | GuessItemType) => {
const userId = supabaseService.getCurrentUserId()
if (userId == null || userId === '') {
goToLogin('/pages/mall/consumer/search')
return
}
uni.showLoading({ title: '检查商品...' })
try {
// 统一转换为 UTSJSONObject 访问属性
const prodObj = JSON.parse(JSON.stringify(product)) as UTSJSONObject
const productId = prodObj.getString('id') ?? ''
const merchantId = prodObj.getString('merchant_id') ?? ''
// 检查商品是否有SKU
const skus = await supabaseService.getProductSkus(productId)
uni.hideLoading()
if (skus.length > 0) {
// 有规格,提示并跳转到商品详情页选择规格
uni.showToast({ title: '请选择规格', icon: 'none' })
setTimeout(() => {
uni.navigateTo({
url: '/pages/mall/consumer/product-detail?id=' + productId
})
}, 500)
} else {
// 无规格,直接加入购物车
uni.showLoading({ title: '添加中...' })
const success = await supabaseService.addToCart(productId, 1, '', merchantId)
uni.hideLoading()
if (success) {
uni.showToast({ title: '已添加到购物车', icon: 'success' })
} else {
uni.showToast({ title: '添加失败,请先登录', icon: 'none' })
}
}
} catch (e) {
console.error('添加到购物车异常', e)
uni.hideLoading()
uni.showToast({ title: '操作异常', icon: 'none' })
}
}
const openCamera = () => {
uni.chooseImage({
count: 1,
sourceType: ['camera'],
success: (res) => {
console.log('拍摄图片路径:', (res.tempFilePaths as string[])[0])
uni.showToast({ title: '已启用相机', icon: 'none' })
},
fail: (err) => {
console.error('启用相机失败', err)
}
})
}
const goBack = () => {
if (showResults.value) {
// 如果在搜索结果页,先返回到搜索初始页
showResults.value = false
searchKeyword.value = ''
} else {
// 如果在搜索初始页,则返回上一页
const pages = getCurrentPages()
if (pages.length > 1) {
uni.navigateBack()
} else {
// 如果只有一页(由于深链接或重定向),返回首页
uni.switchTab({
url: '/pages/main/index'
})
}
}
}
</script>
<style>
.search-page {
width: 100%;
flex: 1;
background-color: #f7f7f7;
display: flex;
flex-direction: column;
min-height: 100%;
}
/* 店铺搜索结果 */
.shop-results-section {
background-color: #fff;
margin-bottom: 10px;
padding: 10px 0;
}
.section-top {
padding: 0 12px 10px;
}
.result-title-sm {
font-size: 14px;
font-weight: bold;
color: #333;
}
.shop-list-scroll {
width: 100%;
white-space: nowrap;
}
.shop-list-row {
display: flex;
flex-direction: row;
padding: 0 12px;
}
.shop-card {
display: flex;
flex-direction: column;
align-items: center;
width: 80px;
margin-right: 15px;
background-color: #f9f9f9;
padding: 10px 5px;
border-radius: 8px;
}
.shop-logo {
width: 48px;
height: 48px;
border-radius: 24px;
margin-bottom: 5px;
border: 1px solid #f0f0f0;
background-color: white;
}
.shop-info {
width: 100%;
text-align: center;
}
.shop-name-txt {
font-size: 12px;
color: #333;
width: 100%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
/* display: block; REMOVED */
margin-bottom: 2px;
}
.shop-products-txt {
font-size: 10px;
color: #999;
}
.search-navbar {
position: relative;
z-index: 20;
background: #ffffff;
display: flex;
flex-direction: row;
align-items: center;
box-sizing: border-box;
border-bottom: 1rpx solid #f2f2f2;
}
.back-btn {
flex: 0 0 56rpx;
height: 64rpx;
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
margin-right: 8rpx;
}
.back-icon {
font-size: 52rpx;
color: #222222;
line-height: 1;
}
.jd-search-box {
flex: 1;
height: 64rpx;
border: 2rpx solid #e1251b;
border-radius: 12rpx;
background: #ffffff;
display: flex;
flex-direction: row;
align-items: center;
overflow: hidden;
box-sizing: border-box;
}
.jd-search-input {
flex: 1;
height: 64rpx;
line-height: 64rpx;
padding-left: 20rpx;
padding-right: 12rpx;
font-size: 28rpx;
color: #222222;
box-sizing: border-box;
background-color: transparent;
}
.jd-placeholder {
color: #b8b8b8;
font-size: 28rpx;
}
.clear-wrap {
flex: 0 0 auto;
height: 64rpx;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
padding: 0 8rpx;
}
.clear-icon {
font-size: 28rpx;
color: #999;
}
.camera-wrap {
flex: 0 0 64rpx;
height: 64rpx;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
.camera-icon {
font-size: 34rpx;
color: #999999;
}
.search-submit-btn {
flex: 0 0 104rpx;
height: 64rpx;
background: #e1251b;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
.search-submit-text {
color: #ffffff;
font-size: 30rpx;
font-weight: 600;
white-space: nowrap;
}
.main-content {
flex: 1;
padding: 24rpx;
box-sizing: border-box;
height: 0;
background-color: #f7f7f7;
}
.section-header {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
margin-bottom: 16rpx;
margin-top: 16rpx;
width: 100%;
}
.section-title {
font-size: 30rpx;
font-weight: bold;
color: #222222;
flex: 1;
}
.header-right {
display: flex;
flex-direction: row;
align-items: center;
flex-shrink: 0;
}
.edit-text {
font-size: 26rpx;
color: #999;
}
.all-clear {
font-size: 26rpx;
color: #ff4d1a;
margin-right: 16rpx;
}
.done {
font-size: 26rpx;
color: #222222;
}
.more-toggle {
font-size: 24rpx;
color: #999;
}
.search-history {
margin-bottom: 24rpx;
background-color: #ffffff;
border-radius: 20rpx;
padding: 20rpx 24rpx;
}
.history-tags {
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
}
.history-tag {
background-color: #f5f5f7;
padding: 10rpx 24rpx;
border-radius: 999rpx;
display: flex;
flex-direction: row;
align-items: center;
flex-shrink: 0;
margin-right: 16rpx;
margin-bottom: 16rpx;
height: 56rpx;
}
.history-text {
font-size: 26rpx;
color: #666;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-right: 8rpx;
max-width: 200rpx;
}
.delete-tag-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28rpx;
height: 28rpx;
border-radius: 14rpx;
background-color: #f0f0f0;
}
.delete-icon {
font-size: 20rpx;
color: #999;
}
.hot-search {
margin-bottom: 24rpx;
background-color: #ffffff;
border-radius: 20rpx;
padding: 20rpx 24rpx;
}
.hot-tags {
display: flex;
flex-direction: row;
flex-wrap: wrap;
}
.hot-tag {
background-color: #f5f5f7;
padding: 10rpx 24rpx;
border-radius: 999rpx;
display: flex;
flex-direction: row;
align-items: center;
flex-shrink: 0;
margin-right: 16rpx;
margin-bottom: 16rpx;
height: 56rpx;
}
.hot-tag.hot {
background-color: #fff0f0;
}
.hot-rank {
font-size: 24rpx;
color: #999;
font-weight: bold;
margin-right: 8rpx;
}
.hot-rank.top-three {
color: #ff4d1a;
}
.hot-text {
font-size: 26rpx;
color: #222222;
}
.hot-icon {
font-size: 22rpx;
margin-left: 6rpx;
}
/* 猜你需要 */
.guess-you-like {
margin-bottom: 24rpx;
background-color: #ffffff;
border-radius: 20rpx;
padding: 20rpx 24rpx;
}
.title-with-icon {
display: flex;
flex-direction: row;
align-items: center;
}
.section-icon {
font-size: 30rpx;
margin-right: 8rpx;
}
.refresh-btn {
font-size: 24rpx;
color: #ff4d1a;
}
.guess-grid {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: space-between;
}
.guess-item {
display: flex;
flex-direction: column;
background: #ffffff;
border-radius: 20rpx;
overflow: hidden;
width: 48%;
margin-bottom: 20rpx;
border: 1rpx solid #f0f0f0;
}
@media screen and (min-width: 769px) {
.guess-item {
width: 32%;
}
}
@media screen and (min-width: 1025px) {
.guess-item {
width: 23%;
}
}
.guess-img {
width: 100%;
height: 280rpx;
border-radius: 16rpx;
margin-bottom: 12rpx;
background: #f5f5f7;
object-fit: cover;
}
.guess-name {
font-size: 26rpx;
color: #222222;
margin-bottom: 8rpx;
line-height: 1.4;
height: 72rpx;
overflow: hidden;
text-overflow: ellipsis;
padding: 0 12rpx;
}
.card-tags-row {
display: flex;
flex-direction: row;
flex-wrap: wrap;
padding: 0 12rpx;
margin-bottom: 8rpx;
}
.card-tag {
height: 28rpx;
line-height: 28rpx;
padding: 0 8rpx;
border-radius: 8rpx;
font-size: 18rpx;
font-weight: 700;
color: #fff7d1;
background: #e1251b;
margin-right: 6rpx;
margin-bottom: 4rpx;
}
.card-highlight {
font-size: 20rpx;
line-height: 28rpx;
color: #7a7a7a;
padding: 0 12rpx;
margin-bottom: 8rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.service-tags-row {
display: flex;
flex-direction: row;
flex-wrap: wrap;
padding: 0 12rpx;
margin-bottom: 8rpx;
}
.service-tag {
height: 28rpx;
line-height: 28rpx;
padding: 0 8rpx;
border-radius: 8rpx;
font-size: 18rpx;
font-weight: 600;
color: #12b76a;
background: #ecfdf3;
margin-right: 6rpx;
margin-bottom: 4rpx;
}
.guess-bottom {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 0 12rpx 12rpx;
}
.price-stack {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.guess-price {
font-size: 32rpx;
color: #ff4d1a;
font-weight: bold;
}
.market-price {
font-size: 20rpx;
line-height: 1.2;
color: #9a9a9a;
text-decoration: line-through;
margin-top: 4rpx;
}
.guess-add-btn {
width: 44rpx;
height: 44rpx;
background-color: #ff4d1a;
border-radius: 22rpx;
display: flex;
align-items: center;
justify-content: center;
}
.guess-add-icon {
color: #fff;
font-size: 28rpx;
font-weight: bold;
}
.search-suggestions {
background-color: #ffffff;
border-radius: 20rpx;
padding: 0 24rpx;
margin-bottom: 24rpx;
}
.suggestion-item {
display: flex;
flex-direction: row;
align-items: center;
height: 88rpx;
border-bottom: 1rpx solid #f5f5f7;
}
.suggestion-icon {
margin-right: 16rpx;
font-size: 28rpx;
color: #999;
}
.suggestion-text {
font-size: 28rpx;
color: #222222;
}
.results-header {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 16rpx 24rpx;
background-color: #ffffff;
margin-bottom: 2rpx;
width: 100%;
box-sizing: border-box;
border-radius: 20rpx 20rpx 0 0;
}
.results-title {
font-size: 30rpx;
font-weight: bold;
color: #222222;
}
.filter-tabs {
display: flex;
flex-direction: row;
align-items: center;
flex-wrap: wrap;
justify-content: flex-start;
}
.filter-tab {
font-size: 26rpx;
color: #666;
padding: 12rpx 24rpx;
border-radius: 999rpx;
border: 1rpx solid #e0e0e0;
white-space: nowrap;
text-align: center;
display: flex;
justify-content: center;
align-items: center;
margin-left: 12rpx;
}
.filter-tab.active {
background: #ff4d1a;
color: white;
border-color: #ff4d1a;
}
.search-results {
padding-bottom: 20rpx;
width: 100%;
display: flex;
flex-direction: column;
align-items: flex-start;
}
.results-list {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: flex-start;
padding: 16rpx;
width: 100%;
box-sizing: border-box;
margin-top: 8rpx;
background-color: #ffffff;
border-radius: 0 0 20rpx 20rpx;
}
.result-item {
display: flex;
flex-direction: column;
background: #ffffff;
border-radius: 20rpx;
overflow: hidden;
width: 48%;
margin-bottom: 20rpx;
margin-right: 2%;
border: 1rpx solid #f0f0f0;
}
@media screen and (min-width: 1025px) {
.main-content {
width: 1200px;
max-width: 95%;
margin: 0 auto;
padding: 20px 32px;
}
.result-item {
width: 23%;
margin-right: 2%;
}
}
@media screen and (min-width: 1400px) {
.result-item {
width: 23%;
}
}
.product-image {
width: 100%;
height: 280rpx;
object-fit: cover;
background-color: #f5f5f7;
border-radius: 16rpx;
margin-bottom: 12rpx;
}
.product-name {
font-size: 26rpx;
color: #222222;
margin-bottom: 8rpx;
line-height: 1.4;
height: 72rpx;
overflow: hidden;
text-overflow: ellipsis;
padding: 0 12rpx;
}
.product-bottom {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 0 12rpx 12rpx;
}
.product-price {
font-size: 32rpx;
color: #ff4d1a;
font-weight: bold;
}
.card-sales-text {
font-size: 20rpx;
line-height: 28rpx;
color: #8f8f8f;
padding: 0 12rpx 12rpx;
}
.product-add-btn {
width: 44rpx;
height: 44rpx;
background-color: #ff4d1a;
border-radius: 22rpx;
display: flex;
align-items: center;
justify-content: center;
}
.add-icon {
color: #fff;
font-size: 28rpx;
font-weight: bold;
}
/* 错误状态 */
.error-state {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background-color: #fff;
}
.error-content {
display: flex;
flex-direction: column;
align-items: center;
}
.error-icon {
font-size: 48px;
margin-bottom: 12px;
}
.error-title {
font-size: 16px;
font-weight: bold;
color: #333;
margin-bottom: 12px;
}
.error-desc {
font-size: 14px;
color: #999;
}
/* 加载更多 */
.loading-more {
padding: 20px 0;
display: flex;
flex-direction: column;
align-items: center;
}
.loading-spinner {
width: 24px;
height: 24px;
border: 2px solid #f0f0f0;
border-top-color: #4CAF50;
border-radius: 12px;
margin-bottom: 8px;
}
.loading-text {
font-size: 12px;
color: #999;
}
.no-more {
padding: 20px 0;
text-align: center;
}
.no-more-text {
font-size: 12px;
color: #ccc;
}
.empty-result {
display: flex;
flex-direction: column;
align-items: center;
padding: 40px 0;
}
.empty-icon {
font-size: 40px;
margin-bottom: 12px;
}
.empty-text {
font-size: 14px;
color: #666;
margin-bottom: 4px;
}
.empty-sub {
font-size: 12px;
color: #999;
}
.safe-area {
height: 20px;
}
</style>