feat: QZ Music for Windows(个人学习项目);Vue主界面+设置界面;Router路由;Pinia全局状态管理;封面取色;IPC通信示例

This commit is contained in:
lqtmcstudio
2026-01-02 14:14:59 +08:00
commit 1a397a7fb3
26 changed files with 9385 additions and 0 deletions

185
src/App.vue Normal file
View File

@@ -0,0 +1,185 @@
<template>
<div class="app-container" :class="{ 'dark-mode': store.darkMode }">
<SettingsOverlay />
<SideBar />
<main class="main-content">
<header class="header-bar">
<div class="header-left"></div>
<WindowControls />
</header>
<div class="content-scrollable">
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</div>
</main>
<footer class="footer-player">
<PlayerBar />
</footer>
</div>
</template>
<script setup lang="ts">
import { usePlayerStore } from './stores/playerStore';
import SideBar from './components/layout/SideBar.vue';
import WindowControls from './components/layout/WindowControls.vue';
import PlayerBar from './components/layout/PlayerBar.vue';
import SettingsOverlay from "./components/layout/SettingsOverlay.vue";
const store = usePlayerStore();
</script>
<style lang="scss">
/* 主题变量系统 */
:root {
/* 亮色主题变量 */
--bg-primary: #ffffff;
--bg-secondary: #f6f8fa;
--bg-tertiary: #f9fafb;
--text-primary: #333333;
--text-secondary: #666666;
--text-tertiary: #888888;
--border-color: #e0e0e0;
--border-light: #f0f0f0;
--sidebar-bg: #f6f8fa;
--sidebar-border: #e0e0e0;
--header-bg: #ffffff;
--content-bg: #ffffff;
--player-bg: #ffffff;
--card-bg: #ffffff;
--overlay-bg: rgba(0, 0, 0, 0.4);
--settings-modal-bg: white;
--settings-sidebar-bg: #f9fafb;
--settings-sidebar-border: #eee;
--nav-item-hover: #eee;
--nav-item-active: #333;
--nav-item-active-text: white;
--close-btn-color: #999;
--close-btn-hover: #333;
--dummy-option-border: #f0f0f0;
--logo-placeholder-bg: linear-gradient(45deg, #ff6b6b, #4ecdc4);
}
/* 深色主题变量 */
.dark-mode {
--bg-primary: #1a1a1a;
--bg-secondary: #2d2d2d;
--bg-tertiary: #333333;
--text-primary: #ffffff;
--text-secondary: #b0b0b0;
--text-tertiary: #808080;
--border-color: #404040;
--border-light: #3a3a3a;
--sidebar-bg: #2d2d2d;
--sidebar-border: #404040;
--header-bg: #1a1a1a;
--content-bg: #1a1a1a;
--player-bg: #1a1a1a;
--card-bg: #2d2d2d;
--overlay-bg: rgba(0, 0, 0, 0.7);
--settings-modal-bg: #2d2d2d;
--settings-sidebar-bg: #333333;
--settings-sidebar-border: #404040;
--nav-item-hover: #404040;
--nav-item-active: #6366f1;
--nav-item-active-text: white;
--close-btn-color: #808080;
--close-btn-hover: #ffffff;
--dummy-option-border: #3a3a3a;
--logo-placeholder-bg: linear-gradient(45deg, #6366f1, #8b5cf6);
}
/* 全局重置 */
html, body, #app {
height: 100%;
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
overflow: hidden; /* 防止浏览器默认滚动 */
background-color: var(--bg-primary);
color: var(--text-primary);
transition: background-color 0.6s ease, color 0.6s ease;
}
/* 布局结构 */
.app-container {
display: grid;
grid-template-columns: 220px 1fr; /* 侧边栏宽度 | 主内容 */
grid-template-rows: 1fr 80px; /* 主体高度 | 播放器高度 */
transition: background-color 0.6s ease;
}
/* 布局结构样式 */
.app-container {
height: 100vh;
width: 100vw;
/* 侧边栏跨越第一行 */
.sidebar {
grid-column: 1 / 2;
grid-row: 1 / 2;
}
/* 主内容区 */
.main-content {
grid-column: 2 / 3;
grid-row: 1 / 2;
display: flex;
flex-direction: column;
position: relative;
background: var(--content-bg);
/* 顶部拖拽区 */
.header-bar {
height: 60px;
display: flex;
justify-content: space-between;
align-items: center;
-webkit-app-region: drag; /* Electron 拖拽 */
background: var(--header-bg);
}
/* 路由视图滚动区 */
.content-scrollable {
flex: 1;
overflow-y: auto;
padding: 20px 40px;
background: var(--content-bg);
/* 隐藏滚动条但保留功能 */
&::-webkit-scrollbar { width: 6px; }
&::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 3px;
}
&::-webkit-scrollbar-track {
background: var(--bg-secondary);
}
}
}
/* 底部播放器跨越两列 */
.footer-player {
grid-column: 1 / 3;
grid-row: 2 / 3;
z-index: 200;
background: var(--player-bg);
}
}
/* 路由动画 */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,276 @@
<template>
<div class="player-bar" :style="gradientStyle">
<div class="glass-overlay"></div>
<!-- 新增顶部进度条使用专辑图提取的最深色 -->
<div class="top-progress">
<div class="progress-fill" :style="{ width: store.progressPercentage + '%', backgroundColor: store.progressColor }"></div>
</div>
<div class="bar-content">
<div class="side-container left">
<div class="track-info">
<div class="album-cover">
<img :src="store.currentSong.cover" @load="store.extractColors" alt="Album Pic"/>
</div>
<div class="meta">
<div class="song-title">{{ store.currentSong.title }}</div>
<div class="artist-name">{{ store.currentSong.artist }}</div>
</div>
</div>
</div>
<div class="center-container">
<div class="controls-wrapper">
<button class="ctrl-btn sm">
<Icon icon="lucide:skip-back" width="22" />
</button>
<button class="ctrl-btn lg play-btn" @click="store.togglePlay">
<Icon :icon="store.isPlaying ? 'lucide:pause' : 'lucide:play'" width="28" fill="currentColor" />
</button>
<button class="ctrl-btn sm">
<Icon icon="lucide:skip-forward" width="22" />
</button>
</div>
</div>
<div class="side-container right">
<div class="extra-controls">
<!-- 美化后的时间显示放在右侧最左侧 -->
<div class="time-display">
{{ formatTime(store.currentTime) }}&nbsp;/&nbsp;{{ formatTime(store.currentSong.duration) }}
</div>
<Icon icon="lucide:repeat" width="20" class="icon-btn" />
<div class="volume-box">
<Icon icon="lucide:volume-2" width="20" />
<el-slider v-model="store.volume" size="small" class="custom-slider" />
</div>
<Icon icon="lucide:list-music" width="20"
class="icon-btn"
:class="{ active: store.showPlaylist }"
@click="store.showPlaylist = !store.showPlaylist" />
</div>
</div>
</div>
<!-- 保留底部细进度条鼠标悬停可变粗 -->
<div class="bottom-progress">
<div class="progress-fill" :style="{ width: store.progressPercentage + '%' }"></div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { usePlayerStore } from '../../stores/playerStore';
import { Icon } from '@iconify/vue';
const store = usePlayerStore();
const gradientStyle = computed(() => {
return {
background: `linear-gradient(135deg, ${store.themeColors.primary}, ${store.themeColors.secondary})`
};
});
function formatTime(val: number) {
if (!val || isNaN(val)) return '0:00';
const m = Math.floor(val / 60);
const s = Math.floor(val % 60);
return `${m}:${s < 10 ? '0' + s : s}`;
}
</script>
<style scoped lang="scss">
.player-bar {
height: 80px; // 稍微增高以容纳布局
width: 100%;
position: relative;
overflow: hidden;
transition: background 0.5s ease; // 颜色切换动画
.glass-overlay {
position: absolute;
inset: 0;
background: rgba(255, 255, 255, 0.15); // 稍微提亮
backdrop-filter: blur(20px); // 毛玻璃
z-index: 1;
border-top: 1px solid rgba(255,255,255,0.1);
}
.bar-content {
position: relative;
z-index: 2;
height: 100%;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 3%; // 使用百分比 padding 适应缩放
color: white; // 假设提取的颜色较深,文字用白色,反之需计算反色
}
/* 左右容器,确保中间绝对居中 */
.side-container {
flex: 1;
display: flex;
align-items: center;
min-width: 0; // 防止 flex item 溢出
&.right {
justify-content: flex-end;
}
}
/* 左侧信息 */
.track-info {
display: flex;
align-items: center;
gap: 16px;
.album-cover {
width: 56px;
height: 56px;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
flex-shrink: 0;
img { width: 100%; height: 100%; object-fit: cover; }
}
.meta {
overflow: hidden;
white-space: nowrap;
.song-title { font-size: 1.1rem; font-weight: 600; text-overflow: ellipsis; overflow: hidden; }
.artist-name { font-size: 0.9rem; opacity: 0.8; margin-top: 4px; }
}
}
/* 绝对居中容器 */
.center-container {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
width: 200px; // 限制宽度
.controls-wrapper {
display: flex;
align-items: center;
gap: 24px;
}
}
/* 按钮样式 */
.ctrl-btn {
background: transparent;
border: none;
color: rgba(255,255,255,0.9);
cursor: pointer;
padding: 0;
transition: transform 0.2s, color 0.2s;
&:hover { color: #fff; transform: scale(1.1); }
&:active { transform: scale(0.95); }
&.play-btn {
width: 48px;
height: 48px;
background: rgba(255,255,255,0.2);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
&:hover { background: rgba(255,255,255,0.3); }
}
}
/* 右侧图标 */
.extra-controls {
display: flex;
align-items: center;
gap: 20px;
.time-display {
margin-right: auto;
padding-right: 20px; // 与图标保持距离
}
.icon-btn {
cursor: pointer;
opacity: 0.8;
transition: opacity 0.2s;
&:hover { opacity: 1; }
&.active { color: #ffeb3b; opacity: 1; }
}
.volume-box {
display: flex;
align-items: center;
gap: 8px;
width: 100px;
}
}
/* 底部微型进度条 */
.bottom-progress {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 3px;
background: rgba(255,255,255,0.1);
z-index: 3;
cursor: pointer;
.progress-fill {
height: 100%;
background: rgba(255,255,255,0.6);
transition: width 0.1s linear;
}
&:hover {
height: 6px;
.progress-fill { background: #fff; }
}
}
/* 顶部进度条 */
.top-progress {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 3px;
background: rgba(0, 0, 0, 0.2);
z-index: 4;
.progress-fill {
height: 100%;
transition: width 0.2s ease;
background-color: white; // fallback
}
}
.time-display {
font-family: 'SF Mono', 'Roboto Mono', Menlo, monospace;
font-size: 12.5px;
font-weight: 500;
opacity: 0.9;
letter-spacing: 0.5px;
min-width: 100px;
text-align: left;
}
}
/* 覆盖 Element Slider 样式以适配深色背景 */
:deep(.custom-slider) {
--el-slider-main-bg-color: rgba(255,255,255,0.8);
--el-slider-runway-bg-color: rgba(255,255,255,0.2);
.el-slider__bar { background-color: white; }
.el-slider__button { border-color: white; width: 12px; height: 12px; }
}
</style>

View File

@@ -0,0 +1,361 @@
<template>
<!-- 遮罩层独立动画 -->
<transition name="fade-quick">
<div v-if="store.showSettings" class="backdrop" @click="store.toggleSettings"></div>
</transition>
<!-- 弹窗动画 -->
<transition name="fade-slide">
<div v-if="store.showSettings" class="settings-overlay">
<div class="settings-modal">
<aside class="settings-sidebar">
<div class="title">设置</div>
<ul class="nav-list">
<li v-for="item in menuItems" :key="item.id"
:class="{ active: activeTab === item.id }"
@click="activeTab = item.id">
{{ item.name }}
</li>
</ul>
</aside>
<main class="settings-content">
<button class="close-btn" @click="store.toggleSettings">
<Icon icon="lucide:x" width="24" />
</button>
<transition name="fade" mode="out-in">
<!-- 通用设置 -->
<div v-if="activeTab === 'general'" class="section-content" key="general">
<h2>{{ menuItems.find(i => i.id === activeTab)?.name }}</h2>
<div class="dummy-option">
<span>深色模式</span>
<el-switch v-model="store.darkMode" @change="logSetting('darkMode', store.darkMode)" />
</div>
<div class="dummy-option">
<span>自动播放</span>
<el-switch v-model="settings.autoPlay" @change="logSetting('autoPlay', settings.autoPlay)" />
</div>
</div>
<!-- 播放与音质 -->
<div v-else-if="activeTab === 'audio'" class="section-content" key="audio">
<h2>{{ menuItems.find(i => i.id === activeTab)?.name }}</h2>
<div class="dummy-option">
<span>启用高音质 (Hi-Res)</span>
<el-switch v-model="settings.hiResAudio" @change="logSetting('hiResAudio', settings.hiResAudio)" />
</div>
<div class="dummy-option">
<span>开启桌面歌词</span>
<el-switch v-model="settings.desktopLyrics" @change="logSetting('desktopLyrics', settings.desktopLyrics)" />
</div>
<div class="dummy-option">
<span>音量增强</span>
<el-switch v-model="settings.volumeBoost" @change="logSetting('volumeBoost', settings.volumeBoost)" />
</div>
</div>
<!-- 快捷键 -->
<div v-else-if="activeTab === 'shortcut'" class="section-content" key="shortcut">
<h2>{{ menuItems.find(i => i.id === activeTab)?.name }}</h2>
<div class="dummy-option">
<span>启用全局快捷键</span>
<el-switch v-model="settings.globalShortcuts" @change="logSetting('globalShortcuts', settings.globalShortcuts)" />
</div>
<div class="dummy-option">
<span>媒体键控制</span>
<el-switch v-model="settings.mediaKeys" @change="logSetting('mediaKeys', settings.mediaKeys)" />
</div>
</div>
<!-- 下载管理 -->
<div v-else-if="activeTab === 'download'" class="section-content" key="download">
<h2>{{ menuItems.find(i => i.id === activeTab)?.name }}</h2>
<div class="dummy-option">
<span>下载完成后通知</span>
<el-switch v-model="settings.downloadNotify" @change="logSetting('downloadNotify', settings.downloadNotify)" />
</div>
<div class="dummy-option">
<span>高质量下载</span>
<el-switch v-model="settings.hqDownload" @change="logSetting('hqDownload', settings.hqDownload)" />
</div>
</div>
<!-- 关于页面 -->
<div v-else class="about-section" key="about">
<div class="logo-animate">M</div>
<h3>Muse Player</h3>
<p class="version">Version 1.0.0 Beta</p>
<div class="desc">
Designed for music lovers.<br>
Crafted with Vue 3 & Electron.
</div>
<div class="links">
<a href="#">GitHub</a>
<a href="#">Website</a>
</div>
</div>
</transition>
</main>
</div>
</div>
</transition>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue';
import { usePlayerStore } from '../../stores/playerStore';
import { Icon } from '@iconify/vue';
const store = usePlayerStore();
const activeTab = ref('general');
// 其他设置的本地状态管理
const settings = reactive({
autoPlay: true,
hiResAudio: true,
desktopLyrics: false,
volumeBoost: false,
globalShortcuts: true,
mediaKeys: true,
downloadNotify: true,
hqDownload: true
});
// 切换时打印日志
const logSetting = (key: string, value: boolean) => {
console.log(`[设置变更] ${key}: ${value ? '开启' : '关闭'}`);
// 这里可以添加实际保存设置的逻辑
};
const menuItems = [
{ id: 'general', name: '通用' },
{ id: 'audio', name: '播放与音质' },
{ id: 'shortcut', name: '快捷键' },
{ id: 'download', name: '下载管理' },
{ id: 'about', name: '关于' },
];
</script>
<style scoped lang="scss">
/* 遮罩层 - 快速淡入淡出 */
.fade-quick-enter-active,
.fade-quick-leave-active {
transition: opacity 0.1s ease;
}
.fade-quick-enter-from,
.fade-quick-leave-to {
opacity: 0;
}
/* 遮罩层样式 */
.backdrop {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 998;
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(4px);
}
/* 弹窗容器 */
.settings-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 999;
display: flex;
justify-content: center;
align-items: center;
pointer-events: none; /* 防止点击穿透 */
.settings-modal {
width: 70vw;
height: 70vh;
max-width: 900px;
background: var(--settings-modal-bg);
border-radius: 16px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
display: flex;
overflow: hidden;
position: relative;
pointer-events: auto; /* 恢复弹窗内的点击事件 */
}
.settings-sidebar {
width: 200px;
background: var(--settings-sidebar-bg);
padding: 30px 20px;
border-right: 1px solid var(--settings-sidebar-border);
.title {
font-size: 24px;
font-weight: bold;
margin-bottom: 30px;
padding-left: 10px;
color: var(--text-primary);
}
.nav-list {
list-style: none;
padding: 0;
li {
padding: 12px 16px;
margin-bottom: 8px;
border-radius: 8px;
cursor: pointer;
color: var(--text-secondary);
font-weight: 500;
transition: all 0.2s;
&:hover {
background: var(--nav-item-hover);
color: var(--text-primary);
}
&.active {
background: var(--nav-item-active);
color: var(--nav-item-active-text);
}
}
}
}
.settings-content {
flex: 1;
padding: 40px;
position: relative;
overflow-y: auto;
background: var(--settings-modal-bg);
.close-btn {
position: absolute;
top: 20px;
right: 20px;
background: none;
border: none;
cursor: pointer;
color: var(--close-btn-color);
&:hover {
color: var(--close-btn-hover);
}
}
h2 {
margin-top: 0;
margin-bottom: 30px;
font-size: 20px;
color: var(--text-primary);
}
.dummy-option {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 0;
border-bottom: 1px solid var(--dummy-option-border);
&:last-child {
border-bottom: none;
}
span {
color: var(--text-primary);
}
}
}
/* 关于页面样式 */
.about-section {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
text-align: center;
.logo-animate {
width: 80px;
height: 80px;
background: var(--logo-placeholder-bg);
border-radius: 20px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 40px;
font-weight: bold;
margin-bottom: 20px;
animation: float 6s ease-in-out infinite;
}
h3 {
margin: 0;
font-size: 24px;
color: var(--text-primary);
}
.version {
color: var(--text-tertiary);
margin: 10px 0 30px;
font-family: monospace;
}
.desc {
line-height: 1.6;
color: var(--text-secondary);
margin-bottom: 40px;
}
.links {
display: flex;
gap: 20px;
a {
color: var(--text-primary);
text-decoration: none;
font-weight: 600;
border-bottom: 1px solid transparent;
transition: border 0.2s;
&:hover {
border-color: var(--text-primary);
}
}
}
}
}
/* 弹窗进入动画 - 遮罩已经独立 */
.fade-slide-enter-active,
.fade-slide-leave-active {
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
.fade-slide-enter-from {
opacity: 0;
transform: scale(0.95) translateY(10px);
}
.fade-slide-leave-to {
opacity: 0;
transform: scale(0.95) translateY(-10px);
}
@keyframes float {
0%, 100% {
transform: translateY(0) rotate(0);
}
50% {
transform: translateY(-10px) rotate(5deg);
}
}
</style>

View File

@@ -0,0 +1,98 @@
<template>
<aside class="sidebar">
<div class="brand">
<div class="logo-placeholder">M</div>
<span class="app-name">Muse Player</span>
</div>
<nav class="menu">
<router-link to="/" class="menu-item" active-class="active">
<Icon icon="lucide:home" width="20" />
<span>主页</span>
</router-link>
<router-link to="/local" class="menu-item" active-class="active">
<Icon icon="lucide:hard-drive" width="20" />
<span>本地音乐</span>
</router-link>
<router-link to="/playlist" class="menu-item" active-class="active">
<Icon icon="lucide:list-music" width="20" />
<span>我的歌单</span>
</router-link>
</nav>
</aside>
</template>
<script setup lang="ts">
import { Icon } from '@iconify/vue';
</script>
<style scoped lang="scss">
.sidebar {
width: 220px;
height: 100%;
background-color: var(--sidebar-bg);
display: flex;
flex-direction: column;
padding: 20px 12px;
border-right: 1px solid var(--sidebar-border);
// 针对 Electron 的拖拽区域避免
-webkit-app-region: drag;
.brand {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 40px;
padding-left: 12px;
.logo-placeholder {
width: 32px;
height: 32px;
background: linear-gradient(135deg, #ff6b6b, #556270);
border-radius: 8px;
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
}
.app-name {
font-weight: 600;
font-size: 18px;
color: var(--text-primary);
}
}
.menu {
display: flex;
flex-direction: column;
gap: 8px;
-webkit-app-region: no-drag; // 菜单项可点击,不可拖拽
.menu-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
border-radius: 10px;
color: var(--text-secondary);
text-decoration: none;
transition: all 0.2s ease;
font-size: 14px;
&:hover {
background-color: var(--nav-item-hover);
color: var(--text-primary);
}
&.active {
background-color: var(--bg-primary);
color: var(--text-primary);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
font-weight: 500;
}
}
}
}
</style>

View File

@@ -0,0 +1,88 @@
<template>
<div class="window-controls">
<div class="settings-btn" @click="store.toggleSettings">
<Icon icon="lucide:settings" width="22" />
</div>
<div class="divider"></div>
<div class="traffic-lights">
<div class="light minimize" @click="handleMinimize"></div>
<div class="light maximize" @click="handleMaximize"></div>
<div class="light close" @click="handleClose"></div>
</div>
</div>
</template>
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import { usePlayerStore } from '../../stores/playerStore'
const store = usePlayerStore()
// ✅ 调用主进程暴露的 API
const handleClose = () => {
window.electronAPI.closeWindow()
}
const handleMinimize = () => {
window.electronAPI.minimizeWindow()
}
const handleMaximize = async () => {
// 可选:切换图标(比如最大化/还原)
await window.electronAPI.maximizeWindow()
}
</script>
<style scoped lang="scss">
.window-controls {
display: flex;
align-items: center;
padding: 0 24px;
height: 100%;
gap: 24px; // 设置和红绿灯之间的距离
-webkit-app-region: no-drag;
.settings-btn {
cursor: pointer;
color: var(--text-tertiary);
display: flex;
align-items: center;
transition: all 0.3s ease;
&:hover {
color: var(--text-primary);
transform: rotate(45deg); // 简单的交互动画
}
}
.divider {
width: 1px;
height: 18px;
background-color: var(--border-color);
}
.traffic-lights {
display: flex;
gap: 10px; // 按钮间距拉大
.light {
width: 15px; // 放大按钮 (原12px)
height: 15px;
border-radius: 50%;
cursor: pointer;
position: relative;
transition: transform 0.1s, opacity 0.2s;
&:hover { opacity: 0.8; }
&:active { transform: scale(0.9); }
/* 调整了顺序: 最小化-最大化-关闭,符合一般习惯,也可按需调整 */
&.close { background-color: #ff5f56; }
&.minimize { background-color: #ffbd2e; }
&.maximize { background-color: #27c93f; }
}
}
}
</style>

25
src/main.ts Normal file
View File

@@ -0,0 +1,25 @@
// src/main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { createRouter, createWebHashHistory } from 'vue-router'
import App from './App.vue'
import HomeView from './views/HomeView.vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
const pinia = createPinia()
const router = createRouter({
history: createWebHashHistory(),
routes: [
{ path: '/', component: HomeView },
// 可扩展其他路由
]
})
const app = createApp(App)
app.use(pinia)
app.use(router)
app.use(ElementPlus)
app.mount('#app')

168
src/stores/playerStore.ts Normal file
View File

@@ -0,0 +1,168 @@
import { defineStore } from 'pinia';
import { ref, computed, watch } from 'vue';
export const usePlayerStore = defineStore('player', () => {
const currentSong = ref({
id: 1,
title: "Example Track",
artist: "AI Artist",
cover: "http://p2.music.126.net/W3VMsSEjTdvhz7h3a0oxTg==/17782401556325576.jpg?param=130y130",
url: "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3",
duration: 0
});
const isPlaying = ref(false);
const currentTime = ref(0);
const volume = ref(80);
const showPlaylist = ref(false);
const showSettings = ref(false);
const darkMode = ref(false);
const themeColors = ref({ primary: '#6366f1', secondary: '#a855f7' });
// 新增:用于顶部进度条的最深色
const progressColor = ref('#ffffff');
const audio = new Audio(currentSong.value.url);
audio.volume = volume.value / 100;
// 颜色提取函数(轻量版,不依赖外部库)
function extractColors() {
const img = new Image();
img.crossOrigin = 'Anonymous';
img.src = currentSong.value.cover + '?t=' + Date.now(); // 避免缓存
img.onload = () => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) return;
// 缩小图片以提高性能
const scale = 0.1;
canvas.width = Math.floor(img.width * scale);
canvas.height = Math.floor(img.height * scale);
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
// 收集所有像素颜色
const colors: { r: number; g: number; b: number; brightness: number }[] = [];
for (let i = 0; i < imageData.length; i += 4) {
const r = imageData[i];
const g = imageData[i + 1];
const b = imageData[i + 2];
const brightness = (r + g + b) / 3;
colors.push({ r, g, b, brightness });
}
// 计算颜色频率(简单实现)
const colorFrequency: Record<string, number> = {};
colors.forEach(color => {
// 将颜色量化为 16 位色,减少颜色数量
const key = `${Math.floor(color.r / 16)}${Math.floor(color.g / 16)}${Math.floor(color.b / 16)}`;
colorFrequency[key] = (colorFrequency[key] || 0) + 1;
});
// 转换回 RGB 并按频率排序
const sortedColors = Object.entries(colorFrequency)
.map(([key, count]) => {
const r = parseInt(key[0], 16) * 16;
const g = parseInt(key[1], 16) * 16;
const b = parseInt(key[2], 16) * 16;
return { r, g, b, count };
})
.sort((a, b) => b.count - a.count);
// 提取主色调和辅助色调
if (sortedColors.length > 0) {
// 主色调:频率最高的颜色
const primary = sortedColors[0];
const primaryColor = `rgb(${primary.r}, ${primary.g}, ${primary.b})`;
// 辅助色调:选择与主色调亮度差异较大的颜色
let secondary = sortedColors[1] || sortedColors[0];
let maxBrightnessDiff = Math.abs(primary.brightness - secondary.brightness);
// 在频率较高的颜色中寻找最合适的辅助色
for (let i = 1; i < Math.min(10, sortedColors.length); i++) {
const currentBrightness = (sortedColors[i].r + sortedColors[i].g + sortedColors[i].b) / 3;
const brightnessDiff = Math.abs(primary.brightness - currentBrightness);
if (brightnessDiff > maxBrightnessDiff) {
maxBrightnessDiff = brightnessDiff;
secondary = sortedColors[i];
}
}
const secondaryColor = `rgb(${secondary.r}, ${secondary.g}, ${secondary.b})`;
// 更新主题颜色
themeColors.value = {
primary: primaryColor,
secondary: secondaryColor
};
// 提取进度条颜色(使用主色调的深色版本)
const darkPrimary = {
r: Math.floor(primary.r * 0.7),
g: Math.floor(primary.g * 0.7),
b: Math.floor(primary.b * 0.7)
};
progressColor.value = `rgb(${darkPrimary.r}, ${darkPrimary.g}, ${darkPrimary.b})`;
} else {
// 默认颜色
themeColors.value = { primary: '#6366f1', secondary: '#a855f7' };
progressColor.value = '#ffffff';
}
};
}
// 监听歌曲切换时重新提取颜色
watch(() => currentSong.value.cover, () => {
extractColors();
console.log("!!")
}, { immediate: true });
audio.addEventListener('loadedmetadata', () => {
currentSong.value.duration = audio.duration;
});
audio.addEventListener('timeupdate', () => {
currentTime.value = audio.currentTime;
});
audio.addEventListener('ended', () => {
isPlaying.value = false;
currentTime.value = 0;
});
function togglePlay() {
if (isPlaying.value) {
audio.pause();
} else {
audio.play().catch(e => console.warn("播放失败,需用户交互:", e));
}
isPlaying.value = !isPlaying.value;
}
function seek(time: number) {
audio.currentTime = time;
}
function toggleSettings() { console.log("!!");showSettings.value = !showSettings.value; }
watch(volume, (newVol) => {
audio.volume = newVol / 100;
});
const progressPercentage = computed(() =>
currentSong.value.duration ? (currentTime.value / currentSong.value.duration) * 100 : 0
);
return {
currentSong, isPlaying, currentTime, volume, showPlaylist,
themeColors, progressPercentage, progressColor,
togglePlay, seek, extractColors,
showSettings, toggleSettings,
darkMode
};
});

378
src/views/HomeView.vue Normal file
View File

@@ -0,0 +1,378 @@
<template>
<div class="home-view">
<section class="section-container">
<div class="section-header">
<h2 class="section-title">推荐歌单</h2>
<div class="more">查看全部 <Icon icon="lucide:chevron-right" /></div>
</div>
<div class="playlist-grid">
<div
v-for="item in playlistData"
:key="item.id"
class="playlist-card"
@click="handlePlayListClick(item)"
>
<div class="cover-wrapper">
<img :src="item.img" :alt="item.name" loading="lazy" />
<div class="play-count">
<Icon icon="lucide:play" width="12" />
<span>{{ item.play_count }}</span>
</div>
</div>
<div class="playlist-info">
<div class="playlist-name">{{ item.name }}</div>
<div class="playlist-author">{{ item.author }}</div>
</div>
</div>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { Icon } from '@iconify/vue';
// 注入你提供的 JSON 数据
const playlistData = ref([
{
"play_count": "4.7万",
"id": "17387580241",
"author": "张大佛爷张大佛爷",
"name": "巴西Funk来首精神氮泵助力燃脂",
"time": "2025-11-02",
"img": "http://p2.music.126.net/YkpQqGGlHR4DD8BjGTQe8Q==/109951172228935288.jpg",
"total": 30,
"desc": "巴西放克Funk brasileiro),通常在巴西被称为\"Funk\"是一种受嘻哈音乐Hip Hop影响的音乐风 格,起源于里约热内卢的贫民区(也称为贫民窟)。里约放克(Funk carioca最初源自电音放克Electro和迈阿密贝 斯Miami Bass),之后逐渐发展出独特的风格,并成为巴西低收入青少年中最受欢迎的音乐类型之一。\n\n听说巴西Funk和健身很搭噢",
"source": "wy"
},
{
"play_count": "3.6万",
"id": "14435495551",
"author": "凌晨一点的莱茵猫",
"name": "ACG治愈 | 回忆复刻 重温动漫里的温情时刻",
"time": "2025-10-18",
"img": "http://p2.music.126.net/uPWibcRbc7u3vDD44gXA0A==/109951172205320822.jpg",
"total": 99,
"desc": "不是所有治愈都需要大段对白\n一段旋律就够了\n\n可以是主角低谷时的背景音\n也可以是圆满结局的收尾曲\n\n每一段都裹着当时的情绪\n现在听依旧能暖到心里最软的地方",
"source": "wy"
},
{
"play_count": "3万",
"id": "17438630520",
"author": "露露在发呆",
"name": "跑步 •鬼灭之刃超燃和风主题歌",
"time": "2025-11-15",
"img": "http://p2.music.126.net/DuKyLyYhKd2YhWsRYkd9DQ==/109951172287476927.jpg",
"total": 26,
"desc": "鬼灭之刃即将迎来无限城终极大决战精选历代TV番、剧场版主题曲与超人气配乐插曲与动画同样出彩的是其独特美妙的日式和风电子流行曲风给你今日份的运动计划注入来自鬼灭之刃的二次元能量吧适配有氧、无氧运动 全适配高能KPOP祝你今天也能量满满燃爆卡路里",
"source": "wy"
},
{
"play_count": "1.7万",
"id": "17430413151",
"author": "心语馆",
"name": "【高质量轻音】享受宁静 沉浸自然",
"time": "2025-11-13",
"img": "http://p2.music.126.net/El8lldCevbObAYAcBz0kvg==/109951172275960287.jpg",
"total": 72,
"desc": "当城市喧嚣渐远\n让音符化作自然的信使\n\n钢琴的澄澈如林间晨露\n弦乐的温柔似晚风拂叶\n\n虫鸣与流水声悄然交织\n每一段旋律都在勾勒远山\n\n晴空与旷野的轮廓\n无需刻意追寻\n\n只需闭上眼\n便能在旋律里触摸自然 的呼吸\n\n让身心沉潜于这份不被打扰的宁静\n重拾内心的松弛与澄澈……",
"source": "wy"
},
{
"play_count": "4681",
"id": "17440686473",
"author": "與鯨島",
"name": "刘宇|我生如刀锋 自当斩荆棘",
"time": "2025-11-15",
"img": "http://p2.music.126.net/l6q13AT-n47NICqDGf90lw==/109951172291475298.jpg",
"total": 50,
"desc": "他说:\n“我接受来自四面八方的欢呼鼓舞也接受不明缘由的流言与我接受来自四面八方的欢呼鼓舞也 接受不明缘由的流言与误解。我低谷过,但没人能熄灭我跳动的火焰。眼前不变的是光明和希望,再尖锐的噪音也无法打 断已经响起的旋律,仍在舞蹈,就是我的态度。”\n\n“我很早之前就说过恶评它其实不会伤害到我哪怕我说的一句话 它没有错误有些人都可以在TA的立场上找出这句话的错误我觉得这个不太需要去考虑的因为你做什么事情问心无 愧最重要。”\n\n“我觉得你们也在发光是因为你们的光汇聚在一起我才能这么明亮。”\n\n“天赋固然重要但我想要 才更重要。”\n\n“愿你我皆被温柔以待身处泥泞也嗅得大地芳香。”\n\n“我想告诉你们的是是你们的光唤醒了我心中 的那颗种子,我知道有一天,这颗种子,可以成为为你们遮挡伤害的那棵大树,请相信我。”\n\n“谢谢您曾对我说要去 做那个扇风的人,要让全世界知道这把扇子扇出来的风,叫中国风。”\n\n“国风推广这条路其实我的初心一直不变依 旧在前行,也理解我作为公众人物应该负担起更多的责误解。我低谷过,但没人能熄灭我跳动的火焰。眼前不变的是光明 和希望,再尖锐的噪音也无法打断已经响起的旋律,仍在舞蹈,就是我的态度。”\n\n“我很早之前就说过恶评它其实不 会伤害到我哪怕我说的一句话它没有错误有些人都可以在TA的立场上找出这句话的错误我觉得这个不太需要去考 虑的,因为你做什么事情问心无愧最重要。”\n\n“我觉得你们也在发光是因为你们的光汇聚在一起我才能这么明亮。”\n\n“天赋固然重要但我想要才更重要。”\n\n“愿你我皆被温柔以待身处泥泞也嗅得大地芳香。”\n\n“我想告诉你们的是是你们的光唤醒了我心中的那颗种子我知道有一天这颗种子可以成为为你们遮挡伤害的那棵大树请相信我。”\n\n“谢谢您曾对我说要去做那个扇风的人要让全世界知道这把扇子扇出来的风叫中国风。”\n\n“国风推广这条路其实我的初心一直不变依旧在前行也理解我作为公众人物应该负担起更多的责任、传播的重任。希望未来不负期望 能号召更多的年轻的朋友关注和热爱国风、传统文化。”\n\n“把冠军定在道路上而不是目标上。”\n\n“你们带着特别热 情,特别想要跟你传达他们的爱的时候,你就觉得,这点累算什么。”\n\n“太在意恶评是对好评的不负责。”\n\n",
"source": "wy"
},
{
"play_count": "2.1万",
"id": "17412466554",
"author": "走马川行北海边",
"name": "网上很火韩语歌曲|宿命感拉满氛围神曲",
"time": "2025-11-08",
"img": "http://p2.music.126.net/Rf9Nx1eefoGbqGhqq84SBw==/109951172254139596.jpg",
"total": 72,
"desc": "前奏一响,便是跨越时空的宿命羁绊。这些火爆全网的韩语神曲,藏着韩剧里初雪拔剑的悸动、跨越百年的深情,也藏着爱而不得的怅惘与命中注定的奔赴。空灵声线交织着缱绻旋律,氛围感直接拉满,每一段旋律都像在诉说: 有些人,遇见即是宿命,哪怕兜兜转转,也终会被命运牵引。戴上耳机,沉浸式坠入这场关于宿命的浪漫与遗憾。",
"source": "wy"
},
{
"play_count": "1.2万",
"id": "17426483259",
"author": "心语馆",
"name": "【欧美旋律控】前奏封神 耳熟能详欧美神曲",
"time": "2025-11-12",
"img": "http://p2.music.126.net/4EiMzo9LAGbNjwF79ecuZg==/109951172271263928.jpg",
"total": 67,
"desc": "这份歌单,是资深乐迷私藏的欧美旋律宝库。从格莱美获奖金曲,到小众音乐人的惊艳之作,每一首都经过时间与口碑的双重检验。\n\n有Adele用灵魂嗓音唱出的《Rolling in the Deep》那强烈鼓点和凄美钢琴交织让失恋 的痛楚与坚强直击人心也有Coldplay用《Viva la Vida》带来的摇滚盛宴壮丽弦乐搭配激昂节奏展现帝国兴衰。这 里既有流行、摇滚、民谣等主流风格也不乏R&B、电子乐等小众类型满足不同音乐偏好。\n\n无论你是在午后闲暇、深夜独处还是在通勤路上这些旋律都能为你带来极致的听觉享受让你沉浸在欧美音乐的独特魅力中一听就上瘾循 环到停不下来 。",
"source": "wy"
},
{
"play_count": "4198",
"id": "17514849470",
"author": "心语馆",
"name": "【高质量轻音】清新宁静 明亮自然",
"time": "2025-12-04",
"img": "http://p2.music.126.net/v0jiaItY2fD3LuQVrdt_Cg==/109951172384635472.jpg",
"total": 71,
"desc": "蝉鸣躲进树荫,落叶在石阶上打了个转。木吉他的弦音裹着松针的气息,手鼓轻得像松鼠踩过腐叶。不必追赶时间,只需闭眼,让音符替你接住林间漏下的每一缕暖阳。",
"source": "wy"
},
{
"play_count": "1万",
"id": "14407617761",
"author": "露露在发呆",
"name": "华语R&B像一只蝴蝶飞过废墟",
"time": "2025-10-11",
"img": "http://p2.music.126.net/nYl-Rts9LlhdrGRIx5St-g==/109951172136819703.jpg",
"total": 58,
"desc": "我瞒着所有人装作迈过了很多坎,故意显得很开心的样子,事实上只有我知道,阴影就是阴影,有些坎我永远也迈不过去,有些事一时半会释怀,我承认你很好,有些事跟我喜欢你没关系,有些事没在一个频道上,更没办法将两 件事放一起衡量。",
"source": "wy"
},
{
"play_count": "8188",
"id": "17511869819",
"author": "有只黑猫叫灰心",
"name": "「韩语宿命感」首尔晚风里的心跳",
"time": "2025-12-04",
"img": "http://p2.music.126.net/wXRLNUreaGRVcU7CYe20KA==/109951172381558389.jpg",
"total": 50,
"desc": "ᥫᩣ当首尔的晚风裹着便利店的暖光掠过耳机线韩语歌词里那些轻颤的音节正藏着和你同频的心跳共振。这是专属于宿命感的BGM——每一段旋律都像被按下慢放的初遇镜头是走廊转角撞进眼底的视线是咖啡杯沿碰在一起时的轻响是晚风里没说出口的「恰好我也喜欢你」。耳机里的每一句都是藏在旋律里的、只属于你的心动序章✨",
"source": "wy"
}
]);
const handlePlayListClick = (item: any) => {
console.log("跳转到歌单详情:", item.id);
};
</script>
<style scoped lang="scss">
.home-view {
padding-bottom: 40px;
.section-container {
margin-bottom: 40px;
.section-header {
display: flex;
justify-content: space-between;
align-items: flex-end;
margin-bottom: 20px;
.section-title {
font-size: 24px;
font-weight: 800;
color: #1a1a1a;
margin: 0;
}
.more {
font-size: 13px;
color: #888;
display: flex;
align-items: center;
cursor: pointer;
transition: color 0.2s;
&:hover { color: #ff6b6b; }
}
}
}
.playlist-grid {
display: grid;
// 使用 auto-fill 实现响应式列数
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 24px;
.playlist-card {
cursor: pointer;
transition: all 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
&:hover {
.cover-wrapper {
transform: translateY(-8px);
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.15);
img { transform: scale(1.05); }
}
.playlist-name { color: #ff6b6b; }
}
.cover-wrapper {
position: relative;
aspect-ratio: 1;
border-radius: 14px;
overflow: hidden;
background: #f0f0f0;
margin-bottom: 12px;
transition: all 0.4s ease;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.6s ease;
}
.play-count {
position: absolute;
top: 8px;
right: 8px;
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(8px);
color: white;
padding: 4px 10px;
border-radius: 20px;
font-size: 11px;
display: flex;
align-items: center;
gap: 4px;
z-index: 2;
}
}
.playlist-info {
.playlist-name {
font-size: 14px;
font-weight: 600;
line-height: 1.4;
color: #333;
margin-bottom: 4px;
// 两行文本截断
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
transition: color 0.2s;
}
.playlist-author {
font-size: 12px;
color: #999;
}
}
}
}
}
</style>