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

+153
View File
@@ -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>