mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 21:12:49 +08:00
feat(search): per-source load-more pagination in Search.vue (#115 frontend)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
a1cc0b8574
commit
a97e72ef30
3 files changed
+273
-1
No files matched your search
+134
-1
@@ -165,6 +165,13 @@
|
||||
@add="store.addSong(song)"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<div v-if="showLoadMore" class="load-more-wrap">
|
||||
<button class="load-more-btn" :disabled="currentLoadingMore" @click="loadMore">
|
||||
<Icon v-if="currentLoadingMore" icon="mdi:loading" class="spin" />
|
||||
{{ currentLoadingMore ? '加载中...' : '加载更多' }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else-if="searched" class="empty">未找到相关结果</div>
|
||||
@@ -180,6 +187,9 @@ import { usePlayerStore } from '../stores/player.js';
|
||||
import type { Song } from '../stores/player.js';
|
||||
import SongCard from '../components/SongCard.vue';
|
||||
import CoverArt from '../components/CoverArt.vue';
|
||||
import { mergeDedup, hasMore, nextOffset } from './searchPagination.js';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
const store = usePlayerStore();
|
||||
const route = useRoute();
|
||||
@@ -197,8 +207,10 @@ function loadSource(): SearchSource {
|
||||
return 'netease';
|
||||
}
|
||||
|
||||
type TabType = 'songs' | 'albums' | 'playlists';
|
||||
|
||||
const query = ref((route.query.q as string) || '');
|
||||
const activeTab = ref<'songs' | 'albums' | 'playlists'>('songs');
|
||||
const activeTab = ref<TabType>('songs');
|
||||
const selectedSource = ref<SearchSource>(loadSource());
|
||||
|
||||
interface Album { id: string; name: string; artist: string; coverUrl: string; songCount?: number; platform: string; }
|
||||
@@ -207,6 +219,9 @@ interface Playlist { id: string; name: string; coverUrl: string; songCount?: num
|
||||
const allSongs = ref<Song[]>([]);
|
||||
const allAlbums = ref<Album[]>([]);
|
||||
const allPlaylists = ref<Playlist[]>([]);
|
||||
// "加载更多" 分页状态:hasMore 按 (类型, 音源) 记录,loadingMore 按类型记录。
|
||||
const hasMoreMap = ref<Record<string, boolean>>({});
|
||||
const loadingMore = ref<Record<TabType, boolean>>({ songs: false, albums: false, playlists: false });
|
||||
const loading = ref(false);
|
||||
const searched = ref(false);
|
||||
const uploading = ref(false);
|
||||
@@ -230,6 +245,81 @@ const filteredPlaylists = computed(() =>
|
||||
|
||||
const hasLocalSongs = computed(() => localAudioEnabled.value && allSongs.value.some((s) => s.platform === 'local'));
|
||||
|
||||
// ---- 分页 / 加载更多 ----
|
||||
function pageKey(type: TabType, source: string): string {
|
||||
return `${type}:${source}`;
|
||||
}
|
||||
|
||||
const currentItems = computed(() => {
|
||||
if (activeTab.value === 'albums') return filteredAlbums.value;
|
||||
if (activeTab.value === 'playlists') return filteredPlaylists.value;
|
||||
return filteredSongs.value;
|
||||
});
|
||||
|
||||
const currentLoadingMore = computed(() => loadingMore.value[activeTab.value]);
|
||||
|
||||
const currentHasMore = computed(
|
||||
() => hasMoreMap.value[pageKey(activeTab.value, selectedSource.value)] ?? false
|
||||
);
|
||||
|
||||
// 有结果、还有下一页时才显示按钮;加载中时按钮保留但禁用并显示 spinner。
|
||||
const showLoadMore = computed(() => currentItems.value.length > 0 && currentHasMore.value);
|
||||
|
||||
function resetPagination() {
|
||||
hasMoreMap.value = {};
|
||||
loadingMore.value = { songs: false, albums: false, playlists: false };
|
||||
}
|
||||
|
||||
// 记录某个 (类型, 音源) 是否还有更多:返回条数 === PAGE_SIZE 视为还有下一页。
|
||||
function setHasMore(type: TabType, source: string, returnedCount: number) {
|
||||
hasMoreMap.value = {
|
||||
...hasMoreMap.value,
|
||||
[pageKey(type, source)]: hasMore(returnedCount, PAGE_SIZE),
|
||||
};
|
||||
}
|
||||
|
||||
// 初始 /search/all 返回的是各音源合并的首页,按音源分组统计每种类型的条数。
|
||||
function recordInitialHasMore(items: { platform: string }[], type: TabType) {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const it of items) counts[it.platform] = (counts[it.platform] ?? 0) + 1;
|
||||
const next = { ...hasMoreMap.value };
|
||||
for (const [source, count] of Object.entries(counts)) {
|
||||
next[pageKey(type, source)] = hasMore(count, PAGE_SIZE);
|
||||
}
|
||||
hasMoreMap.value = next;
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
const type = activeTab.value;
|
||||
const source = selectedSource.value;
|
||||
if (loadingMore.value[type]) return;
|
||||
if (!currentHasMore.value) return;
|
||||
const offset = nextOffset(currentItems.value.length, PAGE_SIZE);
|
||||
loadingMore.value = { ...loadingMore.value, [type]: true };
|
||||
try {
|
||||
const res = await axios.get('/api/music/search', {
|
||||
params: { q: query.value, platform: source, limit: PAGE_SIZE, offset },
|
||||
});
|
||||
if (type === 'albums') {
|
||||
const incoming = (res.data.albums ?? []) as Album[];
|
||||
allAlbums.value = mergeDedup(allAlbums.value, incoming);
|
||||
setHasMore(type, source, incoming.length);
|
||||
} else if (type === 'playlists') {
|
||||
const incoming = (res.data.playlists ?? []) as Playlist[];
|
||||
allPlaylists.value = mergeDedup(allPlaylists.value, incoming);
|
||||
setHasMore(type, source, incoming.length);
|
||||
} else {
|
||||
const incoming = (res.data.songs ?? []) as Song[];
|
||||
allSongs.value = mergeDedup(allSongs.value, incoming);
|
||||
setHasMore(type, source, incoming.length);
|
||||
}
|
||||
} catch {
|
||||
// 保留 hasMore 现状,允许用户重试。
|
||||
} finally {
|
||||
loadingMore.value = { ...loadingMore.value, [type]: false };
|
||||
}
|
||||
}
|
||||
|
||||
// Persist source preference
|
||||
watch(selectedSource, (src) => {
|
||||
try { localStorage.setItem(SOURCE_STORAGE_KEY, src); } catch { /* ignore */ }
|
||||
@@ -266,12 +356,16 @@ async function doSearch() {
|
||||
loading.value = true;
|
||||
searched.value = true;
|
||||
activeTab.value = 'songs';
|
||||
resetPagination();
|
||||
router.replace({ query: { q: query.value } });
|
||||
try {
|
||||
const res = await axios.get('/api/music/search/all', { params: { q: query.value } });
|
||||
allSongs.value = res.data.songs ?? [];
|
||||
allAlbums.value = res.data.albums ?? [];
|
||||
allPlaylists.value = res.data.playlists ?? [];
|
||||
recordInitialHasMore(allSongs.value, 'songs');
|
||||
recordInitialHasMore(allAlbums.value, 'albums');
|
||||
recordInitialHasMore(allPlaylists.value, 'playlists');
|
||||
} catch {
|
||||
allSongs.value = []; allAlbums.value = []; allPlaylists.value = [];
|
||||
} finally {
|
||||
@@ -601,6 +695,45 @@ onMounted(() => {
|
||||
.result-section {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.load-more-wrap {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 8px 0 32px;
|
||||
}
|
||||
|
||||
.load-more-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 9px 28px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
font-weight: var(--fw-semi);
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-card);
|
||||
cursor: pointer;
|
||||
transition: color var(--transition-fast), background var(--transition-fast);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: var(--color-primary);
|
||||
background: rgba(51, 94, 234, 0.12);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: load-more-spin 0.8s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes load-more-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
|
||||
Reference in new issue
Block a user