|
|
<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) |
|
|
}) |
|
|
|
|
|
|
|
|
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); |
|
|
} |
|
|
} |