mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-01 20:42:50 +08:00
feat: add Vue.js WebUI — YesPlayMusic-inspired SPA with all pages and components
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
2a966e914c
commit
694e3c953b
25 files changed
+5388
-1
No files matched your search
+2
-1
@@ -5,7 +5,8 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx watch src/index.ts",
|
"dev": "tsx watch src/index.ts",
|
||||||
"build": "tsc",
|
"build": "tsc && npm run build:web",
|
||||||
|
"build:web": "cd web && npm run build",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest"
|
"test:watch": "vitest"
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>TSMusicBot</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Barlow:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+3178
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "tsmusicbot-web",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vue-tsc --noEmit && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "^3.5.0",
|
||||||
|
"vue-router": "^4.5.0",
|
||||||
|
"pinia": "^3.0.0",
|
||||||
|
"axios": "^1.14.0",
|
||||||
|
"node-vibrant": "^4.0.3",
|
||||||
|
"@iconify/vue": "^4.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"vite": "^6.3.0",
|
||||||
|
"@vitejs/plugin-vue": "^5.2.0",
|
||||||
|
"typescript": "^5.8.0",
|
||||||
|
"sass": "^1.87.0",
|
||||||
|
"vue-tsc": "^2.2.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<template>
|
||||||
|
<div class="app" :data-theme="theme">
|
||||||
|
<Navbar />
|
||||||
|
<main class="main-content">
|
||||||
|
<RouterView />
|
||||||
|
</main>
|
||||||
|
<Player />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { usePlayerStore } from './stores/player.js';
|
||||||
|
import Navbar from './components/Navbar.vue';
|
||||||
|
import Player from './components/Player.vue';
|
||||||
|
|
||||||
|
const playerStore = usePlayerStore();
|
||||||
|
const theme = computed(() => playerStore.theme);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.app {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
padding: 80px 10vw 80px;
|
||||||
|
|
||||||
|
@media (max-width: 1336px) {
|
||||||
|
padding: 80px 5vw 80px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<template>
|
||||||
|
<div class="cover-art" :style="{ width: size + 'px', height: size + 'px' }">
|
||||||
|
<img
|
||||||
|
:src="url"
|
||||||
|
:alt="alt"
|
||||||
|
class="cover-img"
|
||||||
|
:style="{ borderRadius: radius + 'px' }"
|
||||||
|
@load="onImageLoad"
|
||||||
|
crossorigin="anonymous"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-if="showShadow"
|
||||||
|
class="cover-shadow"
|
||||||
|
:style="{
|
||||||
|
backgroundImage: `url(${url})`,
|
||||||
|
borderRadius: radius + 'px',
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
withDefaults(defineProps<{
|
||||||
|
url: string;
|
||||||
|
size?: number;
|
||||||
|
radius?: number;
|
||||||
|
alt?: string;
|
||||||
|
showShadow?: boolean;
|
||||||
|
}>(), {
|
||||||
|
size: 48,
|
||||||
|
radius: 8,
|
||||||
|
alt: 'Cover',
|
||||||
|
showShadow: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const loaded = ref(false);
|
||||||
|
|
||||||
|
function onImageLoad() {
|
||||||
|
loaded.value = true;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.cover-art {
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-shadow {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
filter: blur(16px);
|
||||||
|
opacity: 0.6;
|
||||||
|
transform: scale(0.92);
|
||||||
|
z-index: 0;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<template>
|
||||||
|
<nav class="navbar frosted-glass">
|
||||||
|
<RouterLink to="/" class="logo">TSMusicBot</RouterLink>
|
||||||
|
|
||||||
|
<div class="nav-links">
|
||||||
|
<RouterLink to="/" class="nav-link" active-class="active">发现</RouterLink>
|
||||||
|
<RouterLink to="/search" class="nav-link" active-class="active">搜索</RouterLink>
|
||||||
|
<RouterLink to="/history" class="nav-link" active-class="active">播放历史</RouterLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nav-right">
|
||||||
|
<div v-if="activeBot" class="bot-status" :class="{ online: activeBot.connected }">
|
||||||
|
{{ activeBot.name }} {{ activeBot.connected ? '在线' : '离线' }}
|
||||||
|
</div>
|
||||||
|
<RouterLink to="/settings" class="settings-btn">
|
||||||
|
<Icon icon="mdi:cog" />
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { Icon } from '@iconify/vue';
|
||||||
|
import { usePlayerStore } from '../stores/player.js';
|
||||||
|
|
||||||
|
const store = usePlayerStore();
|
||||||
|
const activeBot = computed(() => store.activeBot);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.navbar {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: var(--navbar-height);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 10vw;
|
||||||
|
z-index: 100;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
|
||||||
|
@media (max-width: 1336px) {
|
||||||
|
padding: 0 5vw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-primary);
|
||||||
|
margin-right: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
opacity: 0.6;
|
||||||
|
transition: opacity var(--transition-fast);
|
||||||
|
|
||||||
|
&:hover { opacity: 0.8; }
|
||||||
|
&.active { opacity: 1; color: var(--color-primary); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-right {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-status {
|
||||||
|
padding: 4px 12px;
|
||||||
|
background: var(--hover-bg);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.6;
|
||||||
|
|
||||||
|
&.online {
|
||||||
|
background: rgba(51, 94, 234, 0.15);
|
||||||
|
color: var(--color-primary);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-btn {
|
||||||
|
font-size: 20px;
|
||||||
|
opacity: 0.6;
|
||||||
|
transition: opacity var(--transition-fast);
|
||||||
|
&:hover { opacity: 1; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
<template>
|
||||||
|
<div class="player-bar frosted-glass" v-if="currentSong">
|
||||||
|
<div class="player-left">
|
||||||
|
<CoverArt :url="currentSong.coverUrl" :size="40" />
|
||||||
|
<div class="song-info">
|
||||||
|
<div class="song-name">{{ currentSong.name }}</div>
|
||||||
|
<div class="song-artist">{{ currentSong.artist }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="player-center">
|
||||||
|
<button class="control-btn" @click="store.prev()">
|
||||||
|
<Icon icon="mdi:skip-previous" />
|
||||||
|
</button>
|
||||||
|
<button class="play-btn" @click="togglePlay">
|
||||||
|
<Icon :icon="store.isPlaying ? 'mdi:pause' : 'mdi:play'" />
|
||||||
|
</button>
|
||||||
|
<button class="control-btn" @click="store.next()">
|
||||||
|
<Icon icon="mdi:skip-next" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="player-right">
|
||||||
|
<Icon icon="mdi:volume-high" class="volume-icon" />
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
:value="activeBot?.volume ?? 75"
|
||||||
|
@input="onVolumeChange"
|
||||||
|
class="volume-slider"
|
||||||
|
/>
|
||||||
|
<RouterLink to="/lyrics" class="control-btn lyrics-btn">
|
||||||
|
<Icon icon="mdi:microphone" />
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { Icon } from '@iconify/vue';
|
||||||
|
import { usePlayerStore } from '../stores/player.js';
|
||||||
|
import CoverArt from './CoverArt.vue';
|
||||||
|
|
||||||
|
const store = usePlayerStore();
|
||||||
|
const activeBot = computed(() => store.activeBot);
|
||||||
|
const currentSong = computed(() => store.currentSong);
|
||||||
|
|
||||||
|
function togglePlay() {
|
||||||
|
if (store.isPlaying) {
|
||||||
|
store.pause();
|
||||||
|
} else {
|
||||||
|
store.resume();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onVolumeChange(e: Event) {
|
||||||
|
const target = e.target as HTMLInputElement;
|
||||||
|
store.setVolume(parseInt(target.value));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.player-bar {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: var(--player-height);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 24px;
|
||||||
|
z-index: 100;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
width: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-info {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-artist {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-center {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-btn {
|
||||||
|
font-size: 20px;
|
||||||
|
opacity: 0.7;
|
||||||
|
transition: opacity var(--transition-fast);
|
||||||
|
&:hover { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-btn {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
background: var(--color-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 18px;
|
||||||
|
color: white;
|
||||||
|
transition: transform var(--transition-fast);
|
||||||
|
&:hover { transform: scale(1.08); }
|
||||||
|
&:active { transform: scale(0.95); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-right {
|
||||||
|
width: 240px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-slider {
|
||||||
|
width: 80px;
|
||||||
|
height: 3px;
|
||||||
|
appearance: none;
|
||||||
|
background: var(--border-color);
|
||||||
|
border-radius: 2px;
|
||||||
|
outline: none;
|
||||||
|
|
||||||
|
&::-webkit-slider-thumb {
|
||||||
|
appearance: none;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
background: var(--color-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.lyrics-btn {
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
<template>
|
||||||
|
<div class="queue-panel" :class="{ open }">
|
||||||
|
<div class="queue-header">
|
||||||
|
<h3 class="queue-title">播放队列</h3>
|
||||||
|
<span class="queue-count">{{ store.queue.length }} 首</span>
|
||||||
|
<button class="close-btn" @click="$emit('close')">
|
||||||
|
<Icon icon="mdi:close" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="store.queue.length === 0" class="queue-empty">
|
||||||
|
队列为空
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="queue-list">
|
||||||
|
<div
|
||||||
|
v-for="(song, i) in store.queue"
|
||||||
|
:key="`${song.id}-${i}`"
|
||||||
|
class="queue-item"
|
||||||
|
:class="{ active: store.currentSong?.id === song.id }"
|
||||||
|
>
|
||||||
|
<CoverArt :url="song.coverUrl" :size="32" :radius="4" />
|
||||||
|
<div class="queue-song-info">
|
||||||
|
<div class="queue-song-name">{{ song.name }}</div>
|
||||||
|
<div class="queue-song-artist">{{ song.artist }}</div>
|
||||||
|
</div>
|
||||||
|
<button class="remove-btn" @click="removeSong(i)" title="移除">
|
||||||
|
<Icon icon="mdi:close" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Icon } from '@iconify/vue';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { usePlayerStore } from '../stores/player.js';
|
||||||
|
import CoverArt from './CoverArt.vue';
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
open: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
close: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const store = usePlayerStore();
|
||||||
|
|
||||||
|
async function removeSong(index: number) {
|
||||||
|
if (!store.activeBotId) return;
|
||||||
|
try {
|
||||||
|
await axios.delete(`/api/player/${store.activeBotId}/queue/${index}`);
|
||||||
|
} catch {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.queue-panel {
|
||||||
|
position: fixed;
|
||||||
|
top: var(--navbar-height);
|
||||||
|
right: -360px;
|
||||||
|
bottom: var(--player-height);
|
||||||
|
width: 360px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-left: 1px solid var(--border-color);
|
||||||
|
z-index: 90;
|
||||||
|
transition: right var(--transition-normal);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
&.open {
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-count {
|
||||||
|
margin-left: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 18px;
|
||||||
|
opacity: 0.6;
|
||||||
|
transition: opacity var(--transition-fast);
|
||||||
|
&:hover { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-empty {
|
||||||
|
padding: 40px 20px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: background var(--transition-fast);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--hover-bg);
|
||||||
|
.remove-btn { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: rgba(51, 94, 234, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-song-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-song-name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-song-artist {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-btn {
|
||||||
|
font-size: 14px;
|
||||||
|
opacity: 0;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: opacity var(--transition-fast);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
&:hover { color: var(--text-primary); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
<template>
|
||||||
|
<div class="song-card" :class="{ active }" @dblclick="$emit('play')">
|
||||||
|
<div class="song-index">{{ index }}</div>
|
||||||
|
<CoverArt :url="song.coverUrl" :size="36" :radius="6" />
|
||||||
|
<div class="song-info">
|
||||||
|
<div class="song-name">{{ song.name }}</div>
|
||||||
|
<div class="song-artist">{{ song.artist }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="song-album">{{ song.album }}</div>
|
||||||
|
<div class="song-duration">{{ formatDuration(song.duration) }}</div>
|
||||||
|
<div class="song-actions">
|
||||||
|
<button class="action-btn" @click.stop="$emit('play')" title="播放">
|
||||||
|
<Icon icon="mdi:play" />
|
||||||
|
</button>
|
||||||
|
<button class="action-btn" @click.stop="$emit('add')" title="添加到队列">
|
||||||
|
<Icon icon="mdi:playlist-plus" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Icon } from '@iconify/vue';
|
||||||
|
import CoverArt from './CoverArt.vue';
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
song: { id: string; name: string; artist: string; album: string; duration: number; coverUrl: string; platform: string };
|
||||||
|
index: number;
|
||||||
|
active?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
play: [];
|
||||||
|
add: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
function formatDuration(seconds: number): string {
|
||||||
|
const m = Math.floor(seconds / 60);
|
||||||
|
const s = seconds % 60;
|
||||||
|
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.song-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
transition: background var(--transition-fast);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--hover-bg);
|
||||||
|
.song-actions { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: rgba(51, 94, 234, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-index {
|
||||||
|
width: 24px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-artist {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-album {
|
||||||
|
width: 160px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-duration {
|
||||||
|
width: 48px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
font-size: 18px;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
opacity: 0.7;
|
||||||
|
transition: opacity var(--transition-fast);
|
||||||
|
&:hover { opacity: 1; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
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<typeof setTimeout> | 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 };
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
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');
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
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;
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
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 });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
@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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// 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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<template>
|
||||||
|
<div class="history-page">
|
||||||
|
<h1 class="page-title">播放历史</h1>
|
||||||
|
|
||||||
|
<div v-if="loading" class="loading">加载中...</div>
|
||||||
|
|
||||||
|
<div v-else-if="history.length === 0" class="empty">
|
||||||
|
暂无播放记录
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="history-list">
|
||||||
|
<SongCard
|
||||||
|
v-for="(song, i) in history"
|
||||||
|
:key="`${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>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { usePlayerStore } from '../stores/player.js';
|
||||||
|
import SongCard from '../components/SongCard.vue';
|
||||||
|
|
||||||
|
const store = usePlayerStore();
|
||||||
|
|
||||||
|
const history = ref<Array<{ id: string; name: string; artist: string; album: string; duration: number; coverUrl: string; platform: string }>>([]);
|
||||||
|
const loading = ref(true);
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!store.activeBotId) {
|
||||||
|
await store.fetchBots();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (store.activeBotId) {
|
||||||
|
try {
|
||||||
|
const res = await axios.get(`/api/player/${store.activeBotId}/history`);
|
||||||
|
history.value = res.data.history;
|
||||||
|
} catch {
|
||||||
|
// Ignore if API not ready
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = false;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.page-title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 800;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 80px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
<template>
|
||||||
|
<div class="home">
|
||||||
|
<!-- Bot Instance Selector -->
|
||||||
|
<div class="bot-selector" v-if="store.bots.length > 1">
|
||||||
|
<button
|
||||||
|
v-for="bot in store.bots"
|
||||||
|
:key="bot.id"
|
||||||
|
class="bot-tab"
|
||||||
|
:class="{ active: bot.id === store.activeBotId }"
|
||||||
|
@click="store.setActiveBotId(bot.id)"
|
||||||
|
>
|
||||||
|
{{ bot.name }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search Bar -->
|
||||||
|
<div class="search-bar" @click="$router.push('/search')">
|
||||||
|
<Icon icon="mdi:magnify" class="search-icon" />
|
||||||
|
<span class="search-placeholder">搜索歌曲、歌单、专辑...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Now Playing -->
|
||||||
|
<section v-if="store.currentSong" class="section">
|
||||||
|
<h2 class="section-title">正在播放</h2>
|
||||||
|
<div class="now-playing">
|
||||||
|
<CoverArt :url="store.currentSong.coverUrl" :size="80" :radius="10" :show-shadow="true" />
|
||||||
|
<div class="now-playing-info">
|
||||||
|
<div class="now-playing-name">{{ store.currentSong.name }}</div>
|
||||||
|
<div class="now-playing-artist">{{ store.currentSong.artist }} · {{ store.currentSong.album }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Recommended Playlists -->
|
||||||
|
<section class="section">
|
||||||
|
<h2 class="section-title">推荐歌单</h2>
|
||||||
|
<div class="playlist-grid">
|
||||||
|
<div
|
||||||
|
v-for="playlist in playlists"
|
||||||
|
:key="playlist.id"
|
||||||
|
class="playlist-card hover-scale"
|
||||||
|
@click="$router.push(`/playlist/${playlist.id}?platform=${playlist.platform}`)"
|
||||||
|
>
|
||||||
|
<CoverArt :url="playlist.coverUrl" :size="160" :radius="10" :show-shadow="true" />
|
||||||
|
<div class="playlist-name">{{ playlist.name }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue';
|
||||||
|
import { Icon } from '@iconify/vue';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { usePlayerStore } from '../stores/player.js';
|
||||||
|
import { useWebSocket } from '../composables/useWebSocket.js';
|
||||||
|
import CoverArt from '../components/CoverArt.vue';
|
||||||
|
|
||||||
|
const store = usePlayerStore();
|
||||||
|
const { connect } = useWebSocket();
|
||||||
|
|
||||||
|
interface Playlist {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
coverUrl: string;
|
||||||
|
platform: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const playlists = ref<Playlist[]>([]);
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
store.loadTheme();
|
||||||
|
connect();
|
||||||
|
await store.fetchBots();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await axios.get('/api/music/recommend/playlists');
|
||||||
|
playlists.value = res.data.playlists;
|
||||||
|
} catch {
|
||||||
|
// Ignore if API not ready
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.bot-selector {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-tab {
|
||||||
|
padding: 8px 20px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--hover-bg);
|
||||||
|
opacity: 0.6;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: white;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 20px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
margin-bottom: 32px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--transition-fast);
|
||||||
|
|
||||||
|
&:hover { background: var(--hover-bg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
opacity: 0.4;
|
||||||
|
margin-right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-placeholder {
|
||||||
|
opacity: 0.3;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
margin-bottom: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.now-playing {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
padding: 20px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.now-playing-name {
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.now-playing-artist {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, 1fr);
|
||||||
|
gap: 24px;
|
||||||
|
|
||||||
|
@media (max-width: 1200px) { grid-template-columns: repeat(4, 1fr); }
|
||||||
|
@media (max-width: 900px) { grid-template-columns: repeat(3, 1fr); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-card {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-name {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
<template>
|
||||||
|
<div class="lyrics-page" :style="bgStyle">
|
||||||
|
<div class="lyrics-overlay" />
|
||||||
|
|
||||||
|
<div v-if="currentSong" class="lyrics-content">
|
||||||
|
<div class="lyrics-left">
|
||||||
|
<CoverArt :url="currentSong.coverUrl" :size="280" :radius="14" :show-shadow="true" />
|
||||||
|
<div class="song-meta">
|
||||||
|
<div class="song-name">{{ currentSong.name }}</div>
|
||||||
|
<div class="song-artist">{{ currentSong.artist }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lyrics-right">
|
||||||
|
<div v-if="loading" class="lyrics-loading">加载歌词中...</div>
|
||||||
|
<div v-else-if="lines.length === 0" class="lyrics-empty">暂无歌词</div>
|
||||||
|
<div v-else class="lyrics-scroll" ref="scrollContainer">
|
||||||
|
<div
|
||||||
|
v-for="(line, i) in lines"
|
||||||
|
:key="i"
|
||||||
|
class="lyrics-line"
|
||||||
|
:class="{ active: i === activeLine }"
|
||||||
|
>
|
||||||
|
{{ line.text }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="no-song">
|
||||||
|
当前没有正在播放的歌曲
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch, onMounted } from 'vue';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { usePlayerStore } from '../stores/player.js';
|
||||||
|
import CoverArt from '../components/CoverArt.vue';
|
||||||
|
|
||||||
|
const store = usePlayerStore();
|
||||||
|
const currentSong = computed(() => store.currentSong);
|
||||||
|
|
||||||
|
interface LyricLine {
|
||||||
|
time: number;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = ref<LyricLine[]>([]);
|
||||||
|
const activeLine = ref(0);
|
||||||
|
const loading = ref(false);
|
||||||
|
const scrollContainer = ref<HTMLElement | null>(null);
|
||||||
|
const dominantColor = ref('');
|
||||||
|
|
||||||
|
const bgStyle = computed(() => {
|
||||||
|
if (currentSong.value?.coverUrl) {
|
||||||
|
return {
|
||||||
|
backgroundImage: `url(${currentSong.value.coverUrl})`,
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
|
||||||
|
async function fetchLyrics() {
|
||||||
|
if (!currentSong.value) return;
|
||||||
|
loading.value = true;
|
||||||
|
lines.value = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await axios.get(`/api/music/lyrics/${currentSong.value.id}`, {
|
||||||
|
params: { platform: currentSong.value.platform },
|
||||||
|
});
|
||||||
|
lines.value = res.data.lyrics || [];
|
||||||
|
} catch {
|
||||||
|
lines.value = [];
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(currentSong, () => {
|
||||||
|
fetchLyrics();
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (currentSong.value) fetchLyrics();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.lyrics-page {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 50;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lyrics-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
backdrop-filter: blur(60px);
|
||||||
|
-webkit-backdrop-filter: blur(60px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lyrics-content {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
gap: 60px;
|
||||||
|
max-width: 1000px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lyrics-left {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-meta {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-name {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: white;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-artist {
|
||||||
|
font-size: 14px;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lyrics-right {
|
||||||
|
flex: 1;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lyrics-scroll {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 40px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lyrics-line {
|
||||||
|
font-size: 18px;
|
||||||
|
color: rgba(255, 255, 255, 0.35);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.lyrics-loading,
|
||||||
|
.lyrics-empty {
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
font-size: 14px;
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-song {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
<template>
|
||||||
|
<div class="playlist-page">
|
||||||
|
<div v-if="loading" class="loading">加载中...</div>
|
||||||
|
|
||||||
|
<template v-else-if="playlist">
|
||||||
|
<!-- Hero Header -->
|
||||||
|
<div class="playlist-hero">
|
||||||
|
<CoverArt :url="playlist.coverUrl" :size="200" :radius="14" :show-shadow="true" />
|
||||||
|
<div class="playlist-meta">
|
||||||
|
<h1 class="playlist-title">{{ playlist.name }}</h1>
|
||||||
|
<p class="playlist-desc">{{ playlist.description }}</p>
|
||||||
|
<div class="playlist-stats">
|
||||||
|
{{ playlist.songCount }} 首歌曲
|
||||||
|
</div>
|
||||||
|
<button class="play-all-btn" @click="playAll">
|
||||||
|
<Icon icon="mdi:play" />
|
||||||
|
播放全部
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Song List -->
|
||||||
|
<div class="song-list">
|
||||||
|
<SongCard
|
||||||
|
v-for="(song, i) in songs"
|
||||||
|
:key="song.id"
|
||||||
|
: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>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import { Icon } from '@iconify/vue';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { usePlayerStore } from '../stores/player.js';
|
||||||
|
import CoverArt from '../components/CoverArt.vue';
|
||||||
|
import SongCard from '../components/SongCard.vue';
|
||||||
|
|
||||||
|
const store = usePlayerStore();
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
interface PlaylistDetail {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
coverUrl: string;
|
||||||
|
songCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const playlist = ref<PlaylistDetail | null>(null);
|
||||||
|
const songs = ref<Array<{ id: string; name: string; artist: string; album: string; duration: number; coverUrl: string; platform: string }>>([]);
|
||||||
|
const loading = ref(true);
|
||||||
|
|
||||||
|
async function playAll() {
|
||||||
|
if (songs.value.length > 0) {
|
||||||
|
const first = songs.value[0];
|
||||||
|
await store.play(first.name, first.platform);
|
||||||
|
for (let i = 1; i < songs.value.length; i++) {
|
||||||
|
await store.addToQueue(songs.value[i].name, songs.value[i].platform);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const id = route.params.id as string;
|
||||||
|
const platform = (route.query.platform as string) || 'netease';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await axios.get(`/api/music/playlist/${id}`, {
|
||||||
|
params: { platform },
|
||||||
|
});
|
||||||
|
playlist.value = res.data.playlist;
|
||||||
|
songs.value = res.data.songs;
|
||||||
|
} catch {
|
||||||
|
// Ignore if API not ready
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.playlist-hero {
|
||||||
|
display: flex;
|
||||||
|
gap: 32px;
|
||||||
|
margin-bottom: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 800;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-desc {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-stats {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-all-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 10px 28px;
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: white;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
width: fit-content;
|
||||||
|
transition: transform var(--transition-fast);
|
||||||
|
|
||||||
|
&:hover { transform: scale(1.04); }
|
||||||
|
&:active { transform: scale(0.96); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
<template>
|
||||||
|
<div class="search-page">
|
||||||
|
<div class="search-header">
|
||||||
|
<div class="search-input-wrap">
|
||||||
|
<Icon icon="mdi:magnify" class="search-icon" />
|
||||||
|
<input
|
||||||
|
ref="searchInput"
|
||||||
|
v-model="query"
|
||||||
|
class="search-input"
|
||||||
|
placeholder="搜索歌曲、歌手、专辑..."
|
||||||
|
@keyup.enter="doSearch"
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="platform-toggle">
|
||||||
|
<button
|
||||||
|
class="platform-btn"
|
||||||
|
:class="{ active: platform === 'netease' }"
|
||||||
|
@click="platform = 'netease'; doSearch()"
|
||||||
|
>
|
||||||
|
网易云
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="platform-btn"
|
||||||
|
:class="{ active: platform === 'qq' }"
|
||||||
|
@click="platform = 'qq'; doSearch()"
|
||||||
|
>
|
||||||
|
QQ音乐
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="loading">搜索中...</div>
|
||||||
|
|
||||||
|
<div v-else-if="results.length > 0" class="results">
|
||||||
|
<SongCard
|
||||||
|
v-for="(song, i) in results"
|
||||||
|
:key="song.id"
|
||||||
|
: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>
|
||||||
|
|
||||||
|
<div v-else-if="searched" class="empty">
|
||||||
|
未找到相关结果
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import { Icon } from '@iconify/vue';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { usePlayerStore } from '../stores/player.js';
|
||||||
|
import SongCard from '../components/SongCard.vue';
|
||||||
|
|
||||||
|
const store = usePlayerStore();
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
const query = ref((route.query.q as string) || '');
|
||||||
|
const platform = ref<'netease' | 'qq'>('netease');
|
||||||
|
const results = ref<Array<{ id: string; name: string; artist: string; album: string; duration: number; coverUrl: string; platform: string }>>([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const searched = ref(false);
|
||||||
|
|
||||||
|
async function doSearch() {
|
||||||
|
if (!query.value.trim()) return;
|
||||||
|
loading.value = true;
|
||||||
|
searched.value = true;
|
||||||
|
try {
|
||||||
|
const res = await axios.get('/api/music/search', {
|
||||||
|
params: { q: query.value, platform: platform.value },
|
||||||
|
});
|
||||||
|
results.value = res.data.songs;
|
||||||
|
} catch {
|
||||||
|
results.value = [];
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (query.value) doSearch();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.search-header {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 14px 20px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-icon {
|
||||||
|
font-size: 22px;
|
||||||
|
opacity: 0.4;
|
||||||
|
margin-right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
flex: 1;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
outline: none;
|
||||||
|
font-size: 16px;
|
||||||
|
font-family: inherit;
|
||||||
|
color: var(--text-primary);
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-toggle {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-btn {
|
||||||
|
padding: 6px 18px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--hover-bg);
|
||||||
|
opacity: 0.6;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: white;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
<template>
|
||||||
|
<div class="settings-page">
|
||||||
|
<h1 class="page-title">设置</h1>
|
||||||
|
|
||||||
|
<!-- Theme Toggle -->
|
||||||
|
<section class="settings-section">
|
||||||
|
<h2 class="section-title">外观</h2>
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-label">
|
||||||
|
<Icon icon="mdi:theme-light-dark" class="setting-icon" />
|
||||||
|
主题模式
|
||||||
|
</div>
|
||||||
|
<button class="theme-toggle" @click="store.toggleTheme()">
|
||||||
|
<Icon :icon="store.theme === 'dark' ? 'mdi:weather-night' : 'mdi:weather-sunny'" />
|
||||||
|
{{ store.theme === 'dark' ? '深色' : '浅色' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Bot Management -->
|
||||||
|
<section class="settings-section">
|
||||||
|
<h2 class="section-title">机器人管理</h2>
|
||||||
|
<div class="bot-list">
|
||||||
|
<div v-for="bot in store.bots" :key="bot.id" class="bot-item">
|
||||||
|
<div class="bot-info">
|
||||||
|
<div class="bot-name">{{ bot.name }}</div>
|
||||||
|
<div class="bot-status" :class="{ online: bot.connected }">
|
||||||
|
{{ bot.connected ? '在线' : '离线' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bot-actions">
|
||||||
|
<button class="btn-sm" @click="toggleBot(bot.id, bot.connected)">
|
||||||
|
{{ bot.connected ? '停止' : '启动' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="create-bot">
|
||||||
|
<h3 class="subsection-title">创建新实例</h3>
|
||||||
|
<div class="form-row">
|
||||||
|
<input v-model="newBotName" class="input" placeholder="机器人名称" />
|
||||||
|
<input v-model="newBotServer" class="input" placeholder="TS服务器地址" />
|
||||||
|
<button class="btn-primary" @click="createBot">创建</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Music Account -->
|
||||||
|
<section class="settings-section">
|
||||||
|
<h2 class="section-title">音乐账号</h2>
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-label">
|
||||||
|
<Icon icon="mdi:music-note" class="setting-icon" />
|
||||||
|
网易云登录Cookie
|
||||||
|
</div>
|
||||||
|
<div class="cookie-input-wrap">
|
||||||
|
<textarea
|
||||||
|
v-model="neteaseCookie"
|
||||||
|
class="textarea"
|
||||||
|
placeholder="粘贴网易云音乐Cookie..."
|
||||||
|
rows="3"
|
||||||
|
/>
|
||||||
|
<button class="btn-primary" @click="saveCookie('netease')">保存</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-label">
|
||||||
|
<Icon icon="mdi:music-note" class="setting-icon" />
|
||||||
|
QQ音乐登录Cookie
|
||||||
|
</div>
|
||||||
|
<div class="cookie-input-wrap">
|
||||||
|
<textarea
|
||||||
|
v-model="qqCookie"
|
||||||
|
class="textarea"
|
||||||
|
placeholder="粘贴QQ音乐Cookie..."
|
||||||
|
rows="3"
|
||||||
|
/>
|
||||||
|
<button class="btn-primary" @click="saveCookie('qq')">保存</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Command Prefix -->
|
||||||
|
<section class="settings-section">
|
||||||
|
<h2 class="section-title">命令设置</h2>
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-label">
|
||||||
|
<Icon icon="mdi:console" class="setting-icon" />
|
||||||
|
命令前缀
|
||||||
|
</div>
|
||||||
|
<div class="prefix-input-wrap">
|
||||||
|
<input v-model="commandPrefix" class="input input-sm" placeholder="!" />
|
||||||
|
<button class="btn-primary" @click="savePrefix">保存</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { Icon } from '@iconify/vue';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { usePlayerStore } from '../stores/player.js';
|
||||||
|
|
||||||
|
const store = usePlayerStore();
|
||||||
|
|
||||||
|
const newBotName = ref('');
|
||||||
|
const newBotServer = ref('');
|
||||||
|
const neteaseCookie = ref('');
|
||||||
|
const qqCookie = ref('');
|
||||||
|
const commandPrefix = ref('!');
|
||||||
|
|
||||||
|
async function createBot() {
|
||||||
|
if (!newBotName.value || !newBotServer.value) return;
|
||||||
|
try {
|
||||||
|
await axios.post('/api/bot', {
|
||||||
|
name: newBotName.value,
|
||||||
|
server: newBotServer.value,
|
||||||
|
});
|
||||||
|
newBotName.value = '';
|
||||||
|
newBotServer.value = '';
|
||||||
|
await store.fetchBots();
|
||||||
|
} catch {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleBot(botId: string, connected: boolean) {
|
||||||
|
try {
|
||||||
|
if (connected) {
|
||||||
|
await axios.post(`/api/bot/${botId}/stop`);
|
||||||
|
} else {
|
||||||
|
await axios.post(`/api/bot/${botId}/start`);
|
||||||
|
}
|
||||||
|
await store.fetchBots();
|
||||||
|
} catch {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveCookie(platform: string) {
|
||||||
|
const cookie = platform === 'netease' ? neteaseCookie.value : qqCookie.value;
|
||||||
|
try {
|
||||||
|
await axios.post('/api/settings/cookie', { platform, cookie });
|
||||||
|
} catch {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePrefix() {
|
||||||
|
try {
|
||||||
|
await axios.post('/api/settings/prefix', { prefix: commandPrefix.value });
|
||||||
|
} catch {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.page-title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 800;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section {
|
||||||
|
margin-bottom: 36px;
|
||||||
|
padding: 24px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subsection-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-row {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 20px;
|
||||||
|
background: var(--hover-bg);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
|
||||||
|
&:hover { background: var(--color-primary); color: white; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: var(--hover-bg);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-status {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--border-color);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
|
||||||
|
&.online {
|
||||||
|
background: rgba(51, 94, 234, 0.15);
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: var(--hover-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
outline: none;
|
||||||
|
|
||||||
|
&:focus { border-color: var(--color-primary); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-sm {
|
||||||
|
max-width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: var(--hover-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
outline: none;
|
||||||
|
resize: vertical;
|
||||||
|
|
||||||
|
&:focus { border-color: var(--color-primary); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.cookie-input-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prefix-input-wrap {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
padding: 10px 20px;
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: white;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: transform var(--transition-fast);
|
||||||
|
|
||||||
|
&:hover { transform: scale(1.02); }
|
||||||
|
&:active { transform: scale(0.98); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
padding: 6px 14px;
|
||||||
|
background: var(--hover-bg);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
|
||||||
|
&:hover { background: var(--color-primary); color: white; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"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" }]
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"composite": true,
|
||||||
|
"noEmit": false
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
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',
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in new issue
Block a user