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:
saopig1andClaude Opus 4.8 committed 2026-07-04 14:42:07 +08:00
1 parent a1cc0b8574
commit a97e72ef30
3 files changed
+273 -1

No files matched your search

+134 -1
View File
@@ -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));
+93
View File
@@ -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);
});
});
});
+46
View File
@@ -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<T extends Keyed>(existing: T[], incoming: T[]): T[] {
const seen = new Set<string>(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;
}