mirror of
https://github.com/algerkong/AlgerMusicPlayer.git
synced 2026-04-28 02:47:22 +08:00
feat(ui): 重构 SearchBar、集成 useScrollTitle 标题滚动显示、修复专辑搜索跳转
- 重新设计 SearchBar:左侧 Tab(播放列表/MV/排行榜)+ 滑动指示器 + 搜索框自动展开收缩 - 新增 navTitle store 和 useScrollTitle hook,支持页面滚动后在 SearchBar 显示标题 - 集成 useScrollTitle 到 MusicListPage、歌手详情、关注/粉丝列表、搜索结果页 - 修复搜索结果页专辑点击跳转失败(缺失 type 字段) - 新增 5 种语言 searchBar tab i18n 键值
This commit is contained in:
@@ -173,7 +173,11 @@ export default {
|
|||||||
zoom: 'Zoom',
|
zoom: 'Zoom',
|
||||||
zoom100: 'Zoom 100%',
|
zoom100: 'Zoom 100%',
|
||||||
resetZoom: 'Reset Zoom',
|
resetZoom: 'Reset Zoom',
|
||||||
zoomDefault: 'Default Zoom'
|
zoomDefault: 'Default Zoom',
|
||||||
|
tabPlaylist: 'Playlist',
|
||||||
|
tabMv: 'MV',
|
||||||
|
tabCharts: 'Charts',
|
||||||
|
cancelSearch: 'Cancel'
|
||||||
},
|
},
|
||||||
titleBar: {
|
titleBar: {
|
||||||
closeTitle: 'Choose how to close',
|
closeTitle: 'Choose how to close',
|
||||||
@@ -199,6 +203,7 @@ export default {
|
|||||||
addToPlaylistSuccess: 'Add to Playlist Success',
|
addToPlaylistSuccess: 'Add to Playlist Success',
|
||||||
operationFailed: 'Operation Failed',
|
operationFailed: 'Operation Failed',
|
||||||
songsAlreadyInPlaylist: 'Songs already in playlist',
|
songsAlreadyInPlaylist: 'Songs already in playlist',
|
||||||
|
locateCurrent: 'Locate current song',
|
||||||
historyRecommend: 'Daily History',
|
historyRecommend: 'Daily History',
|
||||||
fetchDatesFailed: 'Failed to fetch dates',
|
fetchDatesFailed: 'Failed to fetch dates',
|
||||||
fetchSongsFailed: 'Failed to fetch songs',
|
fetchSongsFailed: 'Failed to fetch songs',
|
||||||
|
|||||||
@@ -173,7 +173,11 @@ export default {
|
|||||||
zoom: 'ページズーム',
|
zoom: 'ページズーム',
|
||||||
zoom100: '標準ズーム100%',
|
zoom100: '標準ズーム100%',
|
||||||
resetZoom: 'クリックしてズームをリセット',
|
resetZoom: 'クリックしてズームをリセット',
|
||||||
zoomDefault: '標準ズーム'
|
zoomDefault: '標準ズーム',
|
||||||
|
tabPlaylist: 'プレイリスト',
|
||||||
|
tabMv: 'MV',
|
||||||
|
tabCharts: 'チャート',
|
||||||
|
cancelSearch: 'キャンセル'
|
||||||
},
|
},
|
||||||
titleBar: {
|
titleBar: {
|
||||||
closeTitle: '閉じる方法を選択してください',
|
closeTitle: '閉じる方法を選択してください',
|
||||||
@@ -199,6 +203,7 @@ export default {
|
|||||||
addToPlaylist: 'プレイリストに追加',
|
addToPlaylist: 'プレイリストに追加',
|
||||||
addToPlaylistSuccess: 'プレイリストに追加しました',
|
addToPlaylistSuccess: 'プレイリストに追加しました',
|
||||||
songsAlreadyInPlaylist: '楽曲は既にプレイリストに存在します',
|
songsAlreadyInPlaylist: '楽曲は既にプレイリストに存在します',
|
||||||
|
locateCurrent: '再生中の曲を表示',
|
||||||
historyRecommend: '履歴の日次推薦',
|
historyRecommend: '履歴の日次推薦',
|
||||||
fetchDatesFailed: '日付リストの取得に失敗しました',
|
fetchDatesFailed: '日付リストの取得に失敗しました',
|
||||||
fetchSongsFailed: '楽曲リストの取得に失敗しました',
|
fetchSongsFailed: '楽曲リストの取得に失敗しました',
|
||||||
|
|||||||
@@ -172,7 +172,11 @@ export default {
|
|||||||
zoom: '페이지 확대/축소',
|
zoom: '페이지 확대/축소',
|
||||||
zoom100: '표준 확대/축소 100%',
|
zoom100: '표준 확대/축소 100%',
|
||||||
resetZoom: '클릭하여 확대/축소 재설정',
|
resetZoom: '클릭하여 확대/축소 재설정',
|
||||||
zoomDefault: '표준 확대/축소'
|
zoomDefault: '표준 확대/축소',
|
||||||
|
tabPlaylist: '플레이리스트',
|
||||||
|
tabMv: 'MV',
|
||||||
|
tabCharts: '차트',
|
||||||
|
cancelSearch: '취소'
|
||||||
},
|
},
|
||||||
titleBar: {
|
titleBar: {
|
||||||
closeTitle: '닫기 방법을 선택해주세요',
|
closeTitle: '닫기 방법을 선택해주세요',
|
||||||
@@ -198,6 +202,7 @@ export default {
|
|||||||
addToPlaylist: '재생 목록에 추가',
|
addToPlaylist: '재생 목록에 추가',
|
||||||
addToPlaylistSuccess: '재생 목록에 추가 성공',
|
addToPlaylistSuccess: '재생 목록에 추가 성공',
|
||||||
songsAlreadyInPlaylist: '곡이 이미 재생 목록에 있습니다',
|
songsAlreadyInPlaylist: '곡이 이미 재생 목록에 있습니다',
|
||||||
|
locateCurrent: '현재 재생 곡 찾기',
|
||||||
historyRecommend: '일일 기록 권장',
|
historyRecommend: '일일 기록 권장',
|
||||||
fetchDatesFailed: '날짜를 가져오지 못했습니다',
|
fetchDatesFailed: '날짜를 가져오지 못했습니다',
|
||||||
fetchSongsFailed: '곡을 가져오지 못했습니다',
|
fetchSongsFailed: '곡을 가져오지 못했습니다',
|
||||||
|
|||||||
@@ -166,7 +166,11 @@ export default {
|
|||||||
zoom: '页面缩放',
|
zoom: '页面缩放',
|
||||||
zoom100: '标准缩放100%',
|
zoom100: '标准缩放100%',
|
||||||
resetZoom: '点击重置缩放',
|
resetZoom: '点击重置缩放',
|
||||||
zoomDefault: '标准缩放'
|
zoomDefault: '标准缩放',
|
||||||
|
tabPlaylist: '播放列表',
|
||||||
|
tabMv: 'MV',
|
||||||
|
tabCharts: '排行榜',
|
||||||
|
cancelSearch: '取消'
|
||||||
},
|
},
|
||||||
titleBar: {
|
titleBar: {
|
||||||
closeTitle: '请选择关闭方式',
|
closeTitle: '请选择关闭方式',
|
||||||
@@ -192,6 +196,7 @@ export default {
|
|||||||
addToPlaylist: '添加到播放列表',
|
addToPlaylist: '添加到播放列表',
|
||||||
addToPlaylistSuccess: '添加到播放列表成功',
|
addToPlaylistSuccess: '添加到播放列表成功',
|
||||||
songsAlreadyInPlaylist: '歌曲已存在于播放列表中',
|
songsAlreadyInPlaylist: '歌曲已存在于播放列表中',
|
||||||
|
locateCurrent: '定位当前播放',
|
||||||
historyRecommend: '历史日推',
|
historyRecommend: '历史日推',
|
||||||
fetchDatesFailed: '获取日期列表失败',
|
fetchDatesFailed: '获取日期列表失败',
|
||||||
fetchSongsFailed: '获取歌曲列表失败',
|
fetchSongsFailed: '获取歌曲列表失败',
|
||||||
|
|||||||
@@ -166,7 +166,11 @@ export default {
|
|||||||
zoom: '頁面縮放',
|
zoom: '頁面縮放',
|
||||||
zoom100: '標準縮放100%',
|
zoom100: '標準縮放100%',
|
||||||
resetZoom: '點擊重設縮放',
|
resetZoom: '點擊重設縮放',
|
||||||
zoomDefault: '標準縮放'
|
zoomDefault: '標準縮放',
|
||||||
|
tabPlaylist: '播放清單',
|
||||||
|
tabMv: 'MV',
|
||||||
|
tabCharts: '排行榜',
|
||||||
|
cancelSearch: '取消'
|
||||||
},
|
},
|
||||||
titleBar: {
|
titleBar: {
|
||||||
closeTitle: '請選擇關閉方式',
|
closeTitle: '請選擇關閉方式',
|
||||||
@@ -192,6 +196,7 @@ export default {
|
|||||||
addToPlaylist: '新增至播放清單',
|
addToPlaylist: '新增至播放清單',
|
||||||
addToPlaylistSuccess: '新增至播放清單成功',
|
addToPlaylistSuccess: '新增至播放清單成功',
|
||||||
songsAlreadyInPlaylist: '歌曲已存在於播放清單中',
|
songsAlreadyInPlaylist: '歌曲已存在於播放清單中',
|
||||||
|
locateCurrent: '定位當前播放',
|
||||||
historyRecommend: '歷史日推',
|
historyRecommend: '歷史日推',
|
||||||
fetchDatesFailed: '獲取日期列表失敗',
|
fetchDatesFailed: '獲取日期列表失敗',
|
||||||
fetchSongsFailed: '獲取歌曲列表失敗',
|
fetchSongsFailed: '獲取歌曲列表失敗',
|
||||||
|
|||||||
@@ -103,7 +103,10 @@ const handleClick = async () => {
|
|||||||
id: props.item.id,
|
id: props.item.id,
|
||||||
type: 'album',
|
type: 'album',
|
||||||
name: props.item.name,
|
name: props.item.name,
|
||||||
listInfo: { picUrl: props.item.picUrl },
|
listInfo: {
|
||||||
|
...props.item,
|
||||||
|
coverImgUrl: props.item.picUrl
|
||||||
|
},
|
||||||
canRemove: false
|
canRemove: false
|
||||||
});
|
});
|
||||||
} else if (props.item.type === 'playlist') {
|
} else if (props.item.type === 'playlist') {
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { isRef, onMounted, onUnmounted, Ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import { useNavTitleStore } from '@/store/modules/navTitle';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 页面标题滚动监听 hook
|
||||||
|
*
|
||||||
|
* 当 titleEl 元素滚出视口时,在 SearchBar 中显示标题。
|
||||||
|
* 滚回视口后自动隐藏 SearchBar 中的标题。
|
||||||
|
*
|
||||||
|
* @param title 标题文本(字符串或 Ref<string>)
|
||||||
|
* @param titleEl 需要被监听的标题 DOM 元素(通常是页面 h1/h2)
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // 在页面组件中:
|
||||||
|
* const titleRef = ref<HTMLElement | null>(null);
|
||||||
|
* useScrollTitle('歌单名称', titleRef);
|
||||||
|
* // 或响应式标题:
|
||||||
|
* useScrollTitle(computed(() => playlist.value?.name ?? ''), titleRef);
|
||||||
|
*/
|
||||||
|
export function useScrollTitle(title: string | Ref<string>, titleEl: Ref<HTMLElement | null>) {
|
||||||
|
const store = useNavTitleStore();
|
||||||
|
let observer: IntersectionObserver | null = null;
|
||||||
|
|
||||||
|
const setupObserver = (el: HTMLElement) => {
|
||||||
|
observer?.disconnect();
|
||||||
|
observer = new IntersectionObserver(([entry]) => store.setVisible(!entry.isIntersecting), {
|
||||||
|
threshold: 0,
|
||||||
|
rootMargin: '-56px 0px 0px 0px'
|
||||||
|
});
|
||||||
|
observer.observe(el);
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// 设置初始标题
|
||||||
|
store.setTitle(isRef(title) ? title.value : title);
|
||||||
|
|
||||||
|
// 等待 DOM 就绪后启动 observer
|
||||||
|
if (titleEl.value) {
|
||||||
|
setupObserver(titleEl.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 响应式标题:当 Ref<string> 变化时同步更新 store
|
||||||
|
if (isRef(title)) {
|
||||||
|
watch(title, (v) => store.setTitle(v));
|
||||||
|
}
|
||||||
|
|
||||||
|
// titleEl 延迟挂载时补充 observer
|
||||||
|
watch(titleEl, (el) => {
|
||||||
|
if (el) setupObserver(el);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
observer?.disconnect();
|
||||||
|
store.clear();
|
||||||
|
});
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -21,6 +21,7 @@ export * from './modules/localMusic';
|
|||||||
export * from './modules/lyric';
|
export * from './modules/lyric';
|
||||||
export * from './modules/menu';
|
export * from './modules/menu';
|
||||||
export * from './modules/music';
|
export * from './modules/music';
|
||||||
|
export * from './modules/navTitle';
|
||||||
export * from './modules/player';
|
export * from './modules/player';
|
||||||
export * from './modules/playerCore';
|
export * from './modules/playerCore';
|
||||||
export * from './modules/playHistory';
|
export * from './modules/playHistory';
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导航栏标题 store
|
||||||
|
* 用于页面滚动后在 SearchBar 中显示当前页面标题
|
||||||
|
* 无持久化,页面卸载时自动清除
|
||||||
|
*/
|
||||||
|
export const useNavTitleStore = defineStore('navTitle', () => {
|
||||||
|
const title = ref('');
|
||||||
|
const isVisible = ref(false);
|
||||||
|
|
||||||
|
const setTitle = (t: string) => {
|
||||||
|
title.value = t;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setVisible = (v: boolean) => {
|
||||||
|
isVisible.value = v;
|
||||||
|
};
|
||||||
|
|
||||||
|
const clear = () => {
|
||||||
|
title.value = '';
|
||||||
|
isVisible.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
return { title, isVisible, setTitle, setVisible, clear };
|
||||||
|
});
|
||||||
@@ -100,6 +100,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<h1
|
<h1
|
||||||
|
ref="titleElRef"
|
||||||
class="artist-name text-3xl md:text-4xl lg:text-5xl font-bold text-neutral-900 dark:text-white tracking-tight"
|
class="artist-name text-3xl md:text-4xl lg:text-5xl font-bold text-neutral-900 dark:text-white tracking-tight"
|
||||||
>
|
>
|
||||||
{{ artistInfo.name }}
|
{{ artistInfo.name }}
|
||||||
@@ -434,6 +435,7 @@ import { getMusicDetail } from '@/api/music';
|
|||||||
import { navigateToMusicList } from '@/components/common/MusicListNavigator';
|
import { navigateToMusicList } from '@/components/common/MusicListNavigator';
|
||||||
import PlayBottom from '@/components/common/PlayBottom.vue';
|
import PlayBottom from '@/components/common/PlayBottom.vue';
|
||||||
import SongItem from '@/components/common/SongItem.vue';
|
import SongItem from '@/components/common/SongItem.vue';
|
||||||
|
import { useScrollTitle } from '@/hooks/useScrollTitle';
|
||||||
import router from '@/router';
|
import router from '@/router';
|
||||||
import { usePlayerStore } from '@/store';
|
import { usePlayerStore } from '@/store';
|
||||||
import { IArtist } from '@/types/artist';
|
import { IArtist } from '@/types/artist';
|
||||||
@@ -465,6 +467,10 @@ const artistInfo = ref<IArtist>();
|
|||||||
const songs = ref<any[]>([]);
|
const songs = ref<any[]>([]);
|
||||||
const albums = ref<any[]>([]);
|
const albums = ref<any[]>([]);
|
||||||
|
|
||||||
|
const titleElRef = ref<HTMLElement | null>(null);
|
||||||
|
const artistTitle = computed(() => artistInfo.value?.name ?? '');
|
||||||
|
useScrollTitle(artistTitle, titleElRef);
|
||||||
|
|
||||||
// 加载状态
|
// 加载状态
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const songLoading = ref(false);
|
const songLoading = ref(false);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="music-list-page h-full w-full bg-white dark:bg-black transition-colors duration-500">
|
<div class="music-list-page h-full w-full bg-white dark:bg-black transition-colors duration-500">
|
||||||
<n-scrollbar class="h-full" @scroll="handleScroll">
|
<n-scrollbar ref="scrollbarRef" class="h-full" @scroll="handleScroll">
|
||||||
<div class="music-list-content pb-32">
|
<div class="music-list-content pb-32">
|
||||||
<!-- Hero Section 和 Action Bar -->
|
<!-- Hero Section 和 Action Bar -->
|
||||||
<n-spin :show="loading">
|
<n-spin :show="loading">
|
||||||
@@ -63,6 +63,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<h1
|
<h1
|
||||||
|
ref="titleElRef"
|
||||||
class="playlist-name text-3xl md:text-4xl lg:text-5xl font-bold text-neutral-900 dark:text-white tracking-tight mb-4"
|
class="playlist-name text-3xl md:text-4xl lg:text-5xl font-bold text-neutral-900 dark:text-white tracking-tight mb-4"
|
||||||
>
|
>
|
||||||
{{ name }}
|
{{ name }}
|
||||||
@@ -212,6 +213,16 @@
|
|||||||
</n-input>
|
</n-input>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Locate Current Song -->
|
||||||
|
<button
|
||||||
|
v-if="currentPlayingIndex >= 0"
|
||||||
|
class="action-btn-icon w-10 h-10 rounded-full flex items-center justify-center bg-neutral-100 dark:bg-neutral-900 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-200 dark:hover:bg-neutral-800 transition-all"
|
||||||
|
:title="t('comp.musicList.locateCurrent', '定位当前播放')"
|
||||||
|
@click="scrollToCurrentSong"
|
||||||
|
>
|
||||||
|
<i class="ri-focus-3-line text-lg" />
|
||||||
|
</button>
|
||||||
|
|
||||||
<!-- Layout Toggle -->
|
<!-- Layout Toggle -->
|
||||||
<button
|
<button
|
||||||
v-if="!isMobile"
|
v-if="!isMobile"
|
||||||
@@ -226,36 +237,59 @@
|
|||||||
|
|
||||||
<!-- List Content -->
|
<!-- List Content -->
|
||||||
<section class="song-list-section page-padding-x mt-6">
|
<section class="song-list-section page-padding-x mt-6">
|
||||||
<n-spin :show="loadingList">
|
<div
|
||||||
|
v-if="filteredSongs.length === 0 && searchKeyword"
|
||||||
|
class="empty-state py-20 text-center text-neutral-400"
|
||||||
|
>
|
||||||
|
<i class="ri-search-line text-4xl mb-4 opacity-20" />
|
||||||
|
<p>{{ t('comp.musicList.noSearchResults') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="song-list-container">
|
||||||
<div
|
<div
|
||||||
v-if="filteredSongs.length === 0 && searchKeyword"
|
v-for="(item, index) in filteredSongs"
|
||||||
class="empty-state py-20 text-center text-neutral-400"
|
:key="item.id"
|
||||||
|
class="mb-2"
|
||||||
|
:class="{ 'animate-item': index < initialAnimateCount }"
|
||||||
|
:style="
|
||||||
|
index < initialAnimateCount
|
||||||
|
? { animationDelay: calculateAnimationDelay(index, 0.03) }
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
>
|
>
|
||||||
<i class="ri-search-line text-4xl mb-4 opacity-20" />
|
<song-item
|
||||||
<p>{{ t('comp.musicList.noSearchResults') }}</p>
|
:index="index"
|
||||||
|
:compact="isCompactLayout"
|
||||||
|
:item="formatSong(item)"
|
||||||
|
:can-remove="canRemove"
|
||||||
|
:selectable="isSelecting"
|
||||||
|
:selected="selectedSongs.includes(item.id as number)"
|
||||||
|
@play="handlePlayItem(item)"
|
||||||
|
@remove-song="handleRemoveSong"
|
||||||
|
@select="(id, selected) => handleSelect(id, selected)"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="song-list-container">
|
<!-- 未渲染项占位,保持滚动条高度稳定 -->
|
||||||
<div
|
<div v-if="placeholderHeight > 0" :style="{ height: placeholderHeight + 'px' }" />
|
||||||
v-for="(item, index) in filteredSongs"
|
|
||||||
:key="item.id"
|
<!-- 底部加载指示器 -->
|
||||||
class="mb-2 animate-item"
|
<div v-if="loadingList" class="flex items-center justify-center py-6 gap-2">
|
||||||
:style="{ animationDelay: calculateAnimationDelay(index % 20, 0.03) }"
|
<n-spin :size="18" />
|
||||||
>
|
<span class="text-sm text-neutral-400">{{ t('common.loading') }}</span>
|
||||||
<song-item
|
|
||||||
:index="index"
|
|
||||||
:compact="isCompactLayout"
|
|
||||||
:item="formatSong(item)"
|
|
||||||
:can-remove="canRemove"
|
|
||||||
:selectable="isSelecting"
|
|
||||||
:selected="selectedSongs.includes(item.id as number)"
|
|
||||||
@play="handlePlayItem(item)"
|
|
||||||
@remove-song="handleRemoveSong"
|
|
||||||
@select="(id, selected) => handleSelect(id, selected)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</n-spin>
|
<div
|
||||||
|
v-else-if="
|
||||||
|
!hasMore &&
|
||||||
|
renderLimit >= allFilteredSongs.length &&
|
||||||
|
filteredSongs.length > 0 &&
|
||||||
|
!searchKeyword
|
||||||
|
"
|
||||||
|
class="py-6 text-center text-sm text-neutral-300 dark:text-neutral-600"
|
||||||
|
>
|
||||||
|
— {{ t('common.noMore') }} —
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</n-scrollbar>
|
</n-scrollbar>
|
||||||
@@ -266,7 +300,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useMessage } from 'naive-ui';
|
import { useMessage } from 'naive-ui';
|
||||||
import PinyinMatch from 'pinyin-match';
|
import PinyinMatch from 'pinyin-match';
|
||||||
import { computed, onMounted, ref, watch } from 'vue';
|
import { computed, nextTick, onMounted, ref, watch } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
|
|
||||||
@@ -280,6 +314,7 @@ import {
|
|||||||
import PlayBottom from '@/components/common/PlayBottom.vue';
|
import PlayBottom from '@/components/common/PlayBottom.vue';
|
||||||
import SongItem from '@/components/common/SongItem.vue';
|
import SongItem from '@/components/common/SongItem.vue';
|
||||||
import { useDownload } from '@/hooks/useDownload';
|
import { useDownload } from '@/hooks/useDownload';
|
||||||
|
import { useScrollTitle } from '@/hooks/useScrollTitle';
|
||||||
import { useMusicStore, usePlayerStore, useRecommendStore, useUserStore } from '@/store';
|
import { useMusicStore, usePlayerStore, useRecommendStore, useUserStore } from '@/store';
|
||||||
import { usePlayHistoryStore } from '@/store/modules/playHistory';
|
import { usePlayHistoryStore } from '@/store/modules/playHistory';
|
||||||
import { SongResult } from '@/types/music';
|
import { SongResult } from '@/types/music';
|
||||||
@@ -370,6 +405,9 @@ const name = computed(() => {
|
|||||||
return musicStore.currentMusicListName || '';
|
return musicStore.currentMusicListName || '';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const titleElRef = ref<HTMLElement | null>(null);
|
||||||
|
useScrollTitle(name, titleElRef);
|
||||||
|
|
||||||
const songList = computed(() => {
|
const songList = computed(() => {
|
||||||
if (isDailyRecommend.value) return recommendStore.dailyRecommendSongs;
|
if (isDailyRecommend.value) return recommendStore.dailyRecommendSongs;
|
||||||
return musicStore.currentMusicList || [];
|
return musicStore.currentMusicList || [];
|
||||||
@@ -388,7 +426,9 @@ const canRemove = computed(() => {
|
|||||||
const canCollect = ref(false);
|
const canCollect = ref(false);
|
||||||
const isCollected = ref(false);
|
const isCollected = ref(false);
|
||||||
const pageSize = 40;
|
const pageSize = 40;
|
||||||
|
const initialAnimateCount = 20; // 仅前 20 项有入场动画
|
||||||
const displayedSongs = ref<SongResult[]>([]);
|
const displayedSongs = ref<SongResult[]>([]);
|
||||||
|
const renderLimit = ref(pageSize); // DOM 渲染上限,数据全部在内存
|
||||||
const loadingList = ref(false);
|
const loadingList = ref(false);
|
||||||
const loadedIds = ref(new Set<number>());
|
const loadedIds = ref(new Set<number>());
|
||||||
const isPlaylistLoading = ref(false);
|
const isPlaylistLoading = ref(false);
|
||||||
@@ -417,24 +457,37 @@ const getCoverImgUrl = computed(() => {
|
|||||||
return song?.picUrl || song?.al?.picUrl || song?.album?.picUrl || '';
|
return song?.picUrl || song?.al?.picUrl || song?.album?.picUrl || '';
|
||||||
});
|
});
|
||||||
|
|
||||||
const filteredSongs = computed(() => {
|
// 全量歌曲列表(用于"播放全部"等操作)
|
||||||
|
const allFilteredSongs = computed(() => {
|
||||||
const sourceList = isDailyRecommend.value ? songList.value : displayedSongs.value;
|
const sourceList = isDailyRecommend.value ? songList.value : displayedSongs.value;
|
||||||
const dislikeFilteredList = sourceList.filter((s) => !playerStore.dislikeList.includes(s.id));
|
return sourceList.filter((s) => !playerStore.dislikeList.includes(s.id));
|
||||||
|
});
|
||||||
|
|
||||||
if (!searchKeyword.value) return dislikeFilteredList;
|
// 实际渲染到 DOM 的歌曲(搜索时显示全部匹配,非搜索时按 renderLimit 分页渲染)
|
||||||
|
const filteredSongs = computed(() => {
|
||||||
|
if (searchKeyword.value) {
|
||||||
|
const keyword = searchKeyword.value.toLowerCase().trim();
|
||||||
|
return allFilteredSongs.value.filter((song) => {
|
||||||
|
const songName = song.name?.toLowerCase() || '';
|
||||||
|
const albumName = song.al?.name?.toLowerCase() || '';
|
||||||
|
const artists = song.ar || song.artists || [];
|
||||||
|
return (
|
||||||
|
songName.includes(keyword) ||
|
||||||
|
albumName.includes(keyword) ||
|
||||||
|
artists.some((a: any) => a.name?.toLowerCase().includes(keyword)) ||
|
||||||
|
PinyinMatch.match(songName, keyword)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return allFilteredSongs.value.slice(0, renderLimit.value);
|
||||||
|
});
|
||||||
|
|
||||||
const keyword = searchKeyword.value.toLowerCase().trim();
|
// 未渲染项的占位高度,让滚动条从一开始就反映真实总高度
|
||||||
return dislikeFilteredList.filter((song) => {
|
const estimatedItemHeight = computed(() => (isCompactLayout.value ? 50 : 70));
|
||||||
const songName = song.name?.toLowerCase() || '';
|
const placeholderHeight = computed(() => {
|
||||||
const albumName = song.al?.name?.toLowerCase() || '';
|
if (searchKeyword.value) return 0;
|
||||||
const artists = song.ar || song.artists || [];
|
const unrenderedCount = allFilteredSongs.value.length - filteredSongs.value.length;
|
||||||
return (
|
return Math.max(0, unrenderedCount) * estimatedItemHeight.value;
|
||||||
songName.includes(keyword) ||
|
|
||||||
albumName.includes(keyword) ||
|
|
||||||
artists.some((a: any) => a.name?.toLowerCase().includes(keyword)) ||
|
|
||||||
PinyinMatch.match(songName, keyword)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const resetListState = () => {
|
const resetListState = () => {
|
||||||
@@ -520,7 +573,7 @@ const handlePlayAll = () => {
|
|||||||
? filteredSongs.value
|
? filteredSongs.value
|
||||||
: isFullPlaylistLoaded.value
|
: isFullPlaylistLoaded.value
|
||||||
? completePlaylist.value
|
? completePlaylist.value
|
||||||
: displayedSongs.value;
|
: allFilteredSongs.value;
|
||||||
playerStore.setPlayList(list.map(formatSong));
|
playerStore.setPlayList(list.map(formatSong));
|
||||||
playerStore.setPlay(formatSong(list[0]));
|
playerStore.setPlay(formatSong(list[0]));
|
||||||
if (!isFullPlaylistLoaded.value) loadFullPlaylist();
|
if (!isFullPlaylistLoaded.value) loadFullPlaylist();
|
||||||
@@ -553,17 +606,32 @@ const handleRemoveSong = async (songId: number) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 根据滚动位置计算需要渲染多少项,快速滚动也不会出现空白
|
||||||
const handleScroll = (e: Event) => {
|
const handleScroll = (e: Event) => {
|
||||||
const target = e.target as HTMLElement;
|
if (searchKeyword.value) return;
|
||||||
const { scrollTop, scrollHeight, clientHeight } = target;
|
|
||||||
|
|
||||||
// 懒加载:滚动到底部时加载更多
|
const target = e.target as HTMLElement;
|
||||||
if (
|
const { scrollTop, clientHeight } = target;
|
||||||
scrollHeight - scrollTop - clientHeight < 200 &&
|
|
||||||
!loadingList.value &&
|
// 列表区域在滚动内容中的起始偏移(hero + action bar + margin)
|
||||||
hasMore.value &&
|
const listSection = document.querySelector('.song-list-section') as HTMLElement;
|
||||||
!searchKeyword.value
|
const listStart = listSection?.offsetTop || 0;
|
||||||
) {
|
|
||||||
|
// 当前可见区域底部在列表中的位置
|
||||||
|
const visibleBottom = scrollTop + clientHeight - listStart;
|
||||||
|
if (visibleBottom <= 0) return;
|
||||||
|
|
||||||
|
// 计算需要渲染到第几项(多渲染一屏作为缓冲)
|
||||||
|
const bufferHeight = clientHeight;
|
||||||
|
const neededIndex = Math.ceil((visibleBottom + bufferHeight) / estimatedItemHeight.value);
|
||||||
|
const allCount = allFilteredSongs.value.length;
|
||||||
|
|
||||||
|
if (neededIndex > renderLimit.value) {
|
||||||
|
renderLimit.value = Math.min(neededIndex, allCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 内存数据全部渲染完但还有更多数据需要从 API 加载
|
||||||
|
if (renderLimit.value >= allCount && !loadingList.value && hasMore.value) {
|
||||||
loadMoreSongs();
|
loadMoreSongs();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -585,16 +653,10 @@ const loadMoreSongs = async () => {
|
|||||||
.map((i) => i.id)
|
.map((i) => i.id)
|
||||||
.filter((id) => !loadedIds.value.has(id));
|
.filter((id) => !loadedIds.value.has(id));
|
||||||
if (ids.length > 0) await loadSongs(ids);
|
if (ids.length > 0) await loadSongs(ids);
|
||||||
} else {
|
|
||||||
const newSongs = songList.value.slice(start, end);
|
|
||||||
newSongs.forEach((s) => {
|
|
||||||
if (!loadedIds.value.has(s.id)) {
|
|
||||||
loadedIds.value.add(s.id);
|
|
||||||
displayedSongs.value.push(s);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
hasMore.value = displayedSongs.value.length < total.value;
|
hasMore.value = displayedSongs.value.length < total.value;
|
||||||
|
// 新数据加载后扩展渲染窗口
|
||||||
|
renderLimit.value = displayedSongs.value.length;
|
||||||
} finally {
|
} finally {
|
||||||
loadingList.value = false;
|
loadingList.value = false;
|
||||||
}
|
}
|
||||||
@@ -708,6 +770,60 @@ const handleAddToPlaylist = () => {
|
|||||||
cancelSelect();
|
cancelSelect();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 当前播放歌曲在列表中的索引
|
||||||
|
const currentPlayingIndex = computed(() => {
|
||||||
|
const currentId = playerStore.playMusic?.id;
|
||||||
|
if (!currentId) return -1;
|
||||||
|
return allFilteredSongs.value.findIndex((s) => s.id === currentId);
|
||||||
|
});
|
||||||
|
|
||||||
|
const scrollbarRef = ref<any>(null);
|
||||||
|
|
||||||
|
// 滚动到当前播放歌曲
|
||||||
|
const scrollToCurrentSong = async () => {
|
||||||
|
const index = currentPlayingIndex.value;
|
||||||
|
if (index < 0) return;
|
||||||
|
|
||||||
|
// 确保目标歌曲已渲染到 DOM
|
||||||
|
if (index >= renderLimit.value) {
|
||||||
|
renderLimit.value = index + 5;
|
||||||
|
await nextTick();
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = document.querySelector('.song-list-container') as HTMLElement;
|
||||||
|
const target = container?.children[index] as HTMLElement;
|
||||||
|
if (!target || !scrollbarRef.value) return;
|
||||||
|
|
||||||
|
// 获取 n-scrollbar 内部的可滚动容器
|
||||||
|
const scrollEl = document.querySelector('.music-list-page .n-scrollbar-container') as HTMLElement;
|
||||||
|
if (!scrollEl) return;
|
||||||
|
|
||||||
|
// 用 getBoundingClientRect 精确测量目标位置
|
||||||
|
const scrollRect = scrollEl.getBoundingClientRect();
|
||||||
|
const targetRect = target.getBoundingClientRect();
|
||||||
|
const currentScrollTop = scrollEl.scrollTop;
|
||||||
|
|
||||||
|
// 目标在滚动内容中的绝对位置
|
||||||
|
const targetAbsoluteTop = currentScrollTop + targetRect.top - scrollRect.top;
|
||||||
|
|
||||||
|
// 粘性 action bar 占用的高度
|
||||||
|
const actionBarEl = document.querySelector('.action-bar') as HTMLElement;
|
||||||
|
const actionBarHeight = actionBarEl?.offsetHeight || 0;
|
||||||
|
|
||||||
|
// 可视区域高度(去掉 action bar)
|
||||||
|
const visibleHeight = scrollRect.height - actionBarHeight;
|
||||||
|
|
||||||
|
// 滚动到目标居中(在可视区域中间)
|
||||||
|
const scrollTop = targetAbsoluteTop - actionBarHeight - visibleHeight / 2 + targetRect.height / 2;
|
||||||
|
|
||||||
|
scrollbarRef.value.scrollTo({ top: Math.max(0, scrollTop), behavior: 'smooth' });
|
||||||
|
|
||||||
|
// 短暂高亮效果
|
||||||
|
await nextTick();
|
||||||
|
target.classList.add('song-highlight');
|
||||||
|
setTimeout(() => target.classList.remove('song-highlight'), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
const toggleLayout = () => {
|
const toggleLayout = () => {
|
||||||
isCompactLayout.value = !isCompactLayout.value;
|
isCompactLayout.value = !isCompactLayout.value;
|
||||||
localStorage.setItem('musicListLayout', isCompactLayout.value ? 'compact' : 'normal');
|
localStorage.setItem('musicListLayout', isCompactLayout.value ? 'compact' : 'normal');
|
||||||
@@ -730,6 +846,7 @@ watch(
|
|||||||
songList,
|
songList,
|
||||||
(newSongs) => {
|
(newSongs) => {
|
||||||
resetListState();
|
resetListState();
|
||||||
|
renderLimit.value = pageSize; // 重置 DOM 渲染窗口
|
||||||
if (newSongs.length > 0) {
|
if (newSongs.length > 0) {
|
||||||
displayedSongs.value = [...newSongs];
|
displayedSongs.value = [...newSongs];
|
||||||
newSongs.forEach((s) => loadedIds.value.add(s.id));
|
newSongs.forEach((s) => loadedIds.value.add(s.id));
|
||||||
@@ -791,6 +908,21 @@ onMounted(checkCollectionStatus);
|
|||||||
padding-bottom: 100px;
|
padding-bottom: 100px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.song-highlight {
|
||||||
|
animation: highlightPulse 2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes highlightPulse {
|
||||||
|
0%,
|
||||||
|
30% {
|
||||||
|
background-color: rgba(var(--primary-color-rgb, 64, 128, 255), 0.15);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.mobile {
|
.mobile {
|
||||||
.hero-section {
|
.hero-section {
|
||||||
min-height: auto;
|
min-height: auto;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
<div class="flex flex-col gap-6">
|
<div class="flex flex-col gap-6">
|
||||||
<div>
|
<div>
|
||||||
<h1
|
<h1
|
||||||
|
ref="titleElRef"
|
||||||
class="text-3xl md:text-4xl font-bold tracking-tight text-neutral-900 dark:text-white mb-1"
|
class="text-3xl md:text-4xl font-bold tracking-tight text-neutral-900 dark:text-white mb-1"
|
||||||
>
|
>
|
||||||
{{ currentKeyword }}
|
{{ currentKeyword }}
|
||||||
@@ -245,6 +246,7 @@ import SearchItem from '@/components/common/SearchItem.vue';
|
|||||||
import SongItem from '@/components/common/SongItem.vue';
|
import SongItem from '@/components/common/SongItem.vue';
|
||||||
import { SEARCH_TYPE, SEARCH_TYPES } from '@/const/bar-const';
|
import { SEARCH_TYPE, SEARCH_TYPES } from '@/const/bar-const';
|
||||||
import { useDownload } from '@/hooks/useDownload';
|
import { useDownload } from '@/hooks/useDownload';
|
||||||
|
import { useScrollTitle } from '@/hooks/useScrollTitle';
|
||||||
import { usePlayerStore } from '@/store/modules/player';
|
import { usePlayerStore } from '@/store/modules/player';
|
||||||
import { useSearchStore } from '@/store/modules/search';
|
import { useSearchStore } from '@/store/modules/search';
|
||||||
import type { SongResult } from '@/types/music';
|
import type { SongResult } from '@/types/music';
|
||||||
@@ -283,6 +285,9 @@ const hasMore = ref(true);
|
|||||||
const isLoadingMore = ref(false);
|
const isLoadingMore = ref(false);
|
||||||
const currentKeyword = computed(() => (route.query.keyword as string) || '');
|
const currentKeyword = computed(() => (route.query.keyword as string) || '');
|
||||||
|
|
||||||
|
const titleElRef = ref<HTMLElement | null>(null);
|
||||||
|
useScrollTitle(currentKeyword, titleElRef);
|
||||||
|
|
||||||
const searchTypeOptions = computed(() => {
|
const searchTypeOptions = computed(() => {
|
||||||
return SEARCH_TYPES.map((type) => ({
|
return SEARCH_TYPES.map((type) => ({
|
||||||
label: t(type.label),
|
label: t(type.label),
|
||||||
@@ -406,6 +411,7 @@ const loadSearch = async (isLoadMore = false) => {
|
|||||||
item.artists = item.ar;
|
item.artists = item.ar;
|
||||||
});
|
});
|
||||||
albums.forEach((item: any) => {
|
albums.forEach((item: any) => {
|
||||||
|
item.type = '专辑';
|
||||||
item.desc = `${item.artist.name} ${item.company} ${dateFormat(item.publishTime)}`;
|
item.desc = `${item.artist.name} ${item.company} ${dateFormat(item.publishTime)}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
<!-- Header Section -->
|
<!-- Header Section -->
|
||||||
<section class="page-padding-x pt-6 md:pt-8 pb-4">
|
<section class="page-padding-x pt-6 md:pt-8 pb-4">
|
||||||
<h1
|
<h1
|
||||||
|
ref="titleElRef"
|
||||||
class="text-2xl md:text-3xl font-bold text-neutral-900 dark:text-white tracking-tight"
|
class="text-2xl md:text-3xl font-bold text-neutral-900 dark:text-white tracking-tight"
|
||||||
>
|
>
|
||||||
<template v-if="targetUserName">
|
<template v-if="targetUserName">
|
||||||
@@ -134,6 +135,7 @@ import { useRoute, useRouter } from 'vue-router';
|
|||||||
|
|
||||||
import { getUserFollowers } from '@/api/user';
|
import { getUserFollowers } from '@/api/user';
|
||||||
import PlayBottom from '@/components/common/PlayBottom.vue';
|
import PlayBottom from '@/components/common/PlayBottom.vue';
|
||||||
|
import { useScrollTitle } from '@/hooks/useScrollTitle';
|
||||||
import { useUserStore } from '@/store/modules/user';
|
import { useUserStore } from '@/store/modules/user';
|
||||||
import type { IUserFollow } from '@/types/user';
|
import type { IUserFollow } from '@/types/user';
|
||||||
import { getImgUrl } from '@/utils';
|
import { getImgUrl } from '@/utils';
|
||||||
@@ -160,6 +162,14 @@ const targetUserName = ref<string>('');
|
|||||||
|
|
||||||
const user = computed(() => userStore.user);
|
const user = computed(() => userStore.user);
|
||||||
|
|
||||||
|
const titleElRef = ref<HTMLElement | null>(null);
|
||||||
|
const followersTitle = computed(() =>
|
||||||
|
targetUserName.value
|
||||||
|
? targetUserName.value + t('user.follower.userFollowersTitle')
|
||||||
|
: t('user.follower.myFollowersTitle')
|
||||||
|
);
|
||||||
|
useScrollTitle(followersTitle, titleElRef);
|
||||||
|
|
||||||
const checkTargetUser = () => {
|
const checkTargetUser = () => {
|
||||||
const uid = route.query.uid;
|
const uid = route.query.uid;
|
||||||
const name = route.query.name;
|
const name = route.query.name;
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
<!-- Header Section -->
|
<!-- Header Section -->
|
||||||
<section class="page-padding-x pt-6 md:pt-8 pb-4">
|
<section class="page-padding-x pt-6 md:pt-8 pb-4">
|
||||||
<h1
|
<h1
|
||||||
|
ref="titleElRef"
|
||||||
class="text-2xl md:text-3xl font-bold text-neutral-900 dark:text-white tracking-tight"
|
class="text-2xl md:text-3xl font-bold text-neutral-900 dark:text-white tracking-tight"
|
||||||
>
|
>
|
||||||
<template v-if="targetUserName">
|
<template v-if="targetUserName">
|
||||||
@@ -134,6 +135,7 @@ import { useRoute, useRouter } from 'vue-router';
|
|||||||
|
|
||||||
import { getUserFollows } from '@/api/user';
|
import { getUserFollows } from '@/api/user';
|
||||||
import PlayBottom from '@/components/common/PlayBottom.vue';
|
import PlayBottom from '@/components/common/PlayBottom.vue';
|
||||||
|
import { useScrollTitle } from '@/hooks/useScrollTitle';
|
||||||
import { useUserStore } from '@/store/modules/user';
|
import { useUserStore } from '@/store/modules/user';
|
||||||
import type { IUserFollow } from '@/types/user';
|
import type { IUserFollow } from '@/types/user';
|
||||||
import { getImgUrl } from '@/utils';
|
import { getImgUrl } from '@/utils';
|
||||||
@@ -160,6 +162,14 @@ const targetUserName = ref<string>('');
|
|||||||
|
|
||||||
const user = computed(() => userStore.user);
|
const user = computed(() => userStore.user);
|
||||||
|
|
||||||
|
const titleElRef = ref<HTMLElement | null>(null);
|
||||||
|
const followsTitle = computed(() =>
|
||||||
|
targetUserName.value
|
||||||
|
? targetUserName.value + t('user.follow.userFollowsTitle')
|
||||||
|
: t('user.follow.myFollowsTitle')
|
||||||
|
);
|
||||||
|
useScrollTitle(followsTitle, titleElRef);
|
||||||
|
|
||||||
// 检查是否有指定用户ID
|
// 检查是否有指定用户ID
|
||||||
const checkTargetUser = () => {
|
const checkTargetUser = () => {
|
||||||
const uid = route.query.uid;
|
const uid = route.query.uid;
|
||||||
|
|||||||
Reference in New Issue
Block a user