feat:针对移动端优化

This commit is contained in:
alger
2025-12-19 00:23:24 +08:00
parent 70f1044dd9
commit 8e1259d2aa
18 changed files with 2299 additions and 189 deletions
+10 -8
View File
@@ -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>
+1 -1
View File
@@ -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>
+392
View File
@@ -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>