mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
feat: add QQ Music API integration, Pinia home cache, and unified search
- Start @sansenjian/qq-music-api server via dynamic import in api-server.ts - Rewrite QQ Music provider to use real API endpoints (getSearchByKey, getMusicPlay, getSongListDetail, getAlbumInfo, getLyric, etc.) - Cache home page data (recommend playlists, daily songs, user playlists) in Pinia store with 5-minute TTL to avoid refetching on every mount - Add unified /api/music/search/all endpoint that queries both Netease and QQ providers in parallel and returns merged results - Remove platform toggle from Search.vue; search both platforms at once - Add platform badge (网易云 / QQ) to SongCard component Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
ce6fc92780
commit
94a0df8391
8 files changed
+195
-155
No files matched your search
@@ -3,7 +3,13 @@
|
||||
<div class="song-index">{{ index }}</div>
|
||||
<CoverArt :url="song.coverUrl" :size="36" :radius="6" />
|
||||
<div class="song-info">
|
||||
<div class="song-name">{{ song.name }}</div>
|
||||
<div class="song-name-row">
|
||||
<span class="song-name">{{ song.name }}</span>
|
||||
<span
|
||||
class="platform-badge"
|
||||
:class="song.platform === 'qq' ? 'badge-qq' : 'badge-netease'"
|
||||
>{{ song.platform === 'qq' ? 'QQ' : '网易云' }}</span>
|
||||
</div>
|
||||
<div class="song-artist">{{ song.artist }}</div>
|
||||
</div>
|
||||
<div class="song-album">{{ song.album }}</div>
|
||||
@@ -73,6 +79,13 @@ function formatDuration(seconds: number): string {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.song-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.song-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
@@ -81,6 +94,25 @@ function formatDuration(seconds: number): string {
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.platform-badge {
|
||||
flex-shrink: 0;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.badge-netease {
|
||||
background: rgba(232, 17, 35, 0.15);
|
||||
color: #e81123;
|
||||
}
|
||||
|
||||
.badge-qq {
|
||||
background: rgba(18, 183, 106, 0.15);
|
||||
color: #12b76a;
|
||||
}
|
||||
|
||||
.song-artist {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
|
||||
@@ -23,6 +23,16 @@ export interface BotStatus {
|
||||
playMode: string;
|
||||
}
|
||||
|
||||
export interface PlaylistItem {
|
||||
id: string;
|
||||
name: string;
|
||||
coverUrl: string;
|
||||
songCount: number;
|
||||
platform: string;
|
||||
}
|
||||
|
||||
const HOME_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
export const usePlayerStore = defineStore('player', {
|
||||
state: () => ({
|
||||
bots: [] as BotStatus[],
|
||||
@@ -31,6 +41,11 @@ export const usePlayerStore = defineStore('player', {
|
||||
theme: 'dark' as 'dark' | 'light',
|
||||
playStartedAt: 0, // timestamp when current song started
|
||||
pausedElapsed: 0, // elapsed time when paused
|
||||
// Home page cached data
|
||||
recommendPlaylists: [] as PlaylistItem[],
|
||||
dailySongs: [] as Song[],
|
||||
userPlaylists: [] as PlaylistItem[],
|
||||
lastFetchTime: 0,
|
||||
}),
|
||||
|
||||
getters: {
|
||||
@@ -188,5 +203,30 @@ export const usePlayerStore = defineStore('player', {
|
||||
if (!this.activeBotId) return;
|
||||
await axios.post(`/api/player/${this.activeBotId}/mode`, { mode });
|
||||
},
|
||||
|
||||
async fetchHomeData() {
|
||||
// Skip if data was fetched within the last 5 minutes
|
||||
if (this.lastFetchTime > 0 && Date.now() - this.lastFetchTime < HOME_CACHE_TTL) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [playlistRes, dailyRes, userRes] = await Promise.allSettled([
|
||||
axios.get('/api/music/recommend/playlists'),
|
||||
axios.get('/api/music/recommend/songs'),
|
||||
axios.get('/api/music/user/playlists'),
|
||||
]);
|
||||
|
||||
if (playlistRes.status === 'fulfilled') {
|
||||
this.recommendPlaylists = playlistRes.value.data.playlists;
|
||||
}
|
||||
if (dailyRes.status === 'fulfilled') {
|
||||
this.dailySongs = dailyRes.value.data.songs;
|
||||
}
|
||||
if (userRes.status === 'fulfilled') {
|
||||
this.userPlaylists = userRes.value.data.playlists;
|
||||
}
|
||||
|
||||
this.lastFetchTime = Date.now();
|
||||
},
|
||||
},
|
||||
});
|
||||
+11
-48
@@ -47,11 +47,11 @@
|
||||
</section>
|
||||
|
||||
<!-- 每日推荐 -->
|
||||
<section class="section" v-if="dailySongs.length > 0">
|
||||
<section class="section" v-if="store.dailySongs.length > 0">
|
||||
<h2 class="section-title">每日推荐</h2>
|
||||
<div class="daily-grid">
|
||||
<div
|
||||
v-for="song in dailySongs.slice(0, 12)"
|
||||
v-for="song in store.dailySongs.slice(0, 12)"
|
||||
:key="song.id"
|
||||
class="daily-card hover-scale"
|
||||
@click="store.play(song.name, song.platform)"
|
||||
@@ -64,11 +64,11 @@
|
||||
</section>
|
||||
|
||||
<!-- 推荐歌单 -->
|
||||
<section class="section" v-if="playlists.length > 0">
|
||||
<section class="section" v-if="store.recommendPlaylists.length > 0">
|
||||
<h2 class="section-title">推荐歌单</h2>
|
||||
<div class="playlist-grid">
|
||||
<RouterLink
|
||||
v-for="playlist in playlists"
|
||||
v-for="playlist in store.recommendPlaylists"
|
||||
:key="playlist.id"
|
||||
:to="`/playlist/${playlist.id}?platform=${playlist.platform}`"
|
||||
class="playlist-card hover-scale"
|
||||
@@ -80,11 +80,11 @@
|
||||
</section>
|
||||
|
||||
<!-- 我的歌单 -->
|
||||
<section class="section" v-if="userPlaylists.length > 0">
|
||||
<section class="section" v-if="store.userPlaylists.length > 0">
|
||||
<h2 class="section-title">我的歌单</h2>
|
||||
<div class="playlist-grid">
|
||||
<RouterLink
|
||||
v-for="pl in userPlaylists"
|
||||
v-for="pl in store.userPlaylists"
|
||||
:key="pl.id"
|
||||
:to="`/playlist/${pl.id}?platform=${pl.platform}`"
|
||||
class="playlist-card hover-scale"
|
||||
@@ -99,40 +99,18 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { onMounted } from 'vue';
|
||||
import { Icon } from '@iconify/vue';
|
||||
import axios from 'axios';
|
||||
import { usePlayerStore } from '../stores/player.js';
|
||||
import { usePlayerStore, type Song } from '../stores/player.js';
|
||||
import CoverArt from '../components/CoverArt.vue';
|
||||
|
||||
const store = usePlayerStore();
|
||||
|
||||
interface PlaylistItem {
|
||||
id: string;
|
||||
name: string;
|
||||
coverUrl: string;
|
||||
songCount: number;
|
||||
platform: string;
|
||||
}
|
||||
|
||||
interface SongItem {
|
||||
id: string;
|
||||
name: string;
|
||||
artist: string;
|
||||
album: string;
|
||||
duration: number;
|
||||
coverUrl: string;
|
||||
platform: string;
|
||||
}
|
||||
|
||||
const playlists = ref<PlaylistItem[]>([]);
|
||||
const dailySongs = ref<SongItem[]>([]);
|
||||
const userPlaylists = ref<PlaylistItem[]>([]);
|
||||
|
||||
async function playFm() {
|
||||
try {
|
||||
const res = await axios.get('/api/music/personal/fm');
|
||||
const songs: SongItem[] = res.data.songs;
|
||||
const songs: Song[] = res.data.songs;
|
||||
if (songs.length > 0) {
|
||||
await store.play(songs[0].name, songs[0].platform);
|
||||
for (let i = 1; i < songs.length; i++) {
|
||||
@@ -144,23 +122,8 @@ async function playFm() {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// Fetch all data in parallel
|
||||
const [playlistRes, dailyRes, userRes] = await Promise.allSettled([
|
||||
axios.get('/api/music/recommend/playlists'),
|
||||
axios.get('/api/music/recommend/songs'),
|
||||
axios.get('/api/music/user/playlists'),
|
||||
]);
|
||||
|
||||
if (playlistRes.status === 'fulfilled') {
|
||||
playlists.value = playlistRes.value.data.playlists;
|
||||
}
|
||||
if (dailyRes.status === 'fulfilled') {
|
||||
dailySongs.value = dailyRes.value.data.songs;
|
||||
}
|
||||
if (userRes.status === 'fulfilled') {
|
||||
userPlaylists.value = userRes.value.data.playlists;
|
||||
}
|
||||
onMounted(() => {
|
||||
store.fetchHomeData();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -16,22 +16,6 @@
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
<div class="platform-toggle">
|
||||
<button
|
||||
class="platform-btn"
|
||||
:class="{ active: platform === 'netease' }"
|
||||
@click="platform = 'netease'; doSearch()"
|
||||
>
|
||||
网易云
|
||||
</button>
|
||||
<button
|
||||
class="platform-btn"
|
||||
:class="{ active: platform === 'qq' }"
|
||||
@click="platform = 'qq'; doSearch()"
|
||||
>
|
||||
QQ音乐
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">搜索中...</div>
|
||||
@@ -39,7 +23,7 @@
|
||||
<div v-else-if="results.length > 0" class="results">
|
||||
<SongCard
|
||||
v-for="(song, i) in results"
|
||||
:key="song.id"
|
||||
:key="`${song.platform}-${song.id}`"
|
||||
:song="song"
|
||||
:index="i + 1"
|
||||
:active="store.currentSong?.id === song.id"
|
||||
@@ -66,7 +50,6 @@ const store = usePlayerStore();
|
||||
const route = useRoute();
|
||||
|
||||
const query = ref((route.query.q as string) || '');
|
||||
const platform = ref<'netease' | 'qq'>('netease');
|
||||
const results = ref<Array<{ id: string; name: string; artist: string; album: string; duration: number; coverUrl: string; platform: string }>>([]);
|
||||
const loading = ref(false);
|
||||
const searched = ref(false);
|
||||
@@ -76,8 +59,8 @@ async function doSearch() {
|
||||
loading.value = true;
|
||||
searched.value = true;
|
||||
try {
|
||||
const res = await axios.get('/api/music/search', {
|
||||
params: { q: query.value, platform: platform.value },
|
||||
const res = await axios.get('/api/music/search/all', {
|
||||
params: { q: query.value },
|
||||
});
|
||||
results.value = res.data.songs;
|
||||
} catch {
|
||||
@@ -137,27 +120,6 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.platform-toggle {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.platform-btn {
|
||||
padding: 6px 18px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
background: var(--hover-bg);
|
||||
opacity: 0.6;
|
||||
transition: all var(--transition-fast);
|
||||
|
||||
&.active {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
|
||||
Reference in new issue
Block a user