Files
medical-mall/pages/mall/consumer/search.uvue

1850 lines
44 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"
:lower-threshold="120"
@scrolltolower="handleMainScrollToLower"
>
<!-- 初始状态 -->
<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">
<GuessYouLike
title="猜你喜欢"
:pageSize="8"
:loadMoreKey="guessLoadMoreKey"
@productClick="goToProductDetail"
/>
</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" class="guess-you-like" style="margin-top: 16rpx;">
<GuessYouLike
title="猜你喜欢"
:pageSize="8"
:excludeProductIds="searchResultProductIds"
:loadMoreKey="guessLoadMoreKey"
@productClick="goToProductDetail"
/>
</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'
import GuessYouLike from '@/components/mall/GuessYouLike/GuessYouLike.uvue'
// 状态定义
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 guessLoadMoreKey = ref<number>(0)
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 searchResultProductIds = computed((): Array<string> => {
const ids: Array<string> = []
for (let i = 0; i < searchResults.value.length; i++) {
const id = searchResults.value[i].id
if (id !== '' && ids.indexOf(id) < 0) {
ids.push(id)
}
}
return ids
})
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 }
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 为空,但使用 effective 搜索
searchKeyword.value = ''
}
// 临时将 searchKeyword 设置为 effective供 performSearch 使用
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
}
}
function handleReachBottom(): void {
if (showResults.value === true) {
if (hasMore.value) {
loadMore()
return
}
guessLoadMoreKey.value = guessLoadMoreKey.value + 1
return
}
guessLoadMoreKey.value = guessLoadMoreKey.value + 1
}
function handleMainScrollToLower(): void {
handleReachBottom()
}
onReachBottom(() => {
handleReachBottom()
})
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>