后端功能增强:全局异常处理、API控制器、JSP视图和单元测试
- 添加 GlobalExceptionHandler 全局异常处理 - 添加 ApiController REST API 控制器 - 更新 WebConfig 跨域配置和 ProductRepository 查询方法 - 新增 monitor/product-detail/profile JSP 视图页面 - 添加 FlashSaleServiceTest 秒杀服务单元测试 - 更新 application.yml 配置
This commit is contained in:
443
flash-sale-frontend/src/components/common/SearchComponent.vue
Normal file
443
flash-sale-frontend/src/components/common/SearchComponent.vue
Normal file
@@ -0,0 +1,443 @@
|
||||
<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="text-sm text-blue-500">
|
||||
<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 label="电子产品" value="electronics" />
|
||||
<el-option label="服装鞋包" value="fashion" />
|
||||
<el-option label="食品饮料" value="food" />
|
||||
<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 { ElMessage } from 'element-plus'
|
||||
import { debounce } from 'lodash-es'
|
||||
|
||||
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 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="text-red-500 font-bold">$1</span>')
|
||||
}
|
||||
|
||||
// 获取搜索建议
|
||||
const fetchSuggestions = debounce(async () => {
|
||||
if (!searchQuery.value.trim()) {
|
||||
suggestions.value = []
|
||||
return
|
||||
}
|
||||
|
||||
// 模拟搜索建议
|
||||
suggestions.value = [
|
||||
{
|
||||
id: 1,
|
||||
type: 'product',
|
||||
name: `${searchQuery.value} Pro Max`,
|
||||
price: 8999
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: 'product',
|
||||
name: `${searchQuery.value} 标准版`,
|
||||
price: 5999
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
type: 'flashsale',
|
||||
name: `${searchQuery.value} 限时秒杀`,
|
||||
price: 4999
|
||||
}
|
||||
]
|
||||
}, 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(() => {
|
||||
loadSearchHistory()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.search-component {
|
||||
.search-input {
|
||||
width: 300px;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
width: 200px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.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: var(--el-color-primary-light-9);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.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: #f5f7fa;
|
||||
}
|
||||
|
||||
.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: #f0f0f0;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.price {
|
||||
color: #f56c6c;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.advanced-search {
|
||||
margin-top: 20px;
|
||||
border-top: 1px solid #e4e7ed;
|
||||
padding-top: 10px;
|
||||
|
||||
.advanced-form {
|
||||
padding: 10px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user