feat(buyer): 增强搜索功能并提升用户体验

- 新增 `getStoreById` 接口方法,用于获取包含评分在内的店铺信息
- `Search.vue` 新增搜索建议面板,展示搜索历史和热词,优化搜索体验
- 新增 `SearchSuggest.vue` 组件,用于展示搜索建议和搜索历史
- `App.vue` 根据路由元信息按条件设置页面标题
- 优化多个组件的布局与样式,让整体风格更统一
- 重构多个组件的跳转逻辑,改用路由跳转(`router.push`)替代新开窗口(`window.open`),提升用户体验
This commit is contained in:
pikachu1995@126.com
2026-10-02 15:26:58 +08:00
parent 4777eb2dd6
commit 8410179885
44 changed files with 2116 additions and 674 deletions

View File

@@ -7,11 +7,24 @@
size="large"
class="search-input"
placeholder="输入你想查找的商品"
@focus="openPanel"
@input="queueSuggest"
@keyup.enter="search"
/>
<div class="search-btn" @click="search">
<el-icon :size="21"><SearchIcon /></el-icon>
</div>
<div v-if="showPanel" class="suggest-panel" @mousedown.prevent>
<SearchSuggest
:keyword="trimmedKeyword"
:history="history"
:hot-words="promotionTags"
:suggestions="suggestions"
:suggesting="suggesting"
@pick="selectTags"
@clear="clearHistory"
/>
</div>
</div>
</template>
<template v-else>
@@ -30,11 +43,24 @@
size="large"
class="search-input"
placeholder="输入你想查找的商品"
@focus="openPanel"
@input="queueSuggest"
@keyup.enter="search"
/>
<div class="search-btn" @click="search">
<el-icon :size="21"><SearchIcon /></el-icon>
</div>
<div v-if="showPanel" class="suggest-panel" @mousedown.prevent>
<SearchSuggest
:keyword="trimmedKeyword"
:history="history"
:hot-words="promotionTags"
:suggestions="suggestions"
:suggesting="suggesting"
@pick="selectTags"
@clear="clearHistory"
/>
</div>
</div>
<template v-if="showTag">
<div v-if="promotionTags.length === 0"></div>
@@ -56,22 +82,49 @@
<script>
import storage from '@/plugins/storage.js'
import {hotWords} from '@/api/goods.js'
import { goodsList, hotWords } from '@/api/goods.js'
import { Search as SearchIcon } from '@element-plus/icons-vue'
import SearchSuggest from '@/components/SearchSuggest.vue'
const HISTORY_KEY = 'buyerSearchHistory'
const HISTORY_LIMIT = 10
function readHistory() {
try {
const raw = localStorage.getItem(HISTORY_KEY)
const list = raw ? JSON.parse(raw) : []
return Array.isArray(list) ? list.filter((item) => typeof item === 'string' && item.trim()) : []
} catch {
return []
}
}
function parseHotWords(raw) {
if (!raw) return []
if (Array.isArray(raw)) return raw.filter(Boolean)
if (typeof raw !== 'string') return []
try {
const parsed = JSON.parse(raw)
if (Array.isArray(parsed)) return parsed.filter(Boolean)
} catch {
// 旧数据可能是逗号拼接
}
return raw.split(',').map((item) => item.trim()).filter(Boolean)
}
export default {
name: 'search',
components: { SearchIcon },
components: { SearchIcon, SearchSuggest },
props: {
showTag: { // 是否展示搜索栏下方热门搜索
showTag: {
type: Boolean,
default: true
},
showLogo: { // 是否展示左侧logo
showLogo: {
type: Boolean,
default: true
},
storeId: { // 是否为店铺页面
storeId: {
type: String,
default: ""
},
@@ -86,64 +139,148 @@ export default {
},
data() {
return {
searchData: '', // 搜索内容
searchData: '',
history: [],
panelVisible: false,
suggestions: [],
suggesting: false,
suggestTimer: null,
suggestSeq: 0,
};
},
methods: {
selectTags(item) { // 选择热门标签
selectTags(item) {
this.searchData = item;
this.search();
},
search () { // 全平台搜索商品
remember(keyword) {
const next = [keyword, ...this.history.filter((item) => item !== keyword)].slice(0, HISTORY_LIMIT)
this.history = next
localStorage.setItem(HISTORY_KEY, JSON.stringify(next))
},
clearHistory() {
this.history = []
localStorage.removeItem(HISTORY_KEY)
},
openPanel() {
this.panelVisible = true
if (this.trimmedKeyword) this.queueSuggest()
},
closePanel() {
this.panelVisible = false
},
onDocumentClick(event) {
if (this.$el && !this.$el.contains(event.target)) this.closePanel()
},
queueSuggest() {
this.panelVisible = true
clearTimeout(this.suggestTimer)
const keyword = this.trimmedKeyword
if (!keyword) {
this.suggestions = []
this.suggesting = false
return
}
this.suggesting = true
this.suggestTimer = setTimeout(() => this.fetchSuggest(keyword), 300)
},
fetchSuggest(keyword) {
const seq = ++this.suggestSeq
const params = { keyword, pageNumber: 1, pageSize: 8 }
if (this.storeId) params.storeId = this.storeId
goodsList(params).then((res) => {
if (seq !== this.suggestSeq || keyword !== this.trimmedKeyword) return
const records = res && res.success ? (res.result.records || []) : []
const seen = new Set()
const names = []
this.promotionTags.forEach((word) => {
if (word && String(word).toLowerCase().includes(keyword.toLowerCase()) && !seen.has(word)) {
seen.add(word)
names.push(word)
}
})
records.forEach((item) => {
const name = item && item.goodsName
if (name && !seen.has(name)) {
seen.add(name)
names.push(name)
}
})
this.suggestions = names.slice(0, 8)
}).catch(() => {
if (seq === this.suggestSeq) this.suggestions = []
}).finally(() => {
if (seq === this.suggestSeq) this.suggesting = false
})
},
search () {
const keyword = this.trimmedKeyword
this.searchData = keyword
this.closePanel()
if (keyword) this.remember(keyword)
const url = this.$route.path;
if(url == '/goodsList'){
this.$emit('search', this.searchData)
this.$emit('search', keyword)
}else{
const pushData = {
path:'/goodsList',
query: { keyword: this.searchData },
query: { keyword },
}
if(this.storeId) pushData.query.storeId = this.storeId
this.$router.push(pushData);
}
},
searchStore() { // 店铺搜索商品
this.$emit('search', this.searchData)
searchStore() {
this.$emit('search', this.trimmedKeyword)
},
applyHotWords(result) {
const list = parseHotWords(result)
this.$store.commit('SET_HOTWORDS', list)
storage.setItem('hotWordsList', JSON.stringify(list))
},
loadHotWords() {
hotWords({ count: 5 }).then((res) => {
if (res.success && res.result) this.applyHotWords(res.result)
}).catch(() => {})
storage.setItem('hotWordsReloadTime', new Date().getTime())
}
},
computed: {
trimmedKeyword() {
return (this.searchData || '').trim()
},
promotionTags() {
const raw = this.$store.state.hotWordsList;
if (!raw) {
return [];
}
try {
return typeof raw === "string" ? JSON.parse(raw) : raw;
} catch {
return [];
}
return parseHotWords(this.$store.state.hotWordsList)
},
showPanel() {
if (!this.panelVisible) return false
if (this.trimmedKeyword) return true
return this.history.length > 0 || this.promotionTags.length > 0
}
},
watch: {
'$route.query.keyword'(value) {
this.searchData = value || ''
}
},
created() {
this.searchData = this.$route.query.keyword
this.searchData = this.$route.query.keyword || ''
this.history = readHistory()
if (!this.hover) { // 首页顶部固定搜索栏不调用热词接口
// 搜索热词每5分钟请求一次
if (!this.hover) {
const reloadTime = storage.getItem('hotWordsReloadTime')
const time = new Date().getTime() - 5 * 60 * 1000
if (!reloadTime) {
hotWords({count: 5}).then(res => {
if (res.success && res.result) storage.setItem('hotWordsList', res.result)
}).catch(() => {})
storage.setItem('hotWordsReloadTime', new Date().getTime())
} else if (reloadTime && time > reloadTime) {
hotWords({count: 5}).then(res => {
if (res.success && res.result) storage.setItem('hotWordsList', res.result)
}).catch(() => {})
storage.setItem('hotWordsReloadTime', new Date().getTime())
}
if (!reloadTime || time > reloadTime) this.loadHotWords()
}
},
mounted() {
this.onDocumentClick = this.onDocumentClick.bind(this)
document.addEventListener('click', this.onDocumentClick)
},
beforeUnmount() {
clearTimeout(this.suggestTimer)
document.removeEventListener('click', this.onDocumentClick)
}
};
</script>
@@ -169,7 +306,6 @@ export default {
.container {
position: relative;
height: 100%;
}
@@ -217,6 +353,11 @@ export default {
color: #ffffff;
}
}
.search-embedded.search {
width: 100%;
}
.search-box{
margin-left: 28px;
}
@@ -225,28 +366,7 @@ export default {
cursor: pointer;
}
.store-search {
width: 55.6px;
padding: 0 9px;
border-radius: 0;
border-radius: 3px;
&:nth-child(2) {
width: 55px;
margin-left: -2px;
border-radius: 3px;
}
}
.btn-div {
position: relative;
height: 0px;
top: -38px;
left: 352px;
}
.history-list {
margin-left: 28px;
}
@@ -259,4 +379,18 @@ export default {
color: #666666;
cursor: pointer;
}
.suggest-panel {
position: absolute;
left: 0;
right: 0;
top: calc(100% + 8px);
z-index: 30;
padding: 8px 0;
background: #fff;
border: 1px solid #eee;
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
}
</style>