mirror of
https://github.com/algerkong/AlgerMusicPlayer.git
synced 2026-04-03 14:20:50 +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',
|
||||
zoom100: 'Zoom 100%',
|
||||
resetZoom: 'Reset Zoom',
|
||||
zoomDefault: 'Default Zoom'
|
||||
zoomDefault: 'Default Zoom',
|
||||
tabPlaylist: 'Playlist',
|
||||
tabMv: 'MV',
|
||||
tabCharts: 'Charts',
|
||||
cancelSearch: 'Cancel'
|
||||
},
|
||||
titleBar: {
|
||||
closeTitle: 'Choose how to close',
|
||||
@@ -199,6 +203,7 @@ export default {
|
||||
addToPlaylistSuccess: 'Add to Playlist Success',
|
||||
operationFailed: 'Operation Failed',
|
||||
songsAlreadyInPlaylist: 'Songs already in playlist',
|
||||
locateCurrent: 'Locate current song',
|
||||
historyRecommend: 'Daily History',
|
||||
fetchDatesFailed: 'Failed to fetch dates',
|
||||
fetchSongsFailed: 'Failed to fetch songs',
|
||||
|
||||
@@ -173,7 +173,11 @@ export default {
|
||||
zoom: 'ページズーム',
|
||||
zoom100: '標準ズーム100%',
|
||||
resetZoom: 'クリックしてズームをリセット',
|
||||
zoomDefault: '標準ズーム'
|
||||
zoomDefault: '標準ズーム',
|
||||
tabPlaylist: 'プレイリスト',
|
||||
tabMv: 'MV',
|
||||
tabCharts: 'チャート',
|
||||
cancelSearch: 'キャンセル'
|
||||
},
|
||||
titleBar: {
|
||||
closeTitle: '閉じる方法を選択してください',
|
||||
@@ -199,6 +203,7 @@ export default {
|
||||
addToPlaylist: 'プレイリストに追加',
|
||||
addToPlaylistSuccess: 'プレイリストに追加しました',
|
||||
songsAlreadyInPlaylist: '楽曲は既にプレイリストに存在します',
|
||||
locateCurrent: '再生中の曲を表示',
|
||||
historyRecommend: '履歴の日次推薦',
|
||||
fetchDatesFailed: '日付リストの取得に失敗しました',
|
||||
fetchSongsFailed: '楽曲リストの取得に失敗しました',
|
||||
|
||||
@@ -172,7 +172,11 @@ export default {
|
||||
zoom: '페이지 확대/축소',
|
||||
zoom100: '표준 확대/축소 100%',
|
||||
resetZoom: '클릭하여 확대/축소 재설정',
|
||||
zoomDefault: '표준 확대/축소'
|
||||
zoomDefault: '표준 확대/축소',
|
||||
tabPlaylist: '플레이리스트',
|
||||
tabMv: 'MV',
|
||||
tabCharts: '차트',
|
||||
cancelSearch: '취소'
|
||||
},
|
||||
titleBar: {
|
||||
closeTitle: '닫기 방법을 선택해주세요',
|
||||
@@ -198,6 +202,7 @@ export default {
|
||||
addToPlaylist: '재생 목록에 추가',
|
||||
addToPlaylistSuccess: '재생 목록에 추가 성공',
|
||||
songsAlreadyInPlaylist: '곡이 이미 재생 목록에 있습니다',
|
||||
locateCurrent: '현재 재생 곡 찾기',
|
||||
historyRecommend: '일일 기록 권장',
|
||||
fetchDatesFailed: '날짜를 가져오지 못했습니다',
|
||||
fetchSongsFailed: '곡을 가져오지 못했습니다',
|
||||
|
||||
@@ -166,7 +166,11 @@ export default {
|
||||
zoom: '页面缩放',
|
||||
zoom100: '标准缩放100%',
|
||||
resetZoom: '点击重置缩放',
|
||||
zoomDefault: '标准缩放'
|
||||
zoomDefault: '标准缩放',
|
||||
tabPlaylist: '播放列表',
|
||||
tabMv: 'MV',
|
||||
tabCharts: '排行榜',
|
||||
cancelSearch: '取消'
|
||||
},
|
||||
titleBar: {
|
||||
closeTitle: '请选择关闭方式',
|
||||
@@ -192,6 +196,7 @@ export default {
|
||||
addToPlaylist: '添加到播放列表',
|
||||
addToPlaylistSuccess: '添加到播放列表成功',
|
||||
songsAlreadyInPlaylist: '歌曲已存在于播放列表中',
|
||||
locateCurrent: '定位当前播放',
|
||||
historyRecommend: '历史日推',
|
||||
fetchDatesFailed: '获取日期列表失败',
|
||||
fetchSongsFailed: '获取歌曲列表失败',
|
||||
|
||||
@@ -166,7 +166,11 @@ export default {
|
||||
zoom: '頁面縮放',
|
||||
zoom100: '標準縮放100%',
|
||||
resetZoom: '點擊重設縮放',
|
||||
zoomDefault: '標準縮放'
|
||||
zoomDefault: '標準縮放',
|
||||
tabPlaylist: '播放清單',
|
||||
tabMv: 'MV',
|
||||
tabCharts: '排行榜',
|
||||
cancelSearch: '取消'
|
||||
},
|
||||
titleBar: {
|
||||
closeTitle: '請選擇關閉方式',
|
||||
@@ -192,6 +196,7 @@ export default {
|
||||
addToPlaylist: '新增至播放清單',
|
||||
addToPlaylistSuccess: '新增至播放清單成功',
|
||||
songsAlreadyInPlaylist: '歌曲已存在於播放清單中',
|
||||
locateCurrent: '定位當前播放',
|
||||
historyRecommend: '歷史日推',
|
||||
fetchDatesFailed: '獲取日期列表失敗',
|
||||
fetchSongsFailed: '獲取歌曲列表失敗',
|
||||
|
||||
@@ -103,7 +103,10 @@ const handleClick = async () => {
|
||||
id: props.item.id,
|
||||
type: 'album',
|
||||
name: props.item.name,
|
||||
listInfo: { picUrl: props.item.picUrl },
|
||||
listInfo: {
|
||||
...props.item,
|
||||
coverImgUrl: props.item.picUrl
|
||||
},
|
||||
canRemove: false
|
||||
});
|
||||
} else if (props.item.type === 'playlist') {
|
||||
|
||||
58
src/renderer/hooks/useScrollTitle.ts
Normal file
58
src/renderer/hooks/useScrollTitle.ts
Normal file
@@ -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/menu';
|
||||
export * from './modules/music';
|
||||
export * from './modules/navTitle';
|
||||
export * from './modules/player';
|
||||
export * from './modules/playerCore';
|
||||
export * from './modules/playHistory';
|
||||
|
||||
27
src/renderer/store/modules/navTitle.ts
Normal file
27
src/renderer/store/modules/navTitle.ts
Normal file
@@ -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>
|
||||
</div>
|
||||
<h1
|
||||
ref="titleElRef"
|
||||
class="artist-name text-3xl md:text-4xl lg:text-5xl font-bold text-neutral-900 dark:text-white tracking-tight"
|
||||
>
|
||||
{{ artistInfo.name }}
|
||||
@@ -434,6 +435,7 @@ import { getMusicDetail } from '@/api/music';
|
||||
import { navigateToMusicList } from '@/components/common/MusicListNavigator';
|
||||
import PlayBottom from '@/components/common/PlayBottom.vue';
|
||||
import SongItem from '@/components/common/SongItem.vue';
|
||||
import { useScrollTitle } from '@/hooks/useScrollTitle';
|
||||
import router from '@/router';
|
||||
import { usePlayerStore } from '@/store';
|
||||
import { IArtist } from '@/types/artist';
|
||||
@@ -465,6 +467,10 @@ const artistInfo = ref<IArtist>();
|
||||
const songs = 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 songLoading = ref(false);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<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">
|
||||
<!-- Hero Section 和 Action Bar -->
|
||||
<n-spin :show="loading">
|
||||
@@ -63,6 +63,7 @@
|
||||
</span>
|
||||
</div>
|
||||
<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"
|
||||
>
|
||||
{{ name }}
|
||||
@@ -212,6 +213,16 @@
|
||||
</n-input>
|
||||
</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 -->
|
||||
<button
|
||||
v-if="!isMobile"
|
||||
@@ -226,7 +237,6 @@
|
||||
|
||||
<!-- List Content -->
|
||||
<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"
|
||||
@@ -239,8 +249,13 @@
|
||||
<div
|
||||
v-for="(item, index) in filteredSongs"
|
||||
:key="item.id"
|
||||
class="mb-2 animate-item"
|
||||
:style="{ animationDelay: calculateAnimationDelay(index % 20, 0.03) }"
|
||||
class="mb-2"
|
||||
:class="{ 'animate-item': index < initialAnimateCount }"
|
||||
:style="
|
||||
index < initialAnimateCount
|
||||
? { animationDelay: calculateAnimationDelay(index, 0.03) }
|
||||
: undefined
|
||||
"
|
||||
>
|
||||
<song-item
|
||||
:index="index"
|
||||
@@ -254,8 +269,27 @@
|
||||
@select="(id, selected) => handleSelect(id, selected)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 未渲染项占位,保持滚动条高度稳定 -->
|
||||
<div v-if="placeholderHeight > 0" :style="{ height: placeholderHeight + 'px' }" />
|
||||
|
||||
<!-- 底部加载指示器 -->
|
||||
<div v-if="loadingList" class="flex items-center justify-center py-6 gap-2">
|
||||
<n-spin :size="18" />
|
||||
<span class="text-sm text-neutral-400">{{ t('common.loading') }}</span>
|
||||
</div>
|
||||
<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>
|
||||
</n-spin>
|
||||
</section>
|
||||
</div>
|
||||
</n-scrollbar>
|
||||
@@ -266,7 +300,7 @@
|
||||
<script setup lang="ts">
|
||||
import { useMessage } from 'naive-ui';
|
||||
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 { useRoute } from 'vue-router';
|
||||
|
||||
@@ -280,6 +314,7 @@ import {
|
||||
import PlayBottom from '@/components/common/PlayBottom.vue';
|
||||
import SongItem from '@/components/common/SongItem.vue';
|
||||
import { useDownload } from '@/hooks/useDownload';
|
||||
import { useScrollTitle } from '@/hooks/useScrollTitle';
|
||||
import { useMusicStore, usePlayerStore, useRecommendStore, useUserStore } from '@/store';
|
||||
import { usePlayHistoryStore } from '@/store/modules/playHistory';
|
||||
import { SongResult } from '@/types/music';
|
||||
@@ -370,6 +405,9 @@ const name = computed(() => {
|
||||
return musicStore.currentMusicListName || '';
|
||||
});
|
||||
|
||||
const titleElRef = ref<HTMLElement | null>(null);
|
||||
useScrollTitle(name, titleElRef);
|
||||
|
||||
const songList = computed(() => {
|
||||
if (isDailyRecommend.value) return recommendStore.dailyRecommendSongs;
|
||||
return musicStore.currentMusicList || [];
|
||||
@@ -388,7 +426,9 @@ const canRemove = computed(() => {
|
||||
const canCollect = ref(false);
|
||||
const isCollected = ref(false);
|
||||
const pageSize = 40;
|
||||
const initialAnimateCount = 20; // 仅前 20 项有入场动画
|
||||
const displayedSongs = ref<SongResult[]>([]);
|
||||
const renderLimit = ref(pageSize); // DOM 渲染上限,数据全部在内存
|
||||
const loadingList = ref(false);
|
||||
const loadedIds = ref(new Set<number>());
|
||||
const isPlaylistLoading = ref(false);
|
||||
@@ -417,14 +457,17 @@ const getCoverImgUrl = computed(() => {
|
||||
return song?.picUrl || song?.al?.picUrl || song?.album?.picUrl || '';
|
||||
});
|
||||
|
||||
const filteredSongs = computed(() => {
|
||||
// 全量歌曲列表(用于"播放全部"等操作)
|
||||
const allFilteredSongs = computed(() => {
|
||||
const sourceList = isDailyRecommend.value ? songList.value : displayedSongs.value;
|
||||
const dislikeFilteredList = sourceList.filter((s) => !playerStore.dislikeList.includes(s.id));
|
||||
|
||||
if (!searchKeyword.value) return dislikeFilteredList;
|
||||
return sourceList.filter((s) => !playerStore.dislikeList.includes(s.id));
|
||||
});
|
||||
|
||||
// 实际渲染到 DOM 的歌曲(搜索时显示全部匹配,非搜索时按 renderLimit 分页渲染)
|
||||
const filteredSongs = computed(() => {
|
||||
if (searchKeyword.value) {
|
||||
const keyword = searchKeyword.value.toLowerCase().trim();
|
||||
return dislikeFilteredList.filter((song) => {
|
||||
return allFilteredSongs.value.filter((song) => {
|
||||
const songName = song.name?.toLowerCase() || '';
|
||||
const albumName = song.al?.name?.toLowerCase() || '';
|
||||
const artists = song.ar || song.artists || [];
|
||||
@@ -435,6 +478,16 @@ const filteredSongs = computed(() => {
|
||||
PinyinMatch.match(songName, keyword)
|
||||
);
|
||||
});
|
||||
}
|
||||
return allFilteredSongs.value.slice(0, renderLimit.value);
|
||||
});
|
||||
|
||||
// 未渲染项的占位高度,让滚动条从一开始就反映真实总高度
|
||||
const estimatedItemHeight = computed(() => (isCompactLayout.value ? 50 : 70));
|
||||
const placeholderHeight = computed(() => {
|
||||
if (searchKeyword.value) return 0;
|
||||
const unrenderedCount = allFilteredSongs.value.length - filteredSongs.value.length;
|
||||
return Math.max(0, unrenderedCount) * estimatedItemHeight.value;
|
||||
});
|
||||
|
||||
const resetListState = () => {
|
||||
@@ -520,7 +573,7 @@ const handlePlayAll = () => {
|
||||
? filteredSongs.value
|
||||
: isFullPlaylistLoaded.value
|
||||
? completePlaylist.value
|
||||
: displayedSongs.value;
|
||||
: allFilteredSongs.value;
|
||||
playerStore.setPlayList(list.map(formatSong));
|
||||
playerStore.setPlay(formatSong(list[0]));
|
||||
if (!isFullPlaylistLoaded.value) loadFullPlaylist();
|
||||
@@ -553,17 +606,32 @@ const handleRemoveSong = async (songId: number) => {
|
||||
}
|
||||
};
|
||||
|
||||
// 根据滚动位置计算需要渲染多少项,快速滚动也不会出现空白
|
||||
const handleScroll = (e: Event) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const { scrollTop, scrollHeight, clientHeight } = target;
|
||||
if (searchKeyword.value) return;
|
||||
|
||||
// 懒加载:滚动到底部时加载更多
|
||||
if (
|
||||
scrollHeight - scrollTop - clientHeight < 200 &&
|
||||
!loadingList.value &&
|
||||
hasMore.value &&
|
||||
!searchKeyword.value
|
||||
) {
|
||||
const target = e.target as HTMLElement;
|
||||
const { scrollTop, clientHeight } = target;
|
||||
|
||||
// 列表区域在滚动内容中的起始偏移(hero + action bar + margin)
|
||||
const listSection = document.querySelector('.song-list-section') as HTMLElement;
|
||||
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();
|
||||
}
|
||||
};
|
||||
@@ -585,16 +653,10 @@ const loadMoreSongs = async () => {
|
||||
.map((i) => i.id)
|
||||
.filter((id) => !loadedIds.value.has(id));
|
||||
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;
|
||||
// 新数据加载后扩展渲染窗口
|
||||
renderLimit.value = displayedSongs.value.length;
|
||||
} finally {
|
||||
loadingList.value = false;
|
||||
}
|
||||
@@ -708,6 +770,60 @@ const handleAddToPlaylist = () => {
|
||||
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 = () => {
|
||||
isCompactLayout.value = !isCompactLayout.value;
|
||||
localStorage.setItem('musicListLayout', isCompactLayout.value ? 'compact' : 'normal');
|
||||
@@ -730,6 +846,7 @@ watch(
|
||||
songList,
|
||||
(newSongs) => {
|
||||
resetListState();
|
||||
renderLimit.value = pageSize; // 重置 DOM 渲染窗口
|
||||
if (newSongs.length > 0) {
|
||||
displayedSongs.value = [...newSongs];
|
||||
newSongs.forEach((s) => loadedIds.value.add(s.id));
|
||||
@@ -791,6 +908,21 @@ onMounted(checkCollectionStatus);
|
||||
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 {
|
||||
.hero-section {
|
||||
min-height: auto;
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<div class="flex flex-col gap-6">
|
||||
<div>
|
||||
<h1
|
||||
ref="titleElRef"
|
||||
class="text-3xl md:text-4xl font-bold tracking-tight text-neutral-900 dark:text-white mb-1"
|
||||
>
|
||||
{{ currentKeyword }}
|
||||
@@ -245,6 +246,7 @@ import SearchItem from '@/components/common/SearchItem.vue';
|
||||
import SongItem from '@/components/common/SongItem.vue';
|
||||
import { SEARCH_TYPE, SEARCH_TYPES } from '@/const/bar-const';
|
||||
import { useDownload } from '@/hooks/useDownload';
|
||||
import { useScrollTitle } from '@/hooks/useScrollTitle';
|
||||
import { usePlayerStore } from '@/store/modules/player';
|
||||
import { useSearchStore } from '@/store/modules/search';
|
||||
import type { SongResult } from '@/types/music';
|
||||
@@ -283,6 +285,9 @@ const hasMore = ref(true);
|
||||
const isLoadingMore = ref(false);
|
||||
const currentKeyword = computed(() => (route.query.keyword as string) || '');
|
||||
|
||||
const titleElRef = ref<HTMLElement | null>(null);
|
||||
useScrollTitle(currentKeyword, titleElRef);
|
||||
|
||||
const searchTypeOptions = computed(() => {
|
||||
return SEARCH_TYPES.map((type) => ({
|
||||
label: t(type.label),
|
||||
@@ -406,6 +411,7 @@ const loadSearch = async (isLoadMore = false) => {
|
||||
item.artists = item.ar;
|
||||
});
|
||||
albums.forEach((item: any) => {
|
||||
item.type = '专辑';
|
||||
item.desc = `${item.artist.name} ${item.company} ${dateFormat(item.publishTime)}`;
|
||||
});
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
<!-- Header Section -->
|
||||
<section class="page-padding-x pt-6 md:pt-8 pb-4">
|
||||
<h1
|
||||
ref="titleElRef"
|
||||
class="text-2xl md:text-3xl font-bold text-neutral-900 dark:text-white tracking-tight"
|
||||
>
|
||||
<template v-if="targetUserName">
|
||||
@@ -134,6 +135,7 @@ import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import { getUserFollowers } from '@/api/user';
|
||||
import PlayBottom from '@/components/common/PlayBottom.vue';
|
||||
import { useScrollTitle } from '@/hooks/useScrollTitle';
|
||||
import { useUserStore } from '@/store/modules/user';
|
||||
import type { IUserFollow } from '@/types/user';
|
||||
import { getImgUrl } from '@/utils';
|
||||
@@ -160,6 +162,14 @@ const targetUserName = ref<string>('');
|
||||
|
||||
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 uid = route.query.uid;
|
||||
const name = route.query.name;
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
<!-- Header Section -->
|
||||
<section class="page-padding-x pt-6 md:pt-8 pb-4">
|
||||
<h1
|
||||
ref="titleElRef"
|
||||
class="text-2xl md:text-3xl font-bold text-neutral-900 dark:text-white tracking-tight"
|
||||
>
|
||||
<template v-if="targetUserName">
|
||||
@@ -134,6 +135,7 @@ import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import { getUserFollows } from '@/api/user';
|
||||
import PlayBottom from '@/components/common/PlayBottom.vue';
|
||||
import { useScrollTitle } from '@/hooks/useScrollTitle';
|
||||
import { useUserStore } from '@/store/modules/user';
|
||||
import type { IUserFollow } from '@/types/user';
|
||||
import { getImgUrl } from '@/utils';
|
||||
@@ -160,6 +162,14 @@ const targetUserName = ref<string>('');
|
||||
|
||||
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
|
||||
const checkTargetUser = () => {
|
||||
const uid = route.query.uid;
|
||||
|
||||
Reference in New Issue
Block a user