fix: address all known follow-up issues except NetEase batch precheck

Surface failures + tighten edges:

1. Toast for /play-song & /play-playlist failures. Backend now returns
   {ok, message} (localized in Chinese to match the rest of the UI).
   Store stashes a notification on ok=false; new Toast.vue mounted in
   App.vue shows it for 3-5s then fades. Clicking the X dismisses
   immediately. Sits above the player on desktop and above the mobile
   tabbar on phones.

2. QQ collected playlists pagination. fcg_get_profile_order_asset.fcg
   returns max 30 per call; we now loop using has_more / short-page
   detection up to a 300-playlist hard cap. Single-call users (typical)
   exit the loop on the first iteration so no extra requests.

3. getPlayableSongIds chunking. 100 mids per request keeps URL well
   under 8KB; chunk-level errors are isolated so a transient blip on
   one chunk doesn't poison the whole batch. Returns null only when
   every chunk failed (caller falls back to sequential retry).

4. SourceTabs single-source mode now renders a small subdued "网易云"
   or "QQ" label instead of vanishing entirely, so the user always
   knows which platform's data they're looking at.

5. Hide the "我的歌单 N" count badge when N=0 — Home and Library no
   longer show "我的歌单 0" with an empty grid.

6. Auth state change invalidates fetchHomeData cache. Previously, a
   user who logged out as account A and into account B within 5
   minutes would see A's playlists. Now we always re-check auth at
   the top of fetchHomeData and bypass the TTL cache when authStatus
   has changed.

Out of scope:
- NetEase analogous batch precheck (per request).
- 60s TS3 UDP idle disconnect — that's the bundled @honeybbq/teamspeak-
  client UDP layer kicking when no server packet arrives in 60s. It's
  baked in (constant `v=6e4`) and not exposed as an option, and root
  cause is server-side or network-layer behavior we can't reach from
  here.

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 16:00:48 +08:00
1 parent 221079b89c
commit 59fb3ee3bd
8 files changed
+245 -76

No files matched your search

+2
View File
@@ -5,6 +5,7 @@
<RouterView />
</main>
<Player />
<Toast />
<!-- Mobile mini player -->
<div v-if="currentSong" class="m-player" @click="router.push('/lyrics')">
@@ -55,6 +56,7 @@ import { useWebSocket } from './composables/useWebSocket.js';
import Navbar from './components/Navbar.vue';
import Player from './components/Player.vue';
import CoverArt from './components/CoverArt.vue';
import Toast from './components/Toast.vue';
const playerStore = usePlayerStore();
const theme = computed(() => playerStore.theme);
+27 -11
View File
@@ -1,15 +1,20 @@
<template>
<div v-if="sources.length >= 2" class="source-tabs">
<button
v-for="src in sources"
:key="src"
type="button"
class="source-tab"
:class="{ active: src === modelValue }"
@click="$emit('update:modelValue', src)"
>
{{ LABELS[src] }}
</button>
<div v-if="sources.length > 0" class="source-tabs">
<template v-if="sources.length >= 2">
<button
v-for="src in sources"
:key="src"
type="button"
class="source-tab"
:class="{ active: src === modelValue }"
@click="$emit('update:modelValue', src)"
>
{{ LABELS[src] }}
</button>
</template>
<span v-else class="source-tab-label" :title="`数据来自${LABELS[sources[0]]}`">
{{ LABELS[sources[0]] }}
</span>
</div>
</template>
@@ -63,6 +68,17 @@ defineEmits<{
}
}
// Single-source mode: not interactive, but tells the user which platform
// they're looking at instead of leaving them guessing.
.source-tab-label {
padding: 4px 10px;
font-size: var(--fs-xs);
font-weight: var(--fw-medium);
color: var(--text-tertiary);
background: var(--hover-bg);
border-radius: var(--radius-sm);
}
@media (max-width: 768px) {
.source-tabs {
margin-left: 8px;
+106
View File
@@ -0,0 +1,106 @@
<template>
<Transition name="toast">
<div v-if="visible && current" class="toast" :class="`toast--${current.type}`">
<Icon :icon="current.type === 'error' ? 'mdi:alert-circle' : 'mdi:information'" class="toast-icon" />
<span class="toast-msg">{{ current.message }}</span>
<button class="toast-close" @click="visible = false" aria-label="关闭">
<Icon icon="mdi:close" />
</button>
</div>
</Transition>
</template>
<script setup lang="ts">
import { ref, watch, onUnmounted } from 'vue';
import { Icon } from '@iconify/vue';
import { usePlayerStore } from '../stores/player.js';
const store = usePlayerStore();
const visible = ref(false);
const current = ref<{ id: number; message: string; type: 'error' | 'info' } | null>(null);
let timer: ReturnType<typeof setTimeout> | null = null;
watch(
() => store.notification?.id,
() => {
if (!store.notification) return;
current.value = store.notification;
visible.value = true;
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
visible.value = false;
}, current.value.type === 'error' ? 5000 : 3000);
}
);
onUnmounted(() => {
if (timer) clearTimeout(timer);
});
</script>
<style lang="scss" scoped>
.toast {
position: fixed;
left: 50%;
bottom: calc(var(--player-height) + 24px);
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
max-width: min(90vw, 480px);
background: var(--bg-card);
backdrop-filter: blur(20px);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
box-shadow: var(--shadow-dropdown);
font-size: var(--fs-body);
color: var(--text-primary);
z-index: 1000;
@media (max-width: 768px) {
bottom: calc(var(--player-height) + 60px); // sit above mobile tabbar
}
}
.toast--error {
border-color: var(--brand-netease);
.toast-icon { color: var(--brand-netease); }
}
.toast--info {
border-color: var(--color-primary);
.toast-icon { color: var(--color-primary); }
}
.toast-icon {
font-size: 18px;
flex-shrink: 0;
}
.toast-msg {
flex: 1;
line-height: 1.4;
}
.toast-close {
flex-shrink: 0;
font-size: 18px;
color: var(--text-tertiary);
background: transparent;
border: none;
cursor: pointer;
&:hover { color: var(--text-primary); }
}
.toast-enter-active,
.toast-leave-active {
transition: opacity var(--transition-fast), transform var(--transition-fast);
}
.toast-enter-from,
.toast-leave-to {
opacity: 0;
transform: translateX(-50%) translateY(8px);
}
</style>
+36 -11
View File
@@ -63,6 +63,10 @@ export const usePlayerStore = defineStore('player', {
bilibiliPopular: [] as Song[],
authStatus: { netease: false, qq: false },
lastFetchTime: 0,
// Transient notification for surfacing failures (e.g., "song not playable")
// to a global Toast. Bumped `id` triggers re-render of the same message.
notification: null as { id: number; message: string; type: 'error' | 'info' } | null,
}),
getters: {
@@ -269,9 +273,16 @@ export const usePlayerStore = defineStore('player', {
this._syncAfterAction();
},
notify(message: string, type: 'error' | 'info' = 'info') {
this.notification = { id: Date.now(), message, type };
},
async playSong(song: Song) {
if (!this.activeBotId) return;
await axios.post(`/api/player/${this.activeBotId}/play-song`, { song });
const res = await axios.post(`/api/player/${this.activeBotId}/play-song`, { song });
if (res.data?.ok === false && res.data?.message) {
this.notify(res.data.message, 'error');
}
this._setTiming(this.activeBotId, { serverElapsed: 0 });
this._syncAfterAction();
},
@@ -293,7 +304,10 @@ export const usePlayerStore = defineStore('player', {
async playPlaylist(playlistId: string, platform = 'netease') {
if (!this.activeBotId) return;
await axios.post(`/api/player/${this.activeBotId}/play-playlist`, { playlistId, platform });
const res = await axios.post(`/api/player/${this.activeBotId}/play-playlist`, { playlistId, platform });
if (res.data?.message) {
this.notify(res.data.message, res.data.ok === false ? 'error' : 'info');
}
this._setTiming(this.activeBotId, { serverElapsed: 0 });
this._syncAfterAction();
},
@@ -360,19 +374,30 @@ export const usePlayerStore = defineStore('player', {
},
async fetchHomeData() {
if (this.lastFetchTime > 0 && Date.now() - this.lastFetchTime < HOME_CACHE_TTL) {
return;
}
// 1. Fetch auth status for both platforms first.
// Always check auth status first — if it changed since the cached
// fetch (e.g., user logged in/out as a different account), the
// cached playlists belong to a different user and we MUST refetch.
const [neAuthRes, qqAuthRes] = await Promise.allSettled([
axios.get('/api/auth/status', { params: { platform: 'netease' } }),
axios.get('/api/auth/status', { params: { platform: 'qq' } }),
]);
this.authStatus.netease =
neAuthRes.status === 'fulfilled' && !!neAuthRes.value.data?.loggedIn;
this.authStatus.qq =
qqAuthRes.status === 'fulfilled' && !!qqAuthRes.value.data?.loggedIn;
const newAuth = {
netease: neAuthRes.status === 'fulfilled' && !!neAuthRes.value.data?.loggedIn,
qq: qqAuthRes.status === 'fulfilled' && !!qqAuthRes.value.data?.loggedIn,
};
const authChanged =
newAuth.netease !== this.authStatus.netease || newAuth.qq !== this.authStatus.qq;
this.authStatus.netease = newAuth.netease;
this.authStatus.qq = newAuth.qq;
// Cache hit only if auth is unchanged AND within TTL.
if (
!authChanged &&
this.lastFetchTime > 0 &&
Date.now() - this.lastFetchTime < HOME_CACHE_TTL
) {
return;
}
// 2. NetEase data: recommend playlists work anonymously; daily/user
// playlists need login but Promise.allSettled isolates failures.
+1 -1
View File
@@ -76,7 +76,7 @@
<section class="section" v-if="userAvailable.length > 0">
<h2 class="section-title">
我的歌单
<span class="section-count">{{ currentUserPlaylists.length }}</span>
<span v-if="currentUserPlaylists.length > 0" class="section-count">{{ currentUserPlaylists.length }}</span>
<SourceTabs v-model="userSource" :sources="userAvailable" />
</h2>
<div class="playlist-grid">
+1 -1
View File
@@ -6,7 +6,7 @@
<section class="section" v-if="userAvailable.length > 0">
<h2 class="section-title">
我的歌单
<span class="section-count">{{ currentUserPlaylists.length }}</span>
<span v-if="currentUserPlaylists.length > 0" class="section-count">{{ currentUserPlaylists.length }}</span>
<SourceTabs v-model="userSource" :sources="userAvailable" />
</h2>
<div class="playlist-grid">