diff --git a/web/src/views/Search.vue b/web/src/views/Search.vue index e70d1e5..5267ced 100644 --- a/web/src/views/Search.vue +++ b/web/src/views/Search.vue @@ -165,6 +165,13 @@ @add="store.addSong(song)" /> + +
+ +
未找到相关结果
@@ -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('songs'); const selectedSource = ref(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([]); const allAlbums = ref([]); const allPlaylists = ref([]); +// "加载更多" 分页状态:hasMore 按 (类型, 音源) 记录,loadingMore 按类型记录。 +const hasMoreMap = ref>({}); +const loadingMore = ref>({ 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 = {}; + 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)); diff --git a/web/src/views/searchPagination.test.ts b/web/src/views/searchPagination.test.ts new file mode 100644 index 0000000..7181ffa --- /dev/null +++ b/web/src/views/searchPagination.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect } from "vitest"; +import { itemKey, mergeDedup, hasMore, nextOffset, type Keyed } from "./searchPagination.js"; + +const item = (platform: string, id: string): Keyed & { label: string } => ({ + platform, + id, + label: `${platform}:${id}`, +}); + +describe("searchPagination helpers (#115)", () => { + describe("itemKey", () => { + it("builds a `${platform}:${id}` key", () => { + expect(itemKey({ platform: "netease", id: "42" })).toBe("netease:42"); + }); + + it("distinguishes same id across platforms", () => { + expect(itemKey({ platform: "qq", id: "1" })).not.toBe(itemKey({ platform: "netease", id: "1" })); + }); + }); + + describe("mergeDedup", () => { + it("appends incoming items, existing first, order preserved", () => { + const existing = [item("netease", "1"), item("netease", "2")]; + const incoming = [item("netease", "3"), item("netease", "4")]; + expect(mergeDedup(existing, incoming).map((x) => x.id)).toEqual(["1", "2", "3", "4"]); + }); + + it("drops incoming items already present in existing", () => { + const existing = [item("netease", "1"), item("netease", "2")]; + const incoming = [item("netease", "2"), item("netease", "3")]; + expect(mergeDedup(existing, incoming).map((x) => x.id)).toEqual(["1", "2", "3"]); + }); + + it("drops duplicates within the incoming batch", () => { + const existing = [item("netease", "1")]; + const incoming = [item("netease", "2"), item("netease", "2"), item("netease", "3")]; + expect(mergeDedup(existing, incoming).map((x) => x.id)).toEqual(["1", "2", "3"]); + }); + + it("treats same id on different platforms as distinct", () => { + const existing = [item("netease", "1")]; + const incoming = [item("qq", "1")]; + const merged = mergeDedup(existing, incoming); + expect(merged.map(itemKey)).toEqual(["netease:1", "qq:1"]); + }); + + it("does not mutate the existing array", () => { + const existing = [item("netease", "1")]; + const before = existing.slice(); + mergeDedup(existing, [item("netease", "2")]); + expect(existing).toEqual(before); + }); + + it("handles empty incoming", () => { + const existing = [item("netease", "1")]; + expect(mergeDedup(existing, []).map((x) => x.id)).toEqual(["1"]); + }); + }); + + describe("hasMore", () => { + it("is true when a full page came back", () => { + expect(hasMore(20, 20)).toBe(true); + }); + + it("is false when a short page came back", () => { + expect(hasMore(7, 20)).toBe(false); + }); + + it("is false when nothing came back", () => { + expect(hasMore(0, 20)).toBe(false); + }); + }); + + describe("nextOffset", () => { + it("returns the page-aligned offset for a full first page", () => { + expect(nextOffset(20, 20)).toBe(20); + }); + + it("returns 0 when nothing is shown yet", () => { + expect(nextOffset(0, 20)).toBe(0); + }); + + it("rounds up to the next page boundary after dedup drops items", () => { + // page1 (20) + page2 minus 5 dupes -> 35 shown, next page cursor is 40. + expect(nextOffset(35, 20)).toBe(40); + }); + + it("stays aligned across multiple full pages", () => { + expect(nextOffset(40, 20)).toBe(40); + expect(nextOffset(60, 20)).toBe(60); + }); + }); +}); diff --git a/web/src/views/searchPagination.ts b/web/src/views/searchPagination.ts new file mode 100644 index 0000000..c5c7973 --- /dev/null +++ b/web/src/views/searchPagination.ts @@ -0,0 +1,46 @@ +// Pure pagination helpers for Search.vue "加载更多" (load-more) per source + tab. +// Kept framework-free so root vitest can unit-cover the logic (see searchPagination.test.ts). + +/** Minimal shape shared by songs / albums / playlists: needs a stable dedup key. */ +export interface Keyed { + id: string; + platform: string; +} + +/** Stable dedup key for a result item: `${platform}:${id}`. */ +export function itemKey(item: Keyed): string { + return `${item.platform}:${item.id}`; +} + +/** + * Merge `incoming` into `existing`, deduped by `${platform}:${id}`. + * Order is preserved with existing items first; incoming items already present + * (or duplicated within the incoming batch) are dropped. + */ +export function mergeDedup(existing: T[], incoming: T[]): T[] { + const seen = new Set(existing.map(itemKey)); + const result = existing.slice(); + for (const item of incoming) { + const key = itemKey(item); + if (seen.has(key)) continue; + seen.add(key); + result.push(item); + } + return result; +} + +/** + * Whether another page might exist: a full page (=== pageSize) means keep the + * button; a short/empty page (< pageSize) means the source is exhausted. + */ +export function hasMore(returnedCount: number, pageSize: number): boolean { + return returnedCount >= pageSize; +} + +/** + * Offset for the next page request. Offsets are page-aligned, so this is simply + * the number of items already shown for that source+type. + */ +export function nextOffset(currentCountForSource: number, pageSize: number): number { + return Math.ceil(currentCountForSource / pageSize) * pageSize; +}