mirror of
https://github.com/algerkong/AlgerMusicPlayer.git
synced 2026-05-17 10:27:30 +08:00
feat:针对移动端优化
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="history-page">
|
||||
<div class="title-wrapper" :class="setAnimationClass('animate__fadeInRight')">
|
||||
<div class="title-wrapper" :class="setAnimationClass('animate__fadeInRight')" v-if="!isMobile">
|
||||
<div class="title">{{ t('history.title') }}</div>
|
||||
<n-button
|
||||
secondary
|
||||
@@ -54,12 +54,14 @@
|
||||
:style="setAnimationDelay(index, 30)"
|
||||
>
|
||||
<song-item class="history-item-content" :item="item" @play="handlePlay" />
|
||||
<div class="history-item-count min-w-[60px]" v-show="currentTab === 'local'">
|
||||
{{ t('history.playCount', { count: item.count }) }}
|
||||
</div>
|
||||
<div class="history-item-delete" v-show="currentTab === 'local'">
|
||||
<i class="iconfont icon-close" @click="handleDelMusic(item)"></i>
|
||||
</div>
|
||||
<template v-if="!isMobile">
|
||||
<div class="history-item-count min-w-[60px]" v-show="currentTab === 'local'">
|
||||
{{ t('history.playCount', { count: item.count }) }}
|
||||
</div>
|
||||
<div class="history-item-delete" v-show="currentTab === 'local'">
|
||||
<i class="iconfont icon-close" @click="handleDelMusic(item)"></i>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -130,7 +132,7 @@ import { usePlaylistHistory } from '@/hooks/PlaylistHistoryHook';
|
||||
import { usePlayerStore } from '@/store/modules/player';
|
||||
import { useUserStore } from '@/store/modules/user';
|
||||
import type { SongResult } from '@/types/music';
|
||||
import { setAnimationClass, setAnimationDelay } from '@/utils';
|
||||
import { isMobile, setAnimationClass, setAnimationDelay } from '@/utils';
|
||||
|
||||
// 扩展历史记录类型以包含 playTime
|
||||
interface HistoryRecord extends Partial<SongResult> {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="flex gap-4 h-full pb-4">
|
||||
<favorite class="flex-item" />
|
||||
<favorite class="flex-item" v-if="!isMobile" />
|
||||
<history-list class="flex-item" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -10,6 +10,7 @@ defineOptions({
|
||||
name: 'History'
|
||||
});
|
||||
|
||||
import { isMobile } from '@/utils';
|
||||
import Favorite from '@/views/favorite/index.vue';
|
||||
import HistoryList from '@/views/history/index.vue';
|
||||
</script>
|
||||
|
||||
@@ -41,7 +41,7 @@ defineOptions({
|
||||
|
||||
.mobile {
|
||||
.main-content {
|
||||
@apply flex-col mx-4;
|
||||
@apply flex-col mx-4 mb-40;
|
||||
}
|
||||
:deep(.favorite-page) {
|
||||
@apply p-0 mx-4 h-full;
|
||||
|
||||
@@ -0,0 +1,464 @@
|
||||
<template>
|
||||
<div class="mobile-search-result">
|
||||
<!-- 搜索结果头部 -->
|
||||
<div class="result-header" :class="{ 'safe-area-top': hasSafeArea }">
|
||||
<div class="header-back" @click="goBack">
|
||||
<i class="ri-arrow-left-s-line"></i>
|
||||
</div>
|
||||
<div class="header-keyword">{{ keyword }}</div>
|
||||
<div class="header-actions">
|
||||
<div class="action-btn" @click="openSearch">
|
||||
<i class="ri-search-line"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索类型标签 -->
|
||||
<div class="search-types">
|
||||
<div
|
||||
v-for="type in searchTypes"
|
||||
:key="type.key"
|
||||
class="type-tag"
|
||||
:class="{ active: searchType === type.key }"
|
||||
@click="selectType(type.key)"
|
||||
>
|
||||
{{ type.label }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索结果列表 -->
|
||||
<div class="result-content" @scroll="handleScroll">
|
||||
<!-- 加载中 -->
|
||||
<div v-if="loading && !results.length" class="loading-state">
|
||||
<n-spin size="medium" />
|
||||
<span class="ml-2">{{ t('search.loading.searching') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 搜索结果 -->
|
||||
<div v-else-if="results.length" class="result-list">
|
||||
<!-- B站视频 -->
|
||||
<template v-if="searchType === SEARCH_TYPE.BILIBILI">
|
||||
<bilibili-item
|
||||
v-for="item in results"
|
||||
:key="item.bvid"
|
||||
:item="item"
|
||||
@play="handlePlayBilibili"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 歌曲搜索 -->
|
||||
<template v-else-if="searchType === SEARCH_TYPE.MUSIC">
|
||||
<song-item
|
||||
v-for="item in results"
|
||||
:key="item.id"
|
||||
:item="item"
|
||||
:is-next="true"
|
||||
@play="handlePlay"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 专辑/歌单/MV 搜索 -->
|
||||
<template v-else>
|
||||
<search-item v-for="item in results" :key="item.id" :item="item" class="mb-3" />
|
||||
</template>
|
||||
|
||||
<!-- 加载更多 -->
|
||||
<div v-if="isLoadingMore" class="loading-more">
|
||||
<n-spin size="small" />
|
||||
<span class="ml-2">{{ t('search.loading.more') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 没有更多 -->
|
||||
<div v-if="!hasMore && results.length" class="no-more">
|
||||
{{ t('search.noMore') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 无结果 -->
|
||||
<div v-else-if="!loading" class="empty-state">
|
||||
<i class="ri-search-line"></i>
|
||||
<span>{{ t('search.noResult') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, onMounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import {
|
||||
createSimpleBilibiliSong,
|
||||
getBilibiliAudioUrl,
|
||||
getBilibiliProxyUrl,
|
||||
getBilibiliVideoDetail,
|
||||
searchBilibili
|
||||
} from '@/api/bilibili';
|
||||
import { getSearch } from '@/api/search';
|
||||
import BilibiliItem from '@/components/common/BilibiliItem.vue';
|
||||
import SearchItem from '@/components/common/SearchItem.vue';
|
||||
import SongItem from '@/components/common/SongItem.vue';
|
||||
import { SEARCH_TYPE, SEARCH_TYPES } from '@/const/bar-const';
|
||||
import { usePlayerStore } from '@/store/modules/player';
|
||||
import { useSearchStore } from '@/store/modules/search';
|
||||
import type { IBilibiliSearchResult } from '@/types/bilibili';
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const playerStore = usePlayerStore();
|
||||
const searchStore = useSearchStore();
|
||||
|
||||
// 注入是否有安全区域
|
||||
const hasSafeArea = inject('hasSafeArea', false);
|
||||
|
||||
// 搜索关键词
|
||||
const keyword = ref((route.query.keyword as string) || '');
|
||||
|
||||
// 搜索类型
|
||||
const searchType = ref(Number(route.query.type) || searchStore.searchType || 1);
|
||||
const searchTypes = computed(() => {
|
||||
locale.value;
|
||||
return SEARCH_TYPES.map((type) => ({
|
||||
label: t(type.label),
|
||||
key: type.key
|
||||
}));
|
||||
});
|
||||
|
||||
// 搜索结果
|
||||
const results = ref<any[]>([]);
|
||||
const loading = ref(false);
|
||||
|
||||
// 分页
|
||||
const ITEMS_PER_PAGE = 30;
|
||||
const page = ref(1);
|
||||
const hasMore = ref(true);
|
||||
const isLoadingMore = ref(false);
|
||||
|
||||
// 执行搜索
|
||||
const performSearch = async (isLoadMore = false) => {
|
||||
if (!keyword.value) return;
|
||||
|
||||
if (isLoadMore) {
|
||||
if (!hasMore.value || isLoadingMore.value) return;
|
||||
isLoadingMore.value = true;
|
||||
} else {
|
||||
loading.value = true;
|
||||
results.value = [];
|
||||
page.value = 1;
|
||||
hasMore.value = true;
|
||||
}
|
||||
|
||||
try {
|
||||
// B站搜索
|
||||
if (searchType.value === SEARCH_TYPE.BILIBILI) {
|
||||
const response = await searchBilibili({
|
||||
keyword: keyword.value,
|
||||
page: page.value,
|
||||
pagesize: ITEMS_PER_PAGE
|
||||
});
|
||||
|
||||
const bilibiliVideos = response.data.data.result.map((item: any) => ({
|
||||
id: item.aid,
|
||||
bvid: item.bvid,
|
||||
title: item.title,
|
||||
author: item.author,
|
||||
pic: getBilibiliProxyUrl(item.pic),
|
||||
duration: item.duration,
|
||||
pubdate: item.pubdate,
|
||||
description: item.description,
|
||||
view: item.play,
|
||||
danmaku: item.video_review
|
||||
}));
|
||||
|
||||
if (isLoadMore) {
|
||||
results.value = [...results.value, ...bilibiliVideos];
|
||||
} else {
|
||||
results.value = bilibiliVideos;
|
||||
}
|
||||
|
||||
hasMore.value = bilibiliVideos.length === ITEMS_PER_PAGE;
|
||||
}
|
||||
// 歌曲搜索
|
||||
else if (searchType.value === SEARCH_TYPE.MUSIC) {
|
||||
const { data } = await getSearch({
|
||||
keywords: keyword.value,
|
||||
type: searchType.value,
|
||||
limit: ITEMS_PER_PAGE,
|
||||
offset: (page.value - 1) * ITEMS_PER_PAGE
|
||||
});
|
||||
|
||||
const songs = (data.result.songs || []).map((item: any) => ({
|
||||
...item,
|
||||
picUrl: item.al?.picUrl,
|
||||
artists: item.ar
|
||||
}));
|
||||
|
||||
if (isLoadMore) {
|
||||
results.value = [...results.value, ...songs];
|
||||
} else {
|
||||
results.value = songs;
|
||||
}
|
||||
|
||||
hasMore.value = songs.length === ITEMS_PER_PAGE;
|
||||
}
|
||||
// 专辑搜索
|
||||
else if (searchType.value === SEARCH_TYPE.ALBUM) {
|
||||
const { data } = await getSearch({
|
||||
keywords: keyword.value,
|
||||
type: searchType.value,
|
||||
limit: ITEMS_PER_PAGE,
|
||||
offset: (page.value - 1) * ITEMS_PER_PAGE
|
||||
});
|
||||
|
||||
const albums = (data.result.albums || []).map((item: any) => ({
|
||||
...item,
|
||||
desc: `${item.artist?.name || ''} ${item.company || ''}`,
|
||||
type: 'album'
|
||||
}));
|
||||
|
||||
if (isLoadMore) {
|
||||
results.value = [...results.value, ...albums];
|
||||
} else {
|
||||
results.value = albums;
|
||||
}
|
||||
|
||||
hasMore.value = albums.length === ITEMS_PER_PAGE;
|
||||
}
|
||||
// 歌单搜索
|
||||
else if (searchType.value === SEARCH_TYPE.PLAYLIST) {
|
||||
const { data } = await getSearch({
|
||||
keywords: keyword.value,
|
||||
type: searchType.value,
|
||||
limit: ITEMS_PER_PAGE,
|
||||
offset: (page.value - 1) * ITEMS_PER_PAGE
|
||||
});
|
||||
|
||||
const playlists = (data.result.playlists || []).map((item: any) => ({
|
||||
...item,
|
||||
picUrl: item.coverImgUrl,
|
||||
playCount: item.playCount,
|
||||
desc: item.creator?.nickname || '',
|
||||
type: 'playlist'
|
||||
}));
|
||||
|
||||
if (isLoadMore) {
|
||||
results.value = [...results.value, ...playlists];
|
||||
} else {
|
||||
results.value = playlists;
|
||||
}
|
||||
|
||||
hasMore.value = playlists.length === ITEMS_PER_PAGE;
|
||||
}
|
||||
// MV 搜索
|
||||
else if (searchType.value === SEARCH_TYPE.MV) {
|
||||
const { data } = await getSearch({
|
||||
keywords: keyword.value,
|
||||
type: searchType.value,
|
||||
limit: ITEMS_PER_PAGE,
|
||||
offset: (page.value - 1) * ITEMS_PER_PAGE
|
||||
});
|
||||
|
||||
const mvs = (data.result.mvs || []).map((item: any) => ({
|
||||
...item,
|
||||
picUrl: item.cover,
|
||||
playCount: item.playCount,
|
||||
desc: item.artists?.map((artist: any) => artist.name).join('/') || '',
|
||||
type: 'mv'
|
||||
}));
|
||||
|
||||
if (isLoadMore) {
|
||||
results.value = [...results.value, ...mvs];
|
||||
} else {
|
||||
results.value = mvs;
|
||||
}
|
||||
|
||||
hasMore.value = mvs.length === ITEMS_PER_PAGE;
|
||||
}
|
||||
|
||||
page.value++;
|
||||
} catch (error) {
|
||||
console.error('搜索失败:', error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
isLoadingMore.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 选择搜索类型
|
||||
const selectType = (type: number) => {
|
||||
if (searchType.value === type) return;
|
||||
|
||||
searchType.value = type;
|
||||
searchStore.searchType = type;
|
||||
|
||||
// 更新路由查询参数
|
||||
router.replace({
|
||||
query: {
|
||||
...route.query,
|
||||
type: type.toString()
|
||||
}
|
||||
});
|
||||
|
||||
performSearch();
|
||||
};
|
||||
|
||||
// 滚动加载更多
|
||||
const handleScroll = (e: Event) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const { scrollTop, scrollHeight, clientHeight } = target;
|
||||
|
||||
if (scrollTop + clientHeight >= scrollHeight - 100) {
|
||||
performSearch(true);
|
||||
}
|
||||
};
|
||||
|
||||
// 播放音乐
|
||||
const handlePlay = (item: any) => {
|
||||
playerStore.addToNextPlay(item);
|
||||
};
|
||||
|
||||
// 播放B站视频
|
||||
const handlePlayBilibili = async (item: IBilibiliSearchResult) => {
|
||||
try {
|
||||
const videoDetail = await getBilibiliVideoDetail(item.bvid);
|
||||
const pages = videoDetail.data.pages;
|
||||
|
||||
if (pages && pages.length === 1) {
|
||||
const audioUrl = await getBilibiliAudioUrl(item.bvid, pages[0].cid);
|
||||
const playItem = createSimpleBilibiliSong(item, audioUrl);
|
||||
playItem.bilibiliData = {
|
||||
bvid: item.bvid,
|
||||
cid: pages[0].cid
|
||||
};
|
||||
playerStore.setPlay(playItem);
|
||||
} else {
|
||||
router.push(`/bilibili/${item.bvid}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('播放B站视频失败:', error);
|
||||
router.push(`/bilibili/${item.bvid}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 返回
|
||||
const goBack = () => {
|
||||
router.back();
|
||||
};
|
||||
|
||||
// 打开搜索
|
||||
const openSearch = () => {
|
||||
router.push('/mobile-search');
|
||||
};
|
||||
|
||||
// 监听路由变化
|
||||
watch(
|
||||
() => route.query,
|
||||
(query) => {
|
||||
if (route.path === '/mobile-search-result' && query.keyword) {
|
||||
keyword.value = query.keyword as string;
|
||||
searchType.value = Number(query.type) || searchStore.searchType || 1;
|
||||
performSearch();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
if (keyword.value) {
|
||||
performSearch();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mobile-search-result {
|
||||
@apply fixed inset-0;
|
||||
@apply bg-light dark:bg-black;
|
||||
@apply flex flex-col;
|
||||
}
|
||||
|
||||
.result-header {
|
||||
@apply flex items-center gap-3 px-4 py-3;
|
||||
@apply border-b border-gray-100 dark:border-gray-800;
|
||||
|
||||
&.safe-area-top {
|
||||
padding-top: calc(var(--safe-area-inset-top, 0px) + 12px);
|
||||
}
|
||||
}
|
||||
|
||||
.header-back {
|
||||
@apply flex items-center justify-center;
|
||||
@apply w-10 h-10 rounded-full text-xl;
|
||||
@apply text-gray-600 dark:text-gray-300;
|
||||
@apply active:bg-gray-100 dark:active:bg-gray-800;
|
||||
}
|
||||
|
||||
.header-keyword {
|
||||
@apply flex-1 text-base font-medium;
|
||||
@apply text-gray-900 dark:text-white;
|
||||
@apply truncate;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
@apply flex items-center gap-2;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
@apply flex items-center justify-center;
|
||||
@apply w-10 h-10 rounded-full text-xl;
|
||||
@apply text-gray-600 dark:text-gray-300;
|
||||
@apply active:bg-gray-100 dark:active:bg-gray-800;
|
||||
}
|
||||
|
||||
.search-types {
|
||||
@apply flex gap-2 px-4 py-3 overflow-x-auto;
|
||||
@apply border-b border-gray-100 dark:border-gray-800;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.type-tag {
|
||||
@apply px-4 py-1.5 rounded-full text-sm whitespace-nowrap;
|
||||
@apply bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300;
|
||||
@apply transition-colors duration-200;
|
||||
|
||||
&.active {
|
||||
@apply bg-green-500 text-white;
|
||||
}
|
||||
}
|
||||
|
||||
.result-content {
|
||||
@apply flex-1 overflow-y-auto;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
@apply flex flex-col items-center justify-center py-20;
|
||||
@apply text-gray-500 dark:text-gray-400;
|
||||
}
|
||||
|
||||
.result-list {
|
||||
@apply pb-20;
|
||||
}
|
||||
|
||||
.loading-more {
|
||||
@apply flex justify-center items-center py-4;
|
||||
@apply text-gray-500 dark:text-gray-400;
|
||||
}
|
||||
|
||||
.no-more {
|
||||
@apply text-center py-4;
|
||||
@apply text-gray-500 dark:text-gray-400;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
@apply flex flex-col items-center justify-center py-20;
|
||||
@apply text-gray-400 dark:text-gray-500;
|
||||
|
||||
i {
|
||||
@apply text-6xl mb-4;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,392 @@
|
||||
<template>
|
||||
<div class="mobile-search-page">
|
||||
<!-- 搜索头部 -->
|
||||
<div class="search-header" :class="{ 'safe-area-top': hasSafeArea }">
|
||||
<div class="header-back" @click="goBack">
|
||||
<i class="ri-arrow-left-s-line"></i>
|
||||
</div>
|
||||
<div class="search-input-wrapper">
|
||||
<i class="ri-search-line search-icon"></i>
|
||||
<input
|
||||
ref="searchInputRef"
|
||||
v-model="searchValue"
|
||||
type="text"
|
||||
class="search-input"
|
||||
:placeholder="hotSearchKeyword"
|
||||
@input="handleInput"
|
||||
@keydown.enter="handleSearch"
|
||||
/>
|
||||
<i v-if="searchValue" class="ri-close-circle-fill clear-icon" @click="clearSearch"></i>
|
||||
</div>
|
||||
<div class="search-button" @click="handleSearch">
|
||||
{{ t('common.search') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索类型标签 -->
|
||||
<div class="search-types">
|
||||
<div
|
||||
v-for="type in searchTypes"
|
||||
:key="type.key"
|
||||
class="type-tag"
|
||||
:class="{ active: searchType === type.key }"
|
||||
@click="selectType(type.key)"
|
||||
>
|
||||
{{ type.label }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索内容区域 -->
|
||||
<div class="search-content">
|
||||
<!-- 搜索建议 -->
|
||||
<div v-if="suggestions.length > 0" class="search-section">
|
||||
<div class="section-title">{{ t('search.suggestions') }}</div>
|
||||
<div class="suggestion-list">
|
||||
<div
|
||||
v-for="(item, index) in suggestions"
|
||||
:key="index"
|
||||
class="suggestion-item"
|
||||
@click="selectSuggestion(item)"
|
||||
>
|
||||
<i class="ri-search-line"></i>
|
||||
<span>{{ item }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索历史 -->
|
||||
<div v-else-if="searchHistory.length > 0" class="search-section">
|
||||
<div class="section-header">
|
||||
<span class="section-title">{{ t('search.history') }}</span>
|
||||
<span class="clear-history" @click="clearHistory">{{ t('common.clear') }}</span>
|
||||
</div>
|
||||
<div class="history-tags">
|
||||
<div
|
||||
v-for="(item, index) in searchHistory"
|
||||
:key="index"
|
||||
class="history-tag"
|
||||
@click="selectSuggestion(item)"
|
||||
>
|
||||
{{ item }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 热门搜索 -->
|
||||
<div v-if="hotSearchList.length > 0 && !searchValue" class="search-section">
|
||||
<div class="section-title">{{ t('search.hot') }}</div>
|
||||
<div class="hot-list">
|
||||
<div
|
||||
v-for="(item, index) in hotSearchList"
|
||||
:key="index"
|
||||
class="hot-item"
|
||||
@click="selectSuggestion(item.searchWord)"
|
||||
>
|
||||
<span class="hot-rank" :class="{ top: index < 3 }">{{ index + 1 }}</span>
|
||||
<span class="hot-word">{{ item.searchWord }}</span>
|
||||
<span v-if="item.iconUrl" class="hot-icon">
|
||||
<img :src="item.iconUrl" alt="" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useDebounceFn } from '@vueuse/core';
|
||||
import { computed, inject, nextTick, onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { getHotSearch, getSearchKeyword } from '@/api/home';
|
||||
import { getSearchSuggestions } from '@/api/search';
|
||||
import { SEARCH_TYPES } from '@/const/bar-const';
|
||||
import { useSearchStore } from '@/store/modules/search';
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const router = useRouter();
|
||||
const searchStore = useSearchStore();
|
||||
|
||||
// 注入是否有安全区域
|
||||
const hasSafeArea = inject('hasSafeArea', false);
|
||||
|
||||
// 搜索值
|
||||
const searchValue = ref('');
|
||||
const searchInputRef = ref<HTMLInputElement | null>(null);
|
||||
|
||||
// 热门搜索关键词占位符
|
||||
const hotSearchKeyword = ref('搜索音乐、歌手、歌单');
|
||||
|
||||
// 搜索类型
|
||||
const searchType = ref(searchStore.searchType || 1);
|
||||
const searchTypes = computed(() => {
|
||||
locale.value;
|
||||
return SEARCH_TYPES.map((type) => ({
|
||||
label: t(type.label),
|
||||
key: type.key
|
||||
}));
|
||||
});
|
||||
|
||||
// 搜索建议
|
||||
const suggestions = ref<string[]>([]);
|
||||
|
||||
// 搜索历史
|
||||
const HISTORY_KEY = 'mobile_search_history';
|
||||
const searchHistory = ref<string[]>([]);
|
||||
|
||||
// 热门搜索
|
||||
const hotSearchList = ref<any[]>([]);
|
||||
|
||||
// 加载热门搜索关键词
|
||||
const loadHotSearchKeyword = async () => {
|
||||
try {
|
||||
const { data } = await getSearchKeyword();
|
||||
hotSearchKeyword.value = data.data.showKeyword;
|
||||
} catch (e) {
|
||||
console.error('加载热门搜索关键词失败:', e);
|
||||
}
|
||||
};
|
||||
|
||||
// 加载热门搜索列表
|
||||
const loadHotSearchList = async () => {
|
||||
try {
|
||||
const { data } = await getHotSearch();
|
||||
hotSearchList.value = data.data || [];
|
||||
} catch (e) {
|
||||
console.error('加载热门搜索失败:', e);
|
||||
}
|
||||
};
|
||||
|
||||
// 加载搜索历史
|
||||
const loadSearchHistory = () => {
|
||||
try {
|
||||
const history = localStorage.getItem(HISTORY_KEY);
|
||||
searchHistory.value = history ? JSON.parse(history) : [];
|
||||
} catch (e) {
|
||||
console.error('加载搜索历史失败:', e);
|
||||
searchHistory.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
// 保存搜索历史
|
||||
const saveSearchHistory = (keyword: string) => {
|
||||
if (!keyword.trim()) return;
|
||||
|
||||
// 移除重复项并添加到开头
|
||||
const history = searchHistory.value.filter((item) => item !== keyword);
|
||||
history.unshift(keyword);
|
||||
|
||||
// 最多保存20条
|
||||
searchHistory.value = history.slice(0, 20);
|
||||
localStorage.setItem(HISTORY_KEY, JSON.stringify(searchHistory.value));
|
||||
};
|
||||
|
||||
// 清除搜索历史
|
||||
const clearHistory = () => {
|
||||
searchHistory.value = [];
|
||||
localStorage.removeItem(HISTORY_KEY);
|
||||
};
|
||||
|
||||
// 获取搜索建议(防抖)
|
||||
const debouncedGetSuggestions = useDebounceFn(async (keyword: string) => {
|
||||
if (!keyword.trim()) {
|
||||
suggestions.value = [];
|
||||
return;
|
||||
}
|
||||
suggestions.value = await getSearchSuggestions(keyword);
|
||||
}, 300);
|
||||
|
||||
// 处理输入
|
||||
const handleInput = () => {
|
||||
debouncedGetSuggestions(searchValue.value);
|
||||
};
|
||||
|
||||
// 清除搜索
|
||||
const clearSearch = () => {
|
||||
searchValue.value = '';
|
||||
suggestions.value = [];
|
||||
};
|
||||
|
||||
// 选择搜索类型
|
||||
const selectType = (type: number) => {
|
||||
searchType.value = type;
|
||||
searchStore.searchType = type;
|
||||
};
|
||||
|
||||
// 选择建议
|
||||
const selectSuggestion = (keyword: string) => {
|
||||
searchValue.value = keyword;
|
||||
handleSearch();
|
||||
};
|
||||
|
||||
// 执行搜索
|
||||
const handleSearch = () => {
|
||||
const keyword = searchValue.value.trim();
|
||||
if (!keyword) return;
|
||||
|
||||
// 保存搜索历史
|
||||
saveSearchHistory(keyword);
|
||||
|
||||
// 跳转到搜索结果页
|
||||
router.push({
|
||||
path: '/mobile-search-result',
|
||||
query: {
|
||||
keyword,
|
||||
type: searchType.value
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 返回上一页
|
||||
const goBack = () => {
|
||||
router.back();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadHotSearchKeyword();
|
||||
loadHotSearchList();
|
||||
loadSearchHistory();
|
||||
nextTick(() => {
|
||||
searchInputRef.value?.focus();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mobile-search-page {
|
||||
@apply fixed inset-0 z-50;
|
||||
@apply bg-light dark:bg-black;
|
||||
@apply flex flex-col;
|
||||
}
|
||||
|
||||
.search-header {
|
||||
@apply flex items-center gap-3 pl-1 pr-3 py-3;
|
||||
@apply border-b border-gray-100 dark:border-gray-800;
|
||||
|
||||
&.safe-area-top {
|
||||
padding-top: calc(var(--safe-area-inset-top, 0px) + 12px);
|
||||
}
|
||||
}
|
||||
|
||||
.header-back {
|
||||
@apply flex items-center justify-center;
|
||||
@apply w-8 h-8 rounded-full text-2xl;
|
||||
@apply text-gray-600 dark:text-gray-300;
|
||||
@apply active:bg-gray-100 dark:active:bg-gray-800;
|
||||
}
|
||||
|
||||
.search-input-wrapper {
|
||||
@apply flex-1 flex items-center gap-2;
|
||||
@apply bg-gray-100 dark:bg-gray-800 rounded-full;
|
||||
@apply px-4 py-1;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
@apply text-gray-400 text-lg;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
@apply flex-1 bg-transparent border-none outline-none;
|
||||
@apply text-gray-900 dark:text-white text-base;
|
||||
|
||||
&::placeholder {
|
||||
@apply text-gray-400;
|
||||
}
|
||||
}
|
||||
|
||||
.clear-icon {
|
||||
@apply text-gray-400 text-lg cursor-pointer;
|
||||
}
|
||||
|
||||
.search-types {
|
||||
@apply flex gap-2 px-4 py-3 overflow-x-auto;
|
||||
@apply border-b border-gray-100 dark:border-gray-800;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.type-tag {
|
||||
@apply px-4 py-1.5 rounded-full text-sm whitespace-nowrap;
|
||||
@apply bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300;
|
||||
@apply transition-colors duration-200;
|
||||
|
||||
&.active {
|
||||
@apply bg-green-500 text-white;
|
||||
}
|
||||
}
|
||||
|
||||
.search-content {
|
||||
@apply flex-1 overflow-y-auto px-4 py-3;
|
||||
}
|
||||
|
||||
.search-section {
|
||||
@apply mb-6;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
@apply flex items-center justify-between mb-3;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
@apply text-sm font-medium text-gray-500 dark:text-gray-400 mb-3;
|
||||
}
|
||||
|
||||
.clear-history {
|
||||
@apply text-sm text-gray-400 dark:text-gray-500;
|
||||
}
|
||||
|
||||
.suggestion-list {
|
||||
@apply space-y-1;
|
||||
}
|
||||
|
||||
.suggestion-item {
|
||||
@apply flex items-center gap-3 py-3;
|
||||
@apply text-gray-700 dark:text-gray-200;
|
||||
@apply active:bg-gray-50 dark:active:bg-gray-800;
|
||||
|
||||
i {
|
||||
@apply text-gray-400;
|
||||
}
|
||||
}
|
||||
|
||||
.history-tags {
|
||||
@apply flex flex-wrap gap-2;
|
||||
}
|
||||
|
||||
.history-tag {
|
||||
@apply px-3 py-1.5 rounded-full text-sm;
|
||||
@apply bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300;
|
||||
@apply active:bg-gray-200 dark:active:bg-gray-700;
|
||||
}
|
||||
|
||||
.hot-list {
|
||||
@apply space-y-1;
|
||||
}
|
||||
|
||||
.hot-item {
|
||||
@apply flex items-center gap-3 py-2.5;
|
||||
@apply active:bg-gray-50 dark:active:bg-gray-800;
|
||||
}
|
||||
|
||||
.hot-rank {
|
||||
@apply w-5 text-center text-sm font-medium text-gray-400;
|
||||
|
||||
&.top {
|
||||
@apply text-red-500;
|
||||
}
|
||||
}
|
||||
|
||||
.hot-word {
|
||||
@apply flex-1 text-gray-700 dark:text-gray-200;
|
||||
}
|
||||
|
||||
.hot-icon {
|
||||
img {
|
||||
@apply h-4;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user