From 221079b89cef840ce40b0d79603931666c333a9d Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Wed, 6 May 2026 15:52:49 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20more=20corner-case=20audit=20=E2=80=94?= =?UTF-8?q?=20partial-failure=20UX=20+=20defensive=20parsing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three small reliability fixes from re-auditing: 1. Playlist.vue used Promise.all, so a flaky /detail endpoint would blank out the whole page even though /songs returned just fine. Switch to allSettled and synthesize a stub playlist header from the song list when only detail fails. User can still play the playlist; just loses the description/cover. 2. sourceTabs.readAll: typeof null === 'object' AND typeof [] === 'object', so a corrupted localStorage value (e.g. an array) would be treated as a record and its missing keys would silently fall back. Reject explicitly so the failure mode is "clean defaults" instead of "wrong shape that almost works". 3. Settings.vue loadProfileConfig: a 200 response with empty/wrong body would set profileConfigs[botId] to a falsy/wrong-shape value, leaving the row stuck on "加载中..." (because the v-if uses !profileConfigs[id]). Validate the shape; surface "响应格式异常" so the retry link is reachable. Co-Authored-By: Claude Opus 4.7 (1M context) --- web/src/stores/sourceTabs.ts | 8 ++++++- web/src/views/Playlist.vue | 41 +++++++++++++++++++++++++----------- web/src/views/Settings.vue | 7 ++++++ 3 files changed, 43 insertions(+), 13 deletions(-) diff --git a/web/src/stores/sourceTabs.ts b/web/src/stores/sourceTabs.ts index 652a6a8..8e03986 100644 --- a/web/src/stores/sourceTabs.ts +++ b/web/src/stores/sourceTabs.ts @@ -13,7 +13,13 @@ function readAll(): Partial> { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return {}; const parsed = JSON.parse(raw); - return typeof parsed === 'object' && parsed !== null ? parsed : {}; + // typeof null === 'object' and typeof [] === 'object' — both pass a + // naive check but neither is a valid record. Reject explicitly so a + // corrupted / manipulated value can't smuggle wrong shapes through. + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + return {}; + } + return parsed as Partial>; } catch { return {}; } diff --git a/web/src/views/Playlist.vue b/web/src/views/Playlist.vue index adcdc7b..01ba118 100644 --- a/web/src/views/Playlist.vue +++ b/web/src/views/Playlist.vue @@ -77,20 +77,37 @@ onMounted(async () => { const id = route.params.id as string; const platform = (route.query.platform as string) || 'netease'; - try { - const [detailRes, songsRes] = await Promise.all([ - axios.get(`/api/music/playlist/${id}/detail`, { params: { platform } }), - axios.get(`/api/music/playlist/${id}`, { params: { platform } }), - ]); - playlist.value = detailRes.data.playlist; - songs.value = songsRes.data.songs; - } catch (err: any) { - console.error('Failed to load playlist:', err?.response?.status, err?.message); + // allSettled, not Promise.all — if detail 404s but songs is fine + // (e.g., a QQ playlist whose detail endpoint flaked but the song + // list resolved), we still want to show the songs rather than + // the "歌单不存在" empty state. + const [detailRes, songsRes] = await Promise.allSettled([ + axios.get(`/api/music/playlist/${id}/detail`, { params: { platform } }), + axios.get(`/api/music/playlist/${id}`, { params: { platform } }), + ]); + + const detail = detailRes.status === 'fulfilled' ? detailRes.value.data?.playlist : null; + const songList = songsRes.status === 'fulfilled' ? (songsRes.value.data?.songs ?? []) : []; + + if (detail) { + playlist.value = detail; + } else if (songList.length > 0) { + // Fall back to a stub built from the route + first song's cover. + playlist.value = { + id, + name: '歌单', + description: '', + coverUrl: songList[0]?.coverUrl ?? '', + songCount: songList.length, + }; + } else { playlist.value = null; - songs.value = []; - } finally { - loading.value = false; + if (detailRes.status === 'rejected') { + console.error('Failed to load playlist detail:', (detailRes.reason as any)?.response?.status, (detailRes.reason as any)?.message); + } } + songs.value = songList; + loading.value = false; }); diff --git a/web/src/views/Settings.vue b/web/src/views/Settings.vue index f8ce798..ba2a310 100755 --- a/web/src/views/Settings.vue +++ b/web/src/views/Settings.vue @@ -782,6 +782,13 @@ async function loadProfileConfig(botId: string) { profileLoadError[botId] = null; try { const res = await axios.get(`/api/player/${botId}/profile`); + // Defensive: a 200 response with non-object body (empty / proxy + // injection / etc.) would otherwise leave the row stuck on + // "加载中..." because profileConfigs[botId] would be falsy. + if (!res.data || typeof res.data !== 'object' || typeof res.data.avatarEnabled !== 'boolean') { + profileLoadError[botId] = '响应格式异常'; + return; + } profileConfigs[botId] = res.data; } catch (err: any) { profileLoadError[botId] = err?.response?.status === 404