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;
+}