diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 7e003df..28d65be 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -13,7 +13,10 @@ "mcp__Claude_Preview__*", "mcp__Claude_in_Chrome__*", "mcp__scheduled-tasks__*", - "Bash(npx vitest:*)" + "Bash(npx vitest:*)", + "Bash(cp \"C:\\\\Users\\\\saopig1\\\\.claude\\\\projects\\\\C--Users-saopig1-Music-teamspeak-music-bot\\\\b5a64d6f-051e-4b87-966c-ece97d2b879b\\\\tool-results\\\\webfetch-1776569008470-exv7qe.bin\" /tmp/design.gz)", + "Bash(gunzip -f /tmp/design.gz)", + "Read(//tmp/**)" ], "deny": [ "Bash(git push * main)", diff --git a/docs/superpowers/plans/2026-05-06-music-source-tabs.md b/docs/superpowers/plans/2026-05-06-music-source-tabs.md new file mode 100644 index 0000000..491c3e2 --- /dev/null +++ b/docs/superpowers/plans/2026-05-06-music-source-tabs.md @@ -0,0 +1,911 @@ +# Music Source Tabs Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add NetEase / QQ source-switcher tabs to Home (推荐歌单 / 每日推荐 / 我的歌单) and Library (我的歌单), with per-section persistence and graceful degradation when only one source is logged in. + +**Architecture:** A single shared `` Vue component handles the tab UI and self-hides when fewer than 2 sources are available. The Pinia store splits the affected fields into `{ netease, qq }` objects, fetches from both platforms in `fetchHomeData()` based on `authStatus`, and consumers select with a reactive `activeSource` ref persisted to localStorage. + +**Tech Stack:** Vue 3 (Composition API + ` + + +``` + +Why these choices: +- `v-if="sources.length >= 2"` — auto-hide when only one source available; parent doesn't need wrapper logic +- Min-height 28px desktop / 36px mobile — comfortable touch on phones +- `--color-primary-12` (12% primary tint) — matches existing active-state pattern in the codebase +- No `--brand-netease/qq` in active state — keeps tab visually consistent regardless of which platform; brand colors are reserved for SongCard platform badges where they identify content origin + +- [ ] **Step 1.2: Verify it imports cleanly via type check** + +Run from project root: + +``` +npx tsc --noEmit +``` + +Expected: exit code 0, no output. + +Then verify the web project also type-checks: + +``` +cd web && npx vue-tsc --noEmit && cd .. +``` + +Expected: exit code 0, no output. + +- [ ] **Step 1.3: Commit** + +``` +git add web/src/components/SourceTabs.vue +git commit -m "feat(web): add SourceTabs component for platform switcher + +Presentational component for switching between netease and qq music +sources. Auto-hides when fewer than 2 sources are passed in. Mobile +breakpoint enlarges touch target to 36px. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 2: Refactor the store (state + fetchHomeData) + +**Files:** +- Modify: `web/src/stores/player.ts` + +This task changes types, which will break Home.vue and Library.vue at compile time. **Do not run tsc/build between Task 2 and Task 4** — they are migrated in a single coherent commit. After Task 4, type-check confirms the whole change. + +- [ ] **Step 2.1: Add the `Source` type alias and update state shape** + +In `web/src/stores/player.ts`, locate the `state: () => ({ ... })` block (around line 47-63). + +**Find:** + +```ts + // Home page cache + recommendPlaylists: [] as PlaylistItem[], + dailySongs: [] as Song[], + userPlaylists: [] as PlaylistItem[], + bilibiliPopular: [] as Song[], + lastFetchTime: 0, +``` + +**Replace with:** + +```ts + // Home page cache, split by source + recommendPlaylists: { netease: [] as PlaylistItem[], qq: [] as PlaylistItem[] }, + dailySongs: { netease: [] as Song[], qq: [] as Song[] }, + userPlaylists: { netease: [] as PlaylistItem[], qq: [] as PlaylistItem[] }, + bilibiliPopular: [] as Song[], + authStatus: { netease: false, qq: false }, + lastFetchTime: 0, +``` + +Also add this exported type at the top of the file, right after the existing `Song` interface (around line 12): + +```ts +export type Source = 'netease' | 'qq'; +``` + +- [ ] **Step 2.2: Rewrite `fetchHomeData()`** + +In the same file, find the `fetchHomeData` action (around line 352-378). + +**Replace the entire action body with:** + +```ts + async fetchHomeData() { + if (this.lastFetchTime > 0 && Date.now() - this.lastFetchTime < HOME_CACHE_TTL) { + return; + } + + // 1. Fetch auth status for both platforms first. + 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; + + // 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, + ]); + + const [neRecPL, neDaily, neUserPL, qqRecPL, qqDaily, qqUserPL, bili] = results; + + if (neRecPL.status === 'fulfilled') { + this.recommendPlaylists.netease = neRecPL.value.data.playlists ?? []; + } + if (neDaily.status === 'fulfilled') { + this.dailySongs.netease = neDaily.value.data.songs ?? []; + } + if (neUserPL.status === 'fulfilled') { + this.userPlaylists.netease = neUserPL.value.data.playlists ?? []; + } + if (qqRecPL.status === 'fulfilled') { + 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(); + }, +``` + +**Do NOT type-check yet** — Home/Library still reference the old shape. They'll be migrated in Tasks 3 and 4. + +--- + +## Task 3: Migrate Home.vue to multi-source tabs + +**Files:** +- Modify: `web/src/views/Home.vue` + +- [ ] **Step 3.1: Add a localStorage helper module** + +Create `web/src/stores/sourceTabs.ts`: + +```ts +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> { + 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 + } +} +``` + +This is a separate file rather than inline so Library can reuse it without duplication. + +- [ ] **Step 3.2: Update Home.vue template** + +Replace the three `
` blocks (推荐歌单 / 每日推荐 / 我的歌单) and the ` +``` + +**Replace with:** + +```ts + +``` + +Note: The 我的歌单 template uses `userSource` (not `userSourceSafe`) on the `` v-model so the user's click maps directly to the persisted ref. The grid below the tabs uses `currentUserPlaylists` which derives from `userSourceSafe`, so even if `userSource` points at an unavailable platform momentarily, the grid still renders something sensible. Same pattern for 推荐歌单 / 每日推荐. + +--- + +## Task 4: Migrate Library.vue and remove dead code + +**Files:** +- Modify: `web/src/views/Library.vue` + +- [ ] **Step 4.1: Replace the template** + +**Find** the ` + + diff --git a/web/src/views/Playlist.vue b/web/src/views/Playlist.vue index adcdc7b..d5c9f8d 100644 --- a/web/src/views/Playlist.vue +++ b/web/src/views/Playlist.vue @@ -32,6 +32,7 @@ :index="i + 1" :active="store.currentSong?.id === song.id" @play="store.playSong(song)" + @playNext="store.playNextSong(song)" @add="store.addSong(song)" /> @@ -77,20 +78,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/Search.vue b/web/src/views/Search.vue index f7a6016..0536e36 100644 --- a/web/src/views/Search.vue +++ b/web/src/views/Search.vue @@ -28,6 +28,7 @@ :index="i + 1" :active="store.currentSong?.id === song.id" @play="store.playSong(song)" + @playNext="store.playNextSong(song)" @add="store.addSong(song)" /> diff --git a/web/src/views/Settings.vue b/web/src/views/Settings.vue index ca90e21..ba2a310 100755 --- a/web/src/views/Settings.vue +++ b/web/src/views/Settings.vue @@ -397,6 +397,52 @@
+ + +
+

机器人 Profile(TeamSpeak 行为)

+

控制 bot 在 TeamSpeak 上自动同步歌曲信息的方式。⚠️ 标记的项会触发频道里所有人的提示音。

+
还没有机器人,先在上面创建一个。
+
+
+ +
+
+ {{ profileLoadError[bot.id] }} + +
+
加载中...
+ +
+
+
+
@@ -703,6 +749,72 @@ async function saveIdleTimeout() { } catch { /* ignore */ } } +// --- Bot Profile config --- +interface ProfileConfig { + avatarEnabled: boolean; + descriptionEnabled: boolean; + nicknameEnabled: boolean; + awayStatusEnabled: boolean; + channelDescEnabled: boolean; + nowPlayingMsgEnabled: boolean; +} + +const PROFILE_TOGGLES: ReadonlyArray<{ + key: keyof ProfileConfig; + label: string; + hint: string; + warning: string | null; +}> = [ + { key: 'avatarEnabled', label: '同步头像', hint: '使用专辑封面作为 bot 头像', warning: null }, + { key: 'descriptionEnabled', label: '同步个人描述', hint: '在 bot 简介里显示当前播放的歌曲', warning: null }, + { key: 'nicknameEnabled', label: '同步昵称', hint: 'bot 昵称跟着歌名变化', warning: null }, + { key: 'awayStatusEnabled', label: '走开状态', hint: '停止播放时把 bot 设为"走开"', warning: null }, + { key: 'channelDescEnabled', label: '更新频道描述', hint: '把"正在播放"信息写入频道描述', warning: '频道编辑提示音' }, + { key: 'nowPlayingMsgEnabled',label: '推送"正在播放"消息', hint: '切歌时在频道里发一条文字消息', warning: '新消息提示音' }, +]; + +const profileConfigs = reactive>({}); +const profileExpanded = reactive>({}); +const profileLoadError = reactive>({}); + +async function loadProfileConfig(botId: string) { + if (profileConfigs[botId]) return; + 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 + ? '机器人未加载' + : '加载失败,请重试'; + } +} + +function toggleProfileExpanded(botId: string) { + profileExpanded[botId] = !profileExpanded[botId]; + if (profileExpanded[botId]) loadProfileConfig(botId); +} + +async function updateProfile(botId: string, key: keyof ProfileConfig, value: boolean) { + const cfg = profileConfigs[botId]; + if (!cfg) return; + const prev = cfg[key]; + cfg[key] = value; // optimistic + try { + const res = await axios.put(`/api/player/${botId}/profile`, { [key]: value }); + profileConfigs[botId] = res.data; + } catch { + cfg[key] = prev; // revert + } +} + onMounted(() => { store.fetchBots(); // Refresh bot status on page visit checkAuthStatus(); @@ -820,16 +932,16 @@ onUnmounted(() => { background: var(--border-color); color: var(--text-tertiary); &.online { - background: rgba(51, 94, 234, 0.15); + background: var(--color-primary-15); color: var(--color-primary); } &.playing { - background: rgba(76, 175, 80, 0.15); - color: #4caf50; + background: var(--color-online-15); + color: var(--color-online); } &.paused { - background: rgba(255, 152, 0, 0.15); - color: #ff9800; + background: var(--color-paused-15); + color: var(--color-paused); } } @@ -853,7 +965,7 @@ onUnmounted(() => { color: var(--color-primary); &.bilibili-icon { - color: #00a1d6; + color: var(--brand-bilibili); } } @@ -865,7 +977,7 @@ onUnmounted(() => { .account-status { font-size: 12px; color: var(--text-tertiary); - &.logged { color: #4caf50; } + &.logged { color: var(--color-online); } } .login-methods { @@ -888,7 +1000,7 @@ onUnmounted(() => { &:hover { border-color: var(--color-primary); color: var(--color-primary); } &.active { - background: rgba(51, 94, 234, 0.1); + background: var(--color-primary-10); border-color: var(--color-primary); color: var(--color-primary); } @@ -1012,7 +1124,7 @@ onUnmounted(() => { &:hover { border-color: var(--border-color); } &.active { border-color: var(--color-primary); - background: rgba(51, 94, 234, 0.1); + background: var(--color-primary-10); } } @@ -1139,4 +1251,189 @@ onUnmounted(() => { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } + +// --- Bot Profile section --- +.profile-section-hint { + font-size: 13px; + color: var(--text-secondary); + margin: -8px 0 16px; + line-height: 1.5; +} + +.empty-hint { + font-size: 13px; + color: var(--text-tertiary); + padding: 12px; +} + +.profile-bot-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.profile-bot { + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + overflow: hidden; +} + +.profile-bot-header { + width: 100%; + display: flex; + align-items: center; + gap: 8px; + padding: 12px 16px; + background: transparent; + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + cursor: pointer; + transition: background var(--transition-fast); + + &:hover { background: var(--hover-bg); } + &.expanded { background: var(--hover-bg); } +} + +.profile-bot-name { + flex: 1; + text-align: left; +} + +.profile-toggles { + display: flex; + flex-direction: column; + padding: 0 16px 8px; + border-top: 1px solid var(--border-color); +} + +.profile-loading { + padding: 16px 0; + font-size: 13px; + color: var(--text-tertiary); + text-align: center; +} + +.profile-error { + color: var(--brand-netease); // re-uses red brand color for error state + + .btn-link { + margin-left: 8px; + font-size: 13px; + color: var(--color-primary); + text-decoration: underline; + background: transparent; + cursor: pointer; + } +} + +.profile-toggle { + display: flex; + align-items: center; + gap: 16px; + padding: 14px 0; + border-bottom: 1px solid var(--border-color); + cursor: pointer; + + &:last-child { border-bottom: none; } +} + +.profile-toggle-text { + flex: 1; + min-width: 0; +} + +.profile-toggle-label { + font-size: 14px; + font-weight: 500; + color: var(--text-primary); + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + line-height: 1.3; +} + +.profile-warn-tag { + font-size: 11px; + font-weight: 500; + padding: 2px 6px; + background: var(--color-paused-15); + color: var(--color-paused); + border-radius: var(--radius-xs); + white-space: nowrap; +} + +.profile-toggle-hint { + font-size: 12px; + color: var(--text-tertiary); + margin-top: 4px; + line-height: 1.4; +} + +.profile-toggle-switch { + flex-shrink: 0; + appearance: none; + -webkit-appearance: none; + width: 40px; + height: 22px; + border-radius: 999px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + position: relative; + cursor: pointer; + transition: background var(--transition-fast), border-color var(--transition-fast); + margin: 0; + + &::before { + content: ''; + position: absolute; + top: 2px; + left: 2px; + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--text-secondary); + transition: transform var(--transition-fast), background var(--transition-fast); + } + + &:checked { + background: var(--color-primary); + border-color: var(--color-primary); + + &::before { + transform: translateX(18px); + background: white; + } + } +} + +@media (max-width: 768px) { + .profile-bot-header { + padding: 14px 12px; + font-size: 15px; + } + + .profile-toggles { + padding: 0 12px 6px; + } + + .profile-toggle { + gap: 12px; + padding: 14px 0; + } + + .profile-toggle-switch { + width: 44px; + height: 24px; + + &::before { + width: 18px; + height: 18px; + } + &:checked::before { + transform: translateX(20px); + } + } +}