# TSMusicBot Phase 7: WebUI Frontend (Vue.js SPA)
> **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:** Build the Vue.js SPA frontend with YesPlayMusic-inspired design. Covers all 6 pages (Home, Search, Playlist, Lyrics, History, Settings), the player bar, navbar, and WebSocket real-time updates.
**Architecture:** Vue 3 + Vite SPA in `web/`. Pinia stores manage player state and WebSocket connection. SCSS variables handle theming (dark/light). The frontend communicates with the backend via REST API (`/api/*`) and WebSocket (`/ws`).
**Tech Stack:** Vue 3, Vite 5, Pinia, Vue Router 4, SCSS, axios, node-vibrant, Iconify
---
### Task 1: Scaffold Vue.js project
**Files:**
- Create: `web/package.json`
- Create: `web/vite.config.ts`
- Create: `web/index.html`
- Create: `web/src/main.ts`
- Create: `web/src/App.vue`
- Create: `web/tsconfig.json`
- [ ] **Step 1: Create Vue project in web/ directory**
```bash
cd "C:/Users/saopig1/Music/teamspeak music bot"
mkdir -p web
cd web
npm init -y
npm install vue vue-router@4 pinia axios node-vibrant @iconify/vue
npm install -D vite @vitejs/plugin-vue typescript sass vue-tsc
```
- [ ] **Step 2: Create web/vite.config.ts**
```typescript
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
server: {
port: 5173,
proxy: {
'/api': 'http://localhost:3000',
'/ws': {
target: 'ws://localhost:3000',
ws: true,
},
},
},
build: {
outDir: 'dist',
},
});
```
- [ ] **Step 3: Create web/tsconfig.json**
```json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"noEmit": true,
"paths": {
"@/*": ["./src/*"]
},
"baseUrl": "."
},
"include": ["src/**/*.ts", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}
```
- [ ] **Step 4: Create web/index.html**
```html
TSMusicBot
```
- [ ] **Step 5: Create web/src/main.ts**
```typescript
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
import router from './router/index.js';
import './styles/global.scss';
const app = createApp(App);
app.use(createPinia());
app.use(router);
app.mount('#app');
```
- [ ] **Step 6: Create web/src/App.vue**
```vue
```
- [ ] **Step 7: Commit**
```bash
cd "C:/Users/saopig1/Music/teamspeak music bot"
git add web/
git commit -m "feat: scaffold Vue.js frontend project with Vite, Pinia, and Vue Router"
```
---
### Task 2: Global styles and theming
**Files:**
- Create: `web/src/styles/variables.scss`
- Create: `web/src/styles/global.scss`
- [ ] **Step 1: Create variables.scss**
```scss
// YesPlayMusic-inspired design tokens
:root {
// Fonts
--font-primary: 'Barlow', -apple-system, 'PingFang SC', 'Microsoft YaHei', sans-serif;
// Spacing
--navbar-height: 56px;
--player-height: 56px;
// Radius
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
--radius-xl: 20px;
// Transitions
--transition-fast: 0.2s ease;
--transition-normal: 0.3s ease;
// Accent
--color-primary: #335eea;
--color-primary-bg: #eaeffd;
}
[data-theme='dark'] {
--bg-primary: #222222;
--bg-secondary: #323232;
--bg-card: rgba(255, 255, 255, 0.04);
--bg-navbar: rgba(34, 34, 34, 0.86);
--text-primary: #ffffff;
--text-secondary: rgba(255, 255, 255, 0.58);
--text-tertiary: rgba(255, 255, 255, 0.38);
--border-color: rgba(255, 255, 255, 0.06);
--hover-bg: rgba(255, 255, 255, 0.08);
}
[data-theme='light'] {
--bg-primary: #ffffff;
--bg-secondary: #f5f5f7;
--bg-card: rgba(0, 0, 0, 0.02);
--bg-navbar: rgba(255, 255, 255, 0.86);
--text-primary: #000000;
--text-secondary: rgba(0, 0, 0, 0.58);
--text-tertiary: rgba(0, 0, 0, 0.38);
--border-color: rgba(0, 0, 0, 0.06);
--hover-bg: rgba(0, 0, 0, 0.04);
}
```
- [ ] **Step 2: Create global.scss**
```scss
@use 'variables';
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
font-size: 16px;
}
body {
font-family: var(--font-primary);
background: var(--bg-primary);
color: var(--text-primary);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
text-decoration: none;
color: inherit;
}
button {
border: none;
background: none;
cursor: pointer;
font-family: inherit;
color: inherit;
}
// Scrollbar
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--text-tertiary);
border-radius: 4px;
}
// Shared utility classes
.frosted-glass {
background: var(--bg-navbar);
backdrop-filter: saturate(180%) blur(20px);
-webkit-backdrop-filter: saturate(180%) blur(20px);
}
.hover-scale {
transition: transform var(--transition-fast);
&:hover {
transform: scale(1.04);
}
&:active {
transform: scale(0.96);
}
}
```
- [ ] **Step 3: Commit**
```bash
git add web/src/styles/
git commit -m "feat: add YesPlayMusic-inspired SCSS theme with dark/light mode"
```
---
### Task 3: Pinia stores (player + WebSocket)
**Files:**
- Create: `web/src/stores/player.ts`
- Create: `web/src/composables/useWebSocket.ts`
- [ ] **Step 1: Create player store**
Create `web/src/stores/player.ts`:
```typescript
import { defineStore } from 'pinia';
import axios from 'axios';
export interface Song {
id: string;
name: string;
artist: string;
album: string;
duration: number;
coverUrl: string;
platform: 'netease' | 'qq';
}
export interface BotStatus {
id: string;
name: string;
connected: boolean;
playing: boolean;
paused: boolean;
currentSong: Song | null;
queueSize: number;
volume: number;
playMode: string;
}
export const usePlayerStore = defineStore('player', {
state: () => ({
bots: [] as BotStatus[],
activeBotId: null as string | null,
queue: [] as Song[],
theme: 'dark' as 'dark' | 'light',
}),
getters: {
activeBot(): BotStatus | null {
return this.bots.find((b) => b.id === this.activeBotId) ?? this.bots[0] ?? null;
},
currentSong(): Song | null {
return this.activeBot?.currentSong ?? null;
},
isPlaying(): boolean {
return this.activeBot?.playing ?? false;
},
isPaused(): boolean {
return this.activeBot?.paused ?? false;
},
},
actions: {
setActiveBotId(id: string) {
this.activeBotId = id;
},
updateBotStatus(botId: string, status: BotStatus) {
const index = this.bots.findIndex((b) => b.id === botId);
if (index >= 0) {
this.bots[index] = status;
} else {
this.bots.push(status);
}
},
removeBotStatus(botId: string) {
this.bots = this.bots.filter((b) => b.id !== botId);
},
setQueue(queue: Song[]) {
this.queue = queue;
},
toggleTheme() {
this.theme = this.theme === 'dark' ? 'light' : 'dark';
localStorage.setItem('theme', this.theme);
},
loadTheme() {
const saved = localStorage.getItem('theme') as 'dark' | 'light' | null;
if (saved) this.theme = saved;
},
async fetchBots() {
const res = await axios.get('/api/bot');
this.bots = res.data.bots;
if (!this.activeBotId && this.bots.length > 0) {
this.activeBotId = this.bots[0].id;
}
},
async play(query: string, platform = 'netease') {
if (!this.activeBotId) return;
await axios.post(`/api/player/${this.activeBotId}/play`, { query, platform });
},
async addToQueue(query: string, platform = 'netease') {
if (!this.activeBotId) return;
await axios.post(`/api/player/${this.activeBotId}/add`, { query, platform });
},
async pause() {
if (!this.activeBotId) return;
await axios.post(`/api/player/${this.activeBotId}/pause`);
},
async resume() {
if (!this.activeBotId) return;
await axios.post(`/api/player/${this.activeBotId}/resume`);
},
async next() {
if (!this.activeBotId) return;
await axios.post(`/api/player/${this.activeBotId}/next`);
},
async prev() {
if (!this.activeBotId) return;
await axios.post(`/api/player/${this.activeBotId}/prev`);
},
async stop() {
if (!this.activeBotId) return;
await axios.post(`/api/player/${this.activeBotId}/stop`);
},
async setVolume(volume: number) {
if (!this.activeBotId) return;
await axios.post(`/api/player/${this.activeBotId}/volume`, { volume });
},
async setMode(mode: string) {
if (!this.activeBotId) return;
await axios.post(`/api/player/${this.activeBotId}/mode`, { mode });
},
},
});
```
- [ ] **Step 2: Create WebSocket composable**
Create `web/src/composables/useWebSocket.ts`:
```typescript
import { ref, onUnmounted } from 'vue';
import { usePlayerStore } from '../stores/player.js';
export function useWebSocket() {
const connected = ref(false);
let ws: WebSocket | null = null;
let reconnectTimer: ReturnType | null = null;
function connect() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = `${protocol}//${window.location.host}/ws`;
ws = new WebSocket(url);
ws.onopen = () => {
connected.value = true;
console.log('WebSocket connected');
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
const store = usePlayerStore();
switch (data.type) {
case 'init':
for (const bot of data.bots) {
store.updateBotStatus(bot.id, bot);
}
break;
case 'stateChange':
store.updateBotStatus(data.botId, data.status);
if (data.queue) store.setQueue(data.queue);
break;
case 'botConnected':
store.updateBotStatus(data.botId, data.status);
break;
case 'botDisconnected':
store.removeBotStatus(data.botId);
break;
}
};
ws.onclose = () => {
connected.value = false;
// Reconnect after 3 seconds
reconnectTimer = setTimeout(connect, 3000);
};
ws.onerror = () => {
ws?.close();
};
}
function disconnect() {
if (reconnectTimer) clearTimeout(reconnectTimer);
ws?.close();
}
onUnmounted(disconnect);
return { connected, connect, disconnect };
}
```
- [ ] **Step 3: Commit**
```bash
git add web/src/stores/ web/src/composables/
git commit -m "feat: add Pinia player store and WebSocket composable"
```
---
### Task 4: Router setup
**Files:**
- Create: `web/src/router/index.ts`
- [ ] **Step 1: Create router**
Create `web/src/router/index.ts`:
```typescript
import { createRouter, createWebHistory } from 'vue-router';
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
name: 'home',
component: () => import('../views/Home.vue'),
},
{
path: '/search',
name: 'search',
component: () => import('../views/Search.vue'),
},
{
path: '/playlist/:id',
name: 'playlist',
component: () => import('../views/Playlist.vue'),
},
{
path: '/lyrics',
name: 'lyrics',
component: () => import('../views/Lyrics.vue'),
},
{
path: '/history',
name: 'history',
component: () => import('../views/History.vue'),
},
{
path: '/settings',
name: 'settings',
component: () => import('../views/Settings.vue'),
},
],
});
export default router;
```
- [ ] **Step 2: Commit**
```bash
git add web/src/router/
git commit -m "feat: add Vue Router with all page routes"
```
---
### Task 5: Navbar component
**Files:**
- Create: `web/src/components/Navbar.vue`
- [ ] **Step 1: Create Navbar**
Create `web/src/components/Navbar.vue`:
```vue
TSMusicBot
发现
搜索
播放历史
{{ activeBot.name }} {{ activeBot.connected ? '在线' : '离线' }}
```
- [ ] **Step 2: Commit**
```bash
git add web/src/components/Navbar.vue
git commit -m "feat: add frosted-glass Navbar component"
```
---
### Task 6: Player bar component
**Files:**
- Create: `web/src/components/Player.vue`
- [ ] **Step 1: Create Player**
Create `web/src/components/Player.vue`:
```vue
{{ currentSong.name }}
{{ currentSong.artist }}
```
- [ ] **Step 2: Commit**
```bash
git add web/src/components/Player.vue
git commit -m "feat: add frosted-glass Player bar with playback controls and volume"
```
---
### Task 7: CoverArt component (with colored shadow)
**Files:**
- Create: `web/src/components/CoverArt.vue`
- [ ] **Step 1: Create CoverArt**
Create `web/src/components/CoverArt.vue`:
```vue
```
- [ ] **Step 2: Commit**
```bash
git add web/src/components/CoverArt.vue
git commit -m "feat: add CoverArt component with YesPlayMusic-style colored shadow"
```
---
### Task 8: Home page
**Files:**
- Create: `web/src/views/Home.vue`
- [ ] **Step 1: Create Home view**
Create `web/src/views/Home.vue`:
```vue
{{ bot.name }}
搜索歌曲、歌单、专辑...
正在播放
{{ store.currentSong.name }}
{{ store.currentSong.artist }} · {{ store.currentSong.album }}
```
- [ ] **Step 2: Commit**
```bash
git add web/src/views/Home.vue
git commit -m "feat: add Home page with bot selector, search bar, now playing, and playlists"
```
---
### Task 9: Search, Playlist, Lyrics, History, Settings pages
**Files:**
- Create: `web/src/views/Search.vue`
- Create: `web/src/views/Playlist.vue`
- Create: `web/src/views/Lyrics.vue`
- Create: `web/src/views/History.vue`
- Create: `web/src/views/Settings.vue`
- Create: `web/src/components/SongCard.vue`
- Create: `web/src/components/Queue.vue`
These pages follow the same patterns established in Task 8. Each page:
- Uses the Pinia store for state
- Calls REST API endpoints for data
- Uses shared components (CoverArt, SongCard)
- Follows the YesPlayMusic design tokens (SCSS variables, frosted glass, hover-scale, etc.)
Due to the size, each view file should be implemented following these specs:
- [ ] **Step 1: Create SongCard component**
Create `web/src/components/SongCard.vue` — a reusable song row with cover, name, artist, duration, and play/add buttons. Used in search results, playlist detail, and queue.
```vue
{{ index }}
{{ song.name }}
{{ song.artist }}
{{ song.album }}
{{ formatDuration(song.duration) }}
```
- [ ] **Step 2: Create Search view**
Create `web/src/views/Search.vue` — search input, platform toggle (netease/qq), results as SongCard list.
- [ ] **Step 3: Create Playlist view**
Create `web/src/views/Playlist.vue` — hero header with cover art, playlist name, play all button, song list.
- [ ] **Step 4: Create Lyrics view**
Create `web/src/views/Lyrics.vue` — full-screen with dynamic blurred album art background, scrolling lyrics with active line highlighting. Uses `node-vibrant` for color extraction.
- [ ] **Step 5: Create History view**
Create `web/src/views/History.vue` — play history list from `/api/player/:botId/history`.
- [ ] **Step 6: Create Settings view**
Create `web/src/views/Settings.vue` — bot instance management (create/delete/start/stop), TS server connection config, music account login (QR code display, SMS form, cookie paste), command prefix, theme toggle.
- [ ] **Step 7: Create Queue component**
Create `web/src/components/Queue.vue` — sidebar queue panel showing current queue with drag-to-reorder and remove buttons.
- [ ] **Step 8: Commit**
```bash
git add web/src/views/ web/src/components/
git commit -m "feat: add all page views (Search, Playlist, Lyrics, History, Settings) and shared components"
```
---
### Task 10: Build and verify
- [ ] **Step 1: Build frontend**
```bash
cd web && npm run build
```
Expected: `web/dist/` is created with `index.html` and JS/CSS assets.
- [ ] **Step 2: Add frontend build script to root package.json**
Add to root `package.json` scripts:
```json
{
"scripts": {
"build:web": "cd web && npm run build",
"build": "tsc && npm run build:web"
}
}
```
- [ ] **Step 3: Commit**
```bash
cd "C:/Users/saopig1/Music/teamspeak music bot"
git add -A
git commit -m "chore: Phase 7 complete — Vue.js WebUI with YesPlayMusic-inspired design"
```