music / src /components /layout /SearchHeader.vue
ahutchen's picture
feat(components): 优化多个组件的样式和功能
9e40388
<template>
<div class="search-header" :class="{ 'transparent': isTransparent, 'searching': isSearching }">
<div class="header-content">
<!-- 左侧返回按钮 -->
<button
v-if="showBackButton"
class="back-btn"
@click="handleBack"
:title="backButtonTitle"
>
<i class="fas fa-arrow-left"></i>
</button>
<!-- 搜索框 -->
<div class="search-container">
<div class="search-input-wrapper">
<i class="fas fa-search search-icon"></i>
<input
ref="searchInput"
type="text"
v-model="searchValue"
:placeholder="placeholder"
class="search-input"
@input="handleInput"
@focus="handleFocus"
@blur="handleBlur"
@keyup.enter="handleSearch"
@keyup.esc="handleEscape"
>
<button
v-if="searchValue && clearable"
class="clear-btn"
@click="handleClear"
title="清空"
>
<i class="fas fa-times"></i>
</button>
</div>
<!-- 音乐源选择按钮 -->
<button
v-if="showSourceSelector"
class="source-btn"
@click="handleSourceSelect"
:title="`当前音乐源: ${currentSourceName}`"
>
<i class="fas fa-music"></i>
<span class="source-text">{{ currentSourceName }}</span>
<i class="fas fa-chevron-down"></i>
</button>
</div>
<!-- 右侧操作按钮 -->
<div class="header-actions" v-if="$slots.actions">
<slot name="actions"></slot>
</div>
</div>
<!-- 搜索建议下拉框 -->
<div
v-if="showSuggestions && suggestions.length > 0"
class="search-suggestions"
@click.stop
>
<div
v-for="(suggestion, index) in suggestions"
:key="index"
class="suggestion-item"
:class="{ 'active': selectedSuggestionIndex === index }"
@click="selectSuggestion(suggestion)"
@mouseover="selectedSuggestionIndex = index"
>
<i class="fas fa-search"></i>
<span class="suggestion-text">{{ suggestion }}</span>
<i class="fas fa-arrow-up-right suggestion-action"></i>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
import { useSearchStore } from '@/stores/search'
const props = defineProps({
// 搜索值
modelValue: {
type: String,
default: ''
},
// 占位符文本
placeholder: {
type: String,
default: '搜索歌曲、歌手或专辑'
},
// 是否显示返回按钮
showBackButton: {
type: Boolean,
default: false
},
// 返回按钮标题
backButtonTitle: {
type: String,
default: '返回'
},
// 是否显示音乐源选择器
showSourceSelector: {
type: Boolean,
default: true
},
// 是否可清空
clearable: {
type: Boolean,
default: true
},
// 是否透明背景
isTransparent: {
type: Boolean,
default: false
},
// 是否自动对焦
autofocus: {
type: Boolean,
default: false
},
// 是否显示搜索建议
enableSuggestions: {
type: Boolean,
default: true
},
// 搜索建议列表
suggestionsList: {
type: Array,
default: () => []
}
})
const emit = defineEmits([
'update:modelValue',
'search',
'focus',
'blur',
'back',
'source-select',
'clear',
'suggestion-select'
])
const searchStore = useSearchStore()
const searchInput = ref(null)
const searchValue = ref(props.modelValue)
const isSearching = ref(false)
const isFocused = ref(false)
const showSuggestions = ref(false)
const selectedSuggestionIndex = ref(-1)
// 计算属性
const currentSourceName = computed(() => {
return searchStore.getSourceName(searchStore.currentSource)
})
const suggestions = computed(() => {
if (!props.enableSuggestions || !searchValue.value || searchValue.value.length < 2) {
return []
}
// 结合传入的建议和历史搜索
const allSuggestions = [
...props.suggestionsList,
...searchStore.getSearchSuggestions(searchValue.value)
]
// 去重并限制数量
const uniqueSuggestions = [...new Set(allSuggestions)]
return uniqueSuggestions.slice(0, 5)
})
// 监听props变化
watch(() => props.modelValue, (newValue) => {
searchValue.value = newValue
})
watch(searchValue, (newValue) => {
emit('update:modelValue', newValue)
})
// 方法
const handleInput = () => {
if (props.enableSuggestions && searchValue.value.length >= 2) {
showSuggestions.value = true
selectedSuggestionIndex.value = -1
} else {
showSuggestions.value = false
}
}
const handleFocus = () => {
isFocused.value = true
emit('focus')
if (props.enableSuggestions && searchValue.value.length >= 2) {
showSuggestions.value = true
}
}
const handleBlur = () => {
isFocused.value = false
emit('blur')
// 延迟隐藏建议,允许点击建议项
setTimeout(() => {
showSuggestions.value = false
}, 200)
}
const handleSearch = () => {
if (selectedSuggestionIndex.value >= 0 && suggestions.value[selectedSuggestionIndex.value]) {
selectSuggestion(suggestions.value[selectedSuggestionIndex.value])
} else if (searchValue.value.trim()) {
performSearch(searchValue.value.trim())
}
}
const handleEscape = () => {
if (showSuggestions.value) {
showSuggestions.value = false
selectedSuggestionIndex.value = -1
} else {
handleClear()
}
}
const handleClear = () => {
searchValue.value = ''
showSuggestions.value = false
selectedSuggestionIndex.value = -1
emit('clear')
if (searchInput.value) {
searchInput.value.focus()
}
}
const handleBack = () => {
emit('back')
}
const handleSourceSelect = () => {
emit('source-select')
}
const selectSuggestion = (suggestion) => {
searchValue.value = suggestion
showSuggestions.value = false
selectedSuggestionIndex.value = -1
performSearch(suggestion)
emit('suggestion-select', suggestion)
}
const performSearch = (query) => {
isSearching.value = true
emit('search', query)
// 添加到搜索历史
searchStore.addToHistory({
keyword: query,
source: searchStore.currentSource,
timestamp: Date.now()
})
setTimeout(() => {
isSearching.value = false
}, 1000)
}
const focus = () => {
if (searchInput.value) {
searchInput.value.focus()
}
}
const blur = () => {
if (searchInput.value) {
searchInput.value.blur()
}
}
// 键盘导航
const handleKeyNavigation = (event) => {
if (!showSuggestions.value || suggestions.value.length === 0) return
switch (event.key) {
case 'ArrowDown':
event.preventDefault()
selectedSuggestionIndex.value = Math.min(
selectedSuggestionIndex.value + 1,
suggestions.value.length - 1
)
break
case 'ArrowUp':
event.preventDefault()
selectedSuggestionIndex.value = Math.max(
selectedSuggestionIndex.value - 1,
-1
)
break
}
}
// 生命周期
onMounted(() => {
if (props.autofocus) {
nextTick(() => {
focus()
})
}
document.addEventListener('keydown', handleKeyNavigation)
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeyNavigation)
})
// 暴露方法
defineExpose({
focus,
blur
})
</script>
<style scoped>
.search-header {
background: var(--bg-card);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
transition: var(--transition-fast);
position: relative;
z-index: 10;
}
.search-header.transparent {
background: transparent;
border-bottom: none;
}
.search-header.searching {
opacity: 0.9;
}
.header-content {
display: flex;
align-items: center;
padding: 12px 16px;
gap: 12px;
}
.back-btn {
width: 36px;
height: 36px;
border: none;
background: transparent;
color: var(--text-primary);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: var(--transition-fast);
flex-shrink: 0;
}
.back-btn:hover {
background: rgba(255, 255, 255, 0.1);
}
.search-container {
flex: 1;
display: flex;
align-items: center;
gap: 8px;
}
.search-input-wrapper {
flex: 1;
position: relative;
}
.search-icon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: var(--text-tertiary);
font-size: 14px;
pointer-events: none;
z-index: 1;
}
.search-input {
width: 100%;
height: 40px;
border: none;
background: rgba(255, 255, 255, 0.08);
border-radius: 20px;
padding: 0 40px 0 40px;
color: var(--text-primary);
font-size: 14px;
outline: none;
transition: var(--transition-fast);
}
.search-input:focus {
background: rgba(255, 255, 255, 0.12);
box-shadow: 0 0 0 2px rgba(255, 107, 107, 0.3);
}
.search-input::placeholder {
color: var(--text-tertiary);
}
.clear-btn {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
width: 24px;
height: 24px;
border: none;
background: transparent;
color: var(--text-tertiary);
border-radius: 50%;
cursor: pointer;
transition: var(--transition-fast);
z-index: 1;
}
.clear-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: var(--text-secondary);
}
.source-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 8px 12px;
border: none;
background: var(--overlay-light);
color: var(--text-secondary);
border-radius: 16px;
font-size: 12px;
cursor: pointer;
transition: var(--transition-fast);
white-space: nowrap;
flex-shrink: 0;
}
.source-btn:hover {
background: var(--overlay-light);
color: var(--text-primary);
}
.source-text {
max-width: 60px;
overflow: hidden;
text-overflow: ellipsis;
}
.header-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.search-suggestions {
position: absolute;
top: 100%;
left: 16px;
right: 16px;
background: var(--bg-card);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
backdrop-filter: blur(20px);
overflow: hidden;
z-index: 100;
}
.suggestion-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
cursor: pointer;
transition: var(--transition-fast);
}
.suggestion-item:hover,
.suggestion-item.active {
background: var(--overlay-light);
}
.suggestion-item i:first-child {
color: var(--text-tertiary);
font-size: 12px;
width: 12px;
}
.suggestion-text {
flex: 1;
color: var(--text-primary);
font-size: 14px;
}
.suggestion-action {
color: var(--text-tertiary);
font-size: 10px;
opacity: 0;
transition: var(--transition-fast);
}
.suggestion-item:hover .suggestion-action {
opacity: 1;
}
/* 响应式 */
@media (max-width: 375px) {
.header-content {
padding: 10px 12px;
gap: 8px;
}
.back-btn {
width: 32px;
height: 32px;
}
.search-input {
height: 36px;
font-size: 13px;
}
.source-btn {
padding: 6px 10px;
font-size: 11px;
}
.source-text {
max-width: 50px;
}
.search-suggestions {
left: 12px;
right: 12px;
}
}
/* 平板和桌面优化 */
@media (min-width: 768px) {
.header-content {
padding: 16px 24px;
}
.search-input {
height: 44px;
font-size: 15px;
}
.source-text {
max-width: 80px;
}
}
/* 动画效果 */
.search-suggestions {
animation: slide-down 0.2s ease-out;
}
@keyframes slide-down {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.search-icon {
animation: search-pulse 2s ease-in-out infinite;
}
@keyframes search-pulse {
0%, 100% {
opacity: 0.6;
}
50% {
opacity: 1;
}
}
.search-header.searching .search-icon {
animation: search-spin 1s linear infinite;
}
@keyframes search-spin {
from {
transform: translateY(-50%) rotate(0deg);
}
to {
transform: translateY(-50%) rotate(360deg);
}
}