mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-01 20:42:50 +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)"
|
@add="store.addSong(song)"
|
||||||
/>
|
/>
|
||||||
</section>
|
</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>
|
</template>
|
||||||
|
|
||||||
<div v-else-if="searched" class="empty">未找到相关结果</div>
|
<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 type { Song } from '../stores/player.js';
|
||||||
import SongCard from '../components/SongCard.vue';
|
import SongCard from '../components/SongCard.vue';
|
||||||
import CoverArt from '../components/CoverArt.vue';
|
import CoverArt from '../components/CoverArt.vue';
|
||||||
|
import { mergeDedup, hasMore, nextOffset } from './searchPagination.js';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
const store = usePlayerStore();
|
const store = usePlayerStore();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@@ -197,8 +207,10 @@ function loadSource(): SearchSource {
|
|||||||
return 'netease';
|
return 'netease';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TabType = 'songs' | 'albums' | 'playlists';
|
||||||
|
|
||||||
const query = ref((route.query.q as string) || '');
|
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());
|
const selectedSource = ref<SearchSource>(loadSource());
|
||||||
|
|
||||||
interface Album { id: string; name: string; artist: string; coverUrl: string; songCount?: number; platform: string; }
|
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 allSongs = ref<Song[]>([]);
|
||||||
const allAlbums = ref<Album[]>([]);
|
const allAlbums = ref<Album[]>([]);
|
||||||
const allPlaylists = ref<Playlist[]>([]);
|
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 loading = ref(false);
|
||||||
const searched = ref(false);
|
const searched = ref(false);
|
||||||
const uploading = 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'));
|
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
|
// Persist source preference
|
||||||
watch(selectedSource, (src) => {
|
watch(selectedSource, (src) => {
|
||||||
try { localStorage.setItem(SOURCE_STORAGE_KEY, src); } catch { /* ignore */ }
|
try { localStorage.setItem(SOURCE_STORAGE_KEY, src); } catch { /* ignore */ }
|
||||||
@@ -266,12 +356,16 @@ async function doSearch() {
|
|||||||
loading.value = true;
|
loading.value = true;
|
||||||
searched.value = true;
|
searched.value = true;
|
||||||
activeTab.value = 'songs';
|
activeTab.value = 'songs';
|
||||||
|
resetPagination();
|
||||||
router.replace({ query: { q: query.value } });
|
router.replace({ query: { q: query.value } });
|
||||||
try {
|
try {
|
||||||
const res = await axios.get('/api/music/search/all', { params: { q: query.value } });
|
const res = await axios.get('/api/music/search/all', { params: { q: query.value } });
|
||||||
allSongs.value = res.data.songs ?? [];
|
allSongs.value = res.data.songs ?? [];
|
||||||
allAlbums.value = res.data.albums ?? [];
|
allAlbums.value = res.data.albums ?? [];
|
||||||
allPlaylists.value = res.data.playlists ?? [];
|
allPlaylists.value = res.data.playlists ?? [];
|
||||||
|
recordInitialHasMore(allSongs.value, 'songs');
|
||||||
|
recordInitialHasMore(allAlbums.value, 'albums');
|
||||||
|
recordInitialHasMore(allPlaylists.value, 'playlists');
|
||||||
} catch {
|
} catch {
|
||||||
allSongs.value = []; allAlbums.value = []; allPlaylists.value = [];
|
allSongs.value = []; allAlbums.value = []; allPlaylists.value = [];
|
||||||
} finally {
|
} finally {
|
||||||
@@ -601,6 +695,45 @@ onMounted(() => {
|
|||||||
.result-section {
|
.result-section {
|
||||||
margin-bottom: 32px;
|
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 {
|
.card-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user