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:
saopig1andClaude Opus 4.6 committed 2026-03-30 00:58:33 +08:00
1 parent 2a966e914c
commit 694e3c953b
25 files changed
+5388 -1

No files matched your search

+73
View File
@@ -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>
+98
View File
@@ -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>
+166
View File
@@ -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>
+164
View File
@@ -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>
+120
View File
@@ -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>