Files
FlashSaleSystem/flash-sale-frontend/src/components/common/SearchComponent.vue
YoVinchen c4582655d9 feat: 删除JSP视图层,完善评价和通知系统,新增拼团模块
- 删除所有 JSP 页面(20个文件),前端完全迁移至 Vue 3 SPA
- 完善评价系统:ReviewDialog 组件、用户评价历史页、评价状态检查API
- 新增通知系统:Notification 实体/仓库/服务/控制器,NotificationCenter 接入真实API
- 新增拼团模块:GroupBuying 全套后端和前端页面
- 修复 review check API 参数双重包装导致请求格式错误
- 修复通知 API 路径缺少 /api 前缀和响应格式处理
- MessageListenerService 集成 NotificationService 创建持久化通知

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 16:40:26 +08:00

464 lines
12 KiB
Vue

<template>
<div class="search-component">
<el-popover
placement="bottom"
:width="600"
trigger="click"
:visible="visible"
@update:visible="val => visible = val"
>
<template #reference>
<el-input
v-model="searchQuery"
placeholder="搜索商品、秒杀活动..."
class="search-input"
@keyup.enter="handleQuickSearch"
@focus="handleFocus"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
<template #suffix>
<el-button
v-if="searchQuery"
text
circle
size="small"
@click.stop="clearSearch"
>
<el-icon><Close /></el-icon>
</el-button>
</template>
</el-input>
</template>
<div class="search-panel">
<!-- 搜索历史 -->
<div v-if="!searchQuery && searchHistory.length > 0" class="search-section">
<div class="section-header">
<span class="title">搜索历史</span>
<el-button text size="small" @click="clearHistory">
清空
</el-button>
</div>
<div class="tag-list">
<el-tag
v-for="item in searchHistory"
:key="item"
closable
@click="selectHistory(item)"
@close="removeHistory(item)"
>
{{ item }}
</el-tag>
</div>
</div>
<!-- 热门搜索 -->
<div v-if="!searchQuery" class="search-section">
<div class="section-header">
<span class="title">热门搜索</span>
</div>
<div class="tag-list">
<el-tag
v-for="item in hotSearches"
:key="item"
@click="selectHot(item)"
>
{{ item }}
</el-tag>
</div>
</div>
<!-- 搜索建议 -->
<div v-if="searchQuery && suggestions.length > 0" class="search-suggestions">
<div class="section-header">
<span class="title">搜索建议</span>
</div>
<div class="suggestion-list">
<div
v-for="item in suggestions"
:key="item.id"
class="suggestion-item"
@click="selectSuggestion(item)"
>
<el-icon :size="16" class="mr-2">
<component :is="item.type === 'product' ? 'ShoppingBag' : 'Lightning'" />
</el-icon>
<div class="content">
<div class="name" v-html="highlightKeyword(item.name)"></div>
<div class="info">
<span class="type">{{ item.type === 'product' ? '商品' : '秒杀' }}</span>
<span class="price">¥{{ item.price }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- 高级搜索 -->
<div class="advanced-search">
<el-collapse v-model="activeCollapse">
<el-collapse-item name="advanced">
<template #title>
<span class="search-advanced-title">
<el-icon><Setting /></el-icon>
高级搜索
</span>
</template>
<div class="advanced-form">
<el-form :model="advancedForm" label-width="80px" size="small">
<el-form-item label="商品分类">
<el-select v-model="advancedForm.category" placeholder="选择分类">
<el-option label="全部分类" value="" />
<el-option v-for="item in categories" :key="item" :label="item" :value="item" />
<el-option label="图书音像" value="books" />
</el-select>
</el-form-item>
<el-form-item label="价格区间">
<div class="flex gap-2">
<el-input-number
v-model="advancedForm.minPrice"
:min="0"
placeholder="最低价"
/>
<span>-</span>
<el-input-number
v-model="advancedForm.maxPrice"
:min="0"
placeholder="最高价"
/>
</div>
</el-form-item>
<el-form-item label="搜索类型">
<el-checkbox-group v-model="advancedForm.types">
<el-checkbox label="product">商品</el-checkbox>
<el-checkbox label="flashsale">秒杀</el-checkbox>
</el-checkbox-group>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleAdvancedSearch">
搜索
</el-button>
<el-button @click="resetAdvanced">重置</el-button>
</el-form-item>
</el-form>
</div>
</el-collapse-item>
</el-collapse>
</div>
</div>
</el-popover>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, watch, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { debounce } from 'lodash-es'
import { productApi } from '@/api/modules/product'
import { flashsaleApi } from '@/api/modules/flashsale'
const router = useRouter()
const visible = ref(false)
const searchQuery = ref('')
const activeCollapse = ref<string[]>([])
// 搜索历史
const searchHistory = ref<string[]>([])
// 热门搜索
const hotSearches = ref([
'iPhone 15',
'MacBook Pro',
'秒杀活动',
'AirPods',
'限时特价',
'新品上市'
])
// 搜索建议
const suggestions = ref<any[]>([])
const categories = ref<string[]>([])
// 高级搜索表单
const advancedForm = reactive({
category: '',
minPrice: undefined as number | undefined,
maxPrice: undefined as number | undefined,
types: ['product', 'flashsale']
})
// 从localStorage加载搜索历史
const loadSearchHistory = () => {
const history = localStorage.getItem('searchHistory')
if (history) {
searchHistory.value = JSON.parse(history)
}
}
// 保存搜索历史
const saveSearchHistory = (keyword: string) => {
if (!keyword.trim()) return
// 去重并限制数量
const history = searchHistory.value.filter(item => item !== keyword)
history.unshift(keyword)
searchHistory.value = history.slice(0, 10)
localStorage.setItem('searchHistory', JSON.stringify(searchHistory.value))
}
// 清空搜索历史
const clearHistory = () => {
searchHistory.value = []
localStorage.removeItem('searchHistory')
}
// 移除单个历史
const removeHistory = (item: string) => {
searchHistory.value = searchHistory.value.filter(h => h !== item)
localStorage.setItem('searchHistory', JSON.stringify(searchHistory.value))
}
// 选择历史记录
const selectHistory = (item: string) => {
searchQuery.value = item
handleQuickSearch()
}
// 选择热门搜索
const selectHot = (item: string) => {
searchQuery.value = item
handleQuickSearch()
}
// 选择搜索建议
const selectSuggestion = (item: any) => {
if (item.type === 'product') {
router.push(`/product/${item.id}`)
} else {
router.push(`/flashsale/${item.id}`)
}
visible.value = false
saveSearchHistory(item.name)
}
// 高亮关键词
const highlightKeyword = (text: string) => {
if (!searchQuery.value) return text
const regex = new RegExp(`(${searchQuery.value})`, 'gi')
return text.replace(regex, '<span class="search-highlight">$1</span>')
}
// 获取搜索建议
const fetchSuggestions = debounce(async () => {
if (!searchQuery.value.trim()) {
suggestions.value = []
return
}
try {
const [productRes, flashSaleRes] = await Promise.all([
productApi.getList({ keyword: searchQuery.value, page: 0, size: 5 }),
flashsaleApi.getList({ page: 0, size: 6 }),
])
const productSuggestions = productRes.success
? productRes.data.content.map((item) => ({
id: item.id,
type: 'product',
name: item.name,
price: item.price,
}))
: []
const flashSaleSuggestions = flashSaleRes.success
? flashSaleRes.data.content
.filter((item) => item.productName.includes(searchQuery.value))
.slice(0, 3)
.map((item) => ({
id: item.id,
type: 'flashsale',
name: item.productName,
price: item.flashPrice,
}))
: []
suggestions.value = [...productSuggestions, ...flashSaleSuggestions].slice(0, 8)
} catch (error) {
suggestions.value = []
}
}, 300)
// 快速搜索
const handleQuickSearch = () => {
if (!searchQuery.value.trim()) return
saveSearchHistory(searchQuery.value)
router.push({
path: '/products',
query: { keyword: searchQuery.value }
})
visible.value = false
}
// 高级搜索
const handleAdvancedSearch = () => {
const params: any = {
keyword: searchQuery.value
}
if (advancedForm.category) params.category = advancedForm.category
if (advancedForm.minPrice !== undefined) params.minPrice = advancedForm.minPrice
if (advancedForm.maxPrice !== undefined) params.maxPrice = advancedForm.maxPrice
router.push({
path: '/products',
query: params
})
visible.value = false
saveSearchHistory(searchQuery.value)
}
// 重置高级搜索
const resetAdvanced = () => {
advancedForm.category = ''
advancedForm.minPrice = undefined
advancedForm.maxPrice = undefined
advancedForm.types = ['product', 'flashsale']
}
// 清空搜索
const clearSearch = () => {
searchQuery.value = ''
suggestions.value = []
}
// 处理焦点
const handleFocus = () => {
visible.value = true
}
// 监听搜索输入
watch(searchQuery, () => {
fetchSuggestions()
})
onMounted(async () => {
loadSearchHistory()
const res = await productApi.getCategories()
categories.value = res.success ? res.data : []
})
</script>
<style scoped lang="scss">
.search-component {
.search-input {
width: 300px;
@media (max-width: 768px) {
width: 200px;
}
}
}
.search-advanced-title {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 14px;
color: #44443f;
}
.search-panel {
.search-section {
margin-bottom: 20px;
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
.title {
font-size: 14px;
font-weight: 500;
color: #303133;
}
}
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
.el-tag {
cursor: pointer;
&:hover {
background-color: #efefed;
}
}
}
}
.search-suggestions {
.suggestion-list {
.suggestion-item {
display: flex;
align-items: center;
padding: 10px;
cursor: pointer;
border-radius: 4px;
transition: background-color 0.3s;
&:hover {
background-color: #f7f7f6;
}
.content {
flex: 1;
.name {
font-size: 14px;
color: #303133;
margin-bottom: 4px;
}
.info {
display: flex;
gap: 10px;
font-size: 12px;
color: #909399;
.type {
padding: 0 6px;
background-color: #efefed;
border-radius: 2px;
}
.price {
color: #2b2b27;
font-weight: 500;
}
}
}
}
}
}
.advanced-search {
margin-top: 20px;
border-top: 1px solid #d8cebf;
padding-top: 10px;
.advanced-form {
padding: 10px 0;
}
}
}
</style>