feat(web): per-platform source tabs on Home and Library

Recommend playlists, daily songs, and user playlists on Home now show
a [网易云][QQ] tab when both platforms are logged in. Library 我的歌单
gets the same tab. Selection persists per-section in localStorage and
falls back gracefully when the persisted source becomes unavailable
(e.g., user logged out). Removes dead 我的收藏 block from Library that
referenced a non-existent /api/music/user/liked endpoint.

Spec: docs/superpowers/specs/2026-05-06-music-source-tabs-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
saopig1andClaude Opus 4.7 committed 2026-05-06 13:19:49 +08:00
1 parent a216709227
commit ac55a346be
4 files changed
+199 -61

No files matched your search

+66 -17
View File
@@ -11,6 +11,8 @@ export interface Song {
platform: 'netease' | 'qq' | 'bilibili' | 'youtube'; platform: 'netease' | 'qq' | 'bilibili' | 'youtube';
} }
export type Source = 'netease' | 'qq';
export interface BotStatus { export interface BotStatus {
id: string; id: string;
name: string; name: string;
@@ -54,11 +56,12 @@ export const usePlayerStore = defineStore('player', {
timings: {} as Record<string, TimingState>, timings: {} as Record<string, TimingState>,
theme: 'dark' as 'dark' | 'light', theme: 'dark' as 'dark' | 'light',
// Home page cache // Home page cache, split by source
recommendPlaylists: [] as PlaylistItem[], recommendPlaylists: { netease: [] as PlaylistItem[], qq: [] as PlaylistItem[] },
dailySongs: [] as Song[], dailySongs: { netease: [] as Song[], qq: [] as Song[] },
userPlaylists: [] as PlaylistItem[], userPlaylists: { netease: [] as PlaylistItem[], qq: [] as PlaylistItem[] },
bilibiliPopular: [] as Song[], bilibiliPopular: [] as Song[],
authStatus: { netease: false, qq: false },
lastFetchTime: 0, lastFetchTime: 0,
}), }),
@@ -354,24 +357,70 @@ export const usePlayerStore = defineStore('player', {
return; return;
} }
const [playlistRes, dailyRes, userRes, biliRes] = await Promise.allSettled([ // 1. Fetch auth status for both platforms first.
axios.get('/api/music/recommend/playlists'), const [neAuthRes, qqAuthRes] = await Promise.allSettled([
axios.get('/api/music/recommend/songs'), axios.get('/api/auth/status', { params: { platform: 'netease' } }),
axios.get('/api/music/user/playlists'), axios.get('/api/auth/status', { params: { platform: 'qq' } }),
axios.get('/api/music/bilibili/popular?limit=12'), ]);
this.authStatus.netease =
neAuthRes.status === 'fulfilled' && !!neAuthRes.value.data?.loggedIn;
this.authStatus.qq =
qqAuthRes.status === 'fulfilled' && !!qqAuthRes.value.data?.loggedIn;
// 2. NetEase data: recommend playlists work anonymously; daily/user
// playlists need login but Promise.allSettled isolates failures.
const neteasePromises = [
axios.get('/api/music/recommend/playlists', { params: { platform: 'netease' } }),
axios.get('/api/music/recommend/songs', { params: { platform: 'netease' } }),
axios.get('/api/music/user/playlists', { params: { platform: 'netease' } }),
];
// 3. QQ data: only fetch when QQ is logged in. When not logged in,
// resolve to empty payloads so the same indexed handling works.
const emptyPlaylists = { data: { playlists: [] } };
const emptySongs = { data: { songs: [] } };
const qqPromises = this.authStatus.qq
? [
axios.get('/api/music/recommend/playlists', { params: { platform: 'qq' } }),
axios.get('/api/music/recommend/songs', { params: { platform: 'qq' } }),
axios.get('/api/music/user/playlists', { params: { platform: 'qq' } }),
]
: [
Promise.resolve(emptyPlaylists),
Promise.resolve(emptySongs),
Promise.resolve(emptyPlaylists),
];
const biliPromise = axios.get('/api/music/bilibili/popular?limit=12');
const results = await Promise.allSettled([
...neteasePromises,
...qqPromises,
biliPromise,
]); ]);
if (playlistRes.status === 'fulfilled') { const [neRecPL, neDaily, neUserPL, qqRecPL, qqDaily, qqUserPL, bili] = results;
this.recommendPlaylists = playlistRes.value.data.playlists;
if (neRecPL.status === 'fulfilled') {
this.recommendPlaylists.netease = neRecPL.value.data.playlists ?? [];
} }
if (dailyRes.status === 'fulfilled') { if (neDaily.status === 'fulfilled') {
this.dailySongs = dailyRes.value.data.songs; this.dailySongs.netease = neDaily.value.data.songs ?? [];
} }
if (userRes.status === 'fulfilled') { if (neUserPL.status === 'fulfilled') {
this.userPlaylists = userRes.value.data.playlists; this.userPlaylists.netease = neUserPL.value.data.playlists ?? [];
} }
if (biliRes.status === 'fulfilled') { if (qqRecPL.status === 'fulfilled') {
this.bilibiliPopular = biliRes.value.data.songs; this.recommendPlaylists.qq = qqRecPL.value.data.playlists ?? [];
}
if (qqDaily.status === 'fulfilled') {
this.dailySongs.qq = qqDaily.value.data.songs ?? [];
}
if (qqUserPL.status === 'fulfilled') {
this.userPlaylists.qq = qqUserPL.value.data.playlists ?? [];
}
if (bili.status === 'fulfilled') {
this.bilibiliPopular = bili.value.data.songs ?? [];
} }
this.lastFetchTime = Date.now(); this.lastFetchTime = Date.now();
+36
View File
@@ -0,0 +1,36 @@
import type { Source } from './player.js';
const STORAGE_KEY = 'source-tabs';
export type TabKey =
| 'home.recommend'
| 'home.daily'
| 'home.user'
| 'library.user';
function readAll(): Partial<Record<TabKey, Source>> {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return {};
const parsed = JSON.parse(raw);
return typeof parsed === 'object' && parsed !== null ? parsed : {};
} catch {
return {};
}
}
export function loadTabSource(key: TabKey, fallback: Source = 'netease'): Source {
const all = readAll();
const v = all[key];
return v === 'netease' || v === 'qq' ? v : fallback;
}
export function saveTabSource(key: TabKey, value: Source): void {
try {
const all = readAll();
all[key] = value;
localStorage.setItem(STORAGE_KEY, JSON.stringify(all));
} catch {
// localStorage may be unavailable (private browsing); silently no-op
}
}
+70 -14
View File
@@ -34,11 +34,14 @@
</section> </section>
<!-- 每日推荐 --> <!-- 每日推荐 -->
<section class="section" v-if="store.dailySongs.length > 0"> <section class="section" v-if="dailyAvailable.length > 0">
<h2 class="section-title">每日推荐</h2> <h2 class="section-title">
每日推荐
<SourceTabs v-model="dailySource" :sources="dailyAvailable" />
</h2>
<div class="daily-grid"> <div class="daily-grid">
<div <div
v-for="song in store.dailySongs.slice(0, 12)" v-for="song in (store.dailySongs[dailySourceSafe] ?? []).slice(0, 12)"
:key="song.id" :key="song.id"
class="daily-card hover-scale" class="daily-card hover-scale"
@click="store.playSong(song)" @click="store.playSong(song)"
@@ -51,11 +54,14 @@
</section> </section>
<!-- 推荐歌单 --> <!-- 推荐歌单 -->
<section class="section" v-if="store.recommendPlaylists.length > 0"> <section class="section" v-if="recommendAvailable.length > 0">
<h2 class="section-title">推荐歌单</h2> <h2 class="section-title">
推荐歌单
<SourceTabs v-model="recommendSource" :sources="recommendAvailable" />
</h2>
<div class="playlist-grid"> <div class="playlist-grid">
<RouterLink <RouterLink
v-for="playlist in store.recommendPlaylists" v-for="playlist in (store.recommendPlaylists[recommendSourceSafe] ?? [])"
:key="playlist.id" :key="playlist.id"
:to="`/playlist/${playlist.id}?platform=${playlist.platform}`" :to="`/playlist/${playlist.id}?platform=${playlist.platform}`"
class="playlist-card hover-scale" class="playlist-card hover-scale"
@@ -67,10 +73,11 @@
</section> </section>
<!-- 我的歌单 --> <!-- 我的歌单 -->
<section class="section" v-if="store.userPlaylists.length > 0"> <section class="section" v-if="userAvailable.length > 0">
<h2 class="section-title"> <h2 class="section-title">
我的歌单 我的歌单
<span class="section-count">{{ store.userPlaylists.length }}</span> <span class="section-count">{{ currentUserPlaylists.length }}</span>
<SourceTabs v-model="userSource" :sources="userAvailable" />
</h2> </h2>
<div class="playlist-grid"> <div class="playlist-grid">
<RouterLink <RouterLink
@@ -85,12 +92,12 @@
</RouterLink> </RouterLink>
</div> </div>
<button <button
v-if="store.userPlaylists.length > USER_PLAYLIST_LIMIT" v-if="currentUserPlaylists.length > USER_PLAYLIST_LIMIT"
class="expand-btn" class="expand-btn"
@click="userPlaylistsExpanded = !userPlaylistsExpanded" @click="userPlaylistsExpanded = !userPlaylistsExpanded"
> >
<Icon :icon="userPlaylistsExpanded ? 'mdi:chevron-up' : 'mdi:chevron-down'" /> <Icon :icon="userPlaylistsExpanded ? 'mdi:chevron-up' : 'mdi:chevron-down'" />
{{ userPlaylistsExpanded ? '收起' : `展开全部 ${store.userPlaylists.length} 个歌单` }} {{ userPlaylistsExpanded ? '收起' : `展开全部 ${currentUserPlaylists.length} 个歌单` }}
</button> </button>
</section> </section>
@@ -117,19 +124,68 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue'; import { ref, computed, watch, onMounted } from 'vue';
import { Icon } from '@iconify/vue'; import { Icon } from '@iconify/vue';
import axios from 'axios'; import axios from 'axios';
import { usePlayerStore, type Song } from '../stores/player.js'; import { usePlayerStore, type Song, type Source } from '../stores/player.js';
import { loadTabSource, saveTabSource } from '../stores/sourceTabs.js';
import CoverArt from '../components/CoverArt.vue'; import CoverArt from '../components/CoverArt.vue';
import SourceTabs from '../components/SourceTabs.vue';
const store = usePlayerStore(); const store = usePlayerStore();
const USER_PLAYLIST_LIMIT = 20; const USER_PLAYLIST_LIMIT = 20;
const userPlaylistsExpanded = ref(false); const userPlaylistsExpanded = ref(false);
// Available sources per section.
const recommendAvailable = computed<Source[]>(() => {
const s: Source[] = ['netease'];
if (store.authStatus.qq) s.push('qq');
return s;
});
const dailyAvailable = computed<Source[]>(() => {
const s: Source[] = [];
if (store.authStatus.netease) s.push('netease');
if (store.authStatus.qq) s.push('qq');
return s;
});
const userAvailable = computed<Source[]>(() => {
const s: Source[] = [];
if (store.authStatus.netease) s.push('netease');
if (store.authStatus.qq) s.push('qq');
return s;
});
// Persisted active source per section.
const recommendSource = ref<Source>(loadTabSource('home.recommend'));
const dailySource = ref<Source>(loadTabSource('home.daily'));
const userSource = ref<Source>(loadTabSource('home.user'));
watch(recommendSource, (v) => saveTabSource('home.recommend', v));
watch(dailySource, (v) => saveTabSource('home.daily', v));
watch(userSource, (v) => saveTabSource('home.user', v));
// Fallback when persisted source is no longer available.
const recommendSourceSafe = computed<Source>(() =>
recommendAvailable.value.includes(recommendSource.value)
? recommendSource.value
: recommendAvailable.value[0] ?? 'netease'
);
const dailySourceSafe = computed<Source>(() =>
dailyAvailable.value.includes(dailySource.value)
? dailySource.value
: dailyAvailable.value[0] ?? 'netease'
);
const userSourceSafe = computed<Source>(() =>
userAvailable.value.includes(userSource.value)
? userSource.value
: userAvailable.value[0] ?? 'netease'
);
const currentUserPlaylists = computed(() => store.userPlaylists[userSourceSafe.value] ?? []);
const visibleUserPlaylists = computed(() => const visibleUserPlaylists = computed(() =>
userPlaylistsExpanded.value userPlaylistsExpanded.value
? store.userPlaylists ? currentUserPlaylists.value
: store.userPlaylists.slice(0, USER_PLAYLIST_LIMIT) : currentUserPlaylists.value.slice(0, USER_PLAYLIST_LIMIT)
); );
async function playFm() { async function playFm() {
+27 -30
View File
@@ -3,14 +3,15 @@
<h1 class="page-title">音乐库</h1> <h1 class="page-title">音乐库</h1>
<!-- 我的歌单 --> <!-- 我的歌单 -->
<section class="section" v-if="store.userPlaylists.length > 0"> <section class="section" v-if="userAvailable.length > 0">
<h2 class="section-title"> <h2 class="section-title">
我的歌单 我的歌单
<span class="section-count">{{ store.userPlaylists.length }}</span> <span class="section-count">{{ currentUserPlaylists.length }}</span>
<SourceTabs v-model="userSource" :sources="userAvailable" />
</h2> </h2>
<div class="playlist-grid"> <div class="playlist-grid">
<RouterLink <RouterLink
v-for="pl in store.userPlaylists" v-for="pl in currentUserPlaylists"
:key="pl.id" :key="pl.id"
:to="`/playlist/${pl.id}?platform=${pl.platform}`" :to="`/playlist/${pl.id}?platform=${pl.platform}`"
class="playlist-card hover-scale" class="playlist-card hover-scale"
@@ -40,23 +41,7 @@
</div> </div>
</section> </section>
<!-- 我的收藏 --> <div v-if="!historyLoading && userAvailable.length === 0 && history.length === 0" class="empty-state">
<section class="section" v-if="liked.length > 0">
<h2 class="section-title">我的收藏</h2>
<div class="song-list">
<SongCard
v-for="(song, i) in liked.slice(0, 10)"
:key="`fav-${song.id}-${i}`"
:song="song"
:index="i + 1"
:active="store.currentSong?.id === song.id"
@play="store.play(song.name, song.platform)"
@add="store.addToQueue(song.name, song.platform)"
/>
</div>
</section>
<div v-if="!historyLoading && store.userPlaylists.length === 0 && history.length === 0 && liked.length === 0" class="empty-state">
<Icon icon="mdi:music-box-outline" class="empty-icon" /> <Icon icon="mdi:music-box-outline" class="empty-icon" />
<div>登录网易云或QQ音乐后,这里将显示你的歌单和播放记录</div> <div>登录网易云或QQ音乐后,这里将显示你的歌单和播放记录</div>
</div> </div>
@@ -64,19 +49,38 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue'; import { ref, computed, watch, onMounted } from 'vue';
import { Icon } from '@iconify/vue'; import { Icon } from '@iconify/vue';
import axios from 'axios'; import axios from 'axios';
import { usePlayerStore, type Song } from '../stores/player.js'; import { usePlayerStore, type Song, type Source } from '../stores/player.js';
import { loadTabSource, saveTabSource } from '../stores/sourceTabs.js';
import CoverArt from '../components/CoverArt.vue'; import CoverArt from '../components/CoverArt.vue';
import SongCard from '../components/SongCard.vue'; import SongCard from '../components/SongCard.vue';
import SourceTabs from '../components/SourceTabs.vue';
const store = usePlayerStore(); const store = usePlayerStore();
const history = ref<Song[]>([]); const history = ref<Song[]>([]);
const liked = ref<Song[]>([]);
const historyLoading = ref(true); const historyLoading = ref(true);
const userAvailable = computed<Source[]>(() => {
const s: Source[] = [];
if (store.authStatus.netease) s.push('netease');
if (store.authStatus.qq) s.push('qq');
return s;
});
const userSource = ref<Source>(loadTabSource('library.user'));
watch(userSource, (v) => saveTabSource('library.user', v));
const userSourceSafe = computed<Source>(() =>
userAvailable.value.includes(userSource.value)
? userSource.value
: userAvailable.value[0] ?? 'netease'
);
const currentUserPlaylists = computed(() => store.userPlaylists[userSourceSafe.value] ?? []);
onMounted(async () => { onMounted(async () => {
if (!store.activeBotId) { if (!store.activeBotId) {
await store.fetchBots(); await store.fetchBots();
@@ -91,13 +95,6 @@ onMounted(async () => {
} catch { } catch {
// API may not be ready // API may not be ready
} }
try {
const res = await axios.get('/api/music/user/liked');
liked.value = res.data.songs ?? [];
} catch {
// Liked songs API may not exist yet
}
} }
historyLoading.value = false; historyLoading.value = false;