Ceru Music 是一个基于 Electron + Vue 3 的跨平台桌面音乐播放器,支持多音乐平台数据源,提供流畅的音乐播放体验。
前端框架: Vue 3 + TypeScript + Composition API
桌面框架: Electron (v37.2.3)
UI组件库: TDesign Vue Next (v1.15.2)
状态管理: Pinia (v3.0.3)
路由管理: Vue Router (v4.5.1)
构建工具: Vite + electron-vite
包管理器: PNPM
Node pnpm 版本:
xxxxxxxxxxPS D:\code\Ceru-Music> node -vv22.17.0PS D:\code\Ceru-Music> pnpm -v10.14.0
-
xxxxxxxxxxCeru Music├── 主进程 (Main Process)│ ├── 应用生命周期管理│ ├── 窗口管理│ ├── 系统集成 (托盘、快捷键)│ └── 文件系统操作├── 渲染进程 (Renderer Process)│ ├── Vue 3 应用│ ├── 用户界面│ ├── 音乐播放控制│ └── 数据展示└── 预加载脚本 (Preload Script) └── 安全的 IPC 通信桥梁
xxxxxxxxxxsrc/├── main/ # 主进程代码│ ├── index.ts # 主进程入口│ ├── window.ts # 窗口管理│ └── services/ # 主进程服务├── preload/ # 预加载脚本│ └── index.ts # IPC 通信接口└── renderer/ # 渲染进程 (Vue 应用)├── src/│ ├── components/ # Vue 组件│ ├── views/ # 页面视图│ ├── stores/ # Pinia 状态管理│ ├── services/ # API 服务│ ├── utils/ # 工具函数│ └── types/ # TypeScript 类型定义└── index.html # 应用入口
xxxxxxxxxx# 安装依赖pnpm install
# 启动开发服务器pnpm dev
# 代码检查pnpm lint
# 类型检查pnpm typecheck
xxxxxxxxxx# 构建当前平台pnpm build
# 构建 Windows 版本pnpm build:win
# 构建 macOS 版本pnpm build:mac
# 构建 Linux 版本pnpm build:linux
https://music.163.com/api/song/detail
ids=[ID1,ID2,ID3,...] 音乐ID列表
https://music.163.com/api/song/detail?ids=[36270426]
https://music.163.com/song/media/outer/url
id=123 音乐ID
https://music.163.com/song/media/outer/url?id=36270426.mp3
请求地址: https://music.163.com/api/song/lyric
请求参数:
id=123 音乐IDlv=-1 获取歌词yv=-1 获取逐字歌词tv=-1 获取歌词翻译
示例: https://music.163.com/api/song/lyric?id=36270426&lv=-1&yv=-1&tv=-1
请求地址: https://music.163.com/api/search/get/web
请求参数:
s 歌名type=1 搜索类型offset=0 偏移量limit=10 搜索结果数量
示例: https://music.163.com/api/search/get/web?s=来自天堂的魔鬼&type=1&offset=0&limit=10
server: 数据源
netease 网易云音乐(默认)tencent QQ音乐type: 类型
name 歌曲名artist 歌手url 链接pic 封面lrc 歌词song 单曲playlist 歌单id: 类型ID(封面ID/单曲ID/歌单ID)
xxxxxxxxxxhttps://api.qijieya.cn/meting/?type=url&id=1969519579https://api.qijieya.cn/meting/?type=song&id=591321https://api.qijieya.cn/meting/?type=playlist&id=2619366284
https://music.shiqianjiang.cn?id=你是我的风景&server=netease
xxxxxxxxxx// 音乐服务接口定义interface MusicService { search({keyword: string, page?: number, limit?: number}): Promise<SearchResult> getSongDetail({id: string)}: Promise<SongDetail> getSongUrl({id: string}): Promise<string> getLyric({id: string}): Promise<LyricData> getPlaylist({id: string}): Promise<PlaylistData>}
// 通用请求函数async function request(method: string, args: any{},isLoading=false): Promise<any> { try { switch (method) { case 'search': return await musicService.search(args) case 'getSongDetail': return await musicService.getSongDetail(args) case 'getSongUrl': return await musicService.getSongUrl(args) case 'getLyric': return await musicService.getLyric(args) default: throw new Error(`未知的方法: ${method}`) } } catch (error) { console.error(`请求失败: ${method}`, error) throw error }}
// 使用示例request('search', '周杰伦', 1, 20).then((result) => { console.log('搜索结果:', result)})
xxxxxxxxxx// stores/music.tsimport { defineStore } from 'pinia'
export const useMusicStore = defineStore('music', { state: () => ({ // 当前播放歌曲 currentSong: null as Song | null, // 播放列表 playlist: [] as Song[], // 播放状态 isPlaying: false, // 播放模式 (顺序、随机、单曲循环) playMode: 'order' as 'order' | 'random' | 'repeat', // 音量 volume: 0.8, // 播放进度 currentTime: 0, duration: 0 }),
actions: { // 播放歌曲 async playSong(song: Song) { this.currentSong = song this.isPlaying = true this.saveToStorage() },
// 添加到播放列表 addToPlaylist(songs: Song[]) { this.playlist.push(songs) this.saveToStorage() },
// 保存到本地存储 saveToStorage() { localStorage.setItem( 'music-state', JSON.stringify({ currentSong: this.currentSong, playlist: this.playlist, playMode: this.playMode, volume: this.volume }) ) },
// 从本地存储恢复 loadFromStorage() { const saved = localStorage.getItem('music-state') if (saved) { const state = JSON.parse(saved) Object.assign(this, state) } } }})
使用 TDesign 的虚拟滚动组件展示大量歌曲数据:
xxxxxxxxxx<template> <t-virtual-scroll :data="songList" :height="600" :item-height="60" :buffer="10"> <template #default="{ data: song, index }"> <div class="song-item" @click="playSong(song)"> <div class="song-cover"> <img :src="song.pic" :alt="song.name" /> </div> <div class="song-info"> <div class="song-name"></div> <div class="song-artist"></div> </div> <div class="song-duration"></div> </div> </template> </t-virtual-scroll></template>
xxxxxxxxxx// 方案1: LocalStorage (简单方案)class PlaylistStorage { private key = 'ceru-playlists'
save(playlists: Playlist[]) { localStorage.setItem(this.key, JSON.stringify(playlists)) }
load(): Playlist[] { const data = localStorage.getItem(this.key) return data ? JSON.parse(data) : [] }}
// 方案2: Node.js 文件存储 (最优方案,支持分享)class FileStorage { private filePath = path.join(app.getPath('userData'), 'playlists.json')
async save(playlists: Playlist[]) { await fs.writeFile(this.filePath, JSON.stringify(playlists, null, 2)) }
async load(): Promise<Playlist[]> { try { const data = await fs.readFile(this.filePath, 'utf-8') return JSON.parse(data) } catch { return [] } }
// 导出播放列表 async export(playlist: Playlist, exportPath: string) { await fs.writeFile(exportPath, JSON.stringify(playlist, null, 2)) }
// 导入播放列表 async import(importPath: string): Promise<Playlist> { const data = await fs.readFile(importPath, 'utf-8') return JSON.parse(data) }}
xxxxxxxxxx// stores/app.tsexport const useAppStore = defineStore('app', { state: () => ({ isFirstLaunch: true, hasCompletedWelcome: false, userPreferences: { theme: 'auto' as 'light' | 'dark' | 'auto', language: 'zh-CN', defaultMusicSource: 'netease', autoPlay: false } }),
actions: { checkFirstLaunch() { const hasLaunched = localStorage.getItem('has-launched') this.isFirstLaunch = !hasLaunched
if (this.isFirstLaunch) { // 跳转到欢迎页面 router.push('/welcome') } else { // 加载用户配置 this.loadUserPreferences() router.push('/home') } },
completeWelcome(preferences?: Partial<UserPreferences>) { if (preferences) { Object.assign(this.userPreferences, preferences) }
this.hasCompletedWelcome = true localStorage.setItem('has-launched', 'true') localStorage.setItem('user-preferences', JSON.stringify(this.userPreferences))
router.push('/home') } }})
xxxxxxxxxx<template> <div class="welcome-container"> <t-steps :current="currentStep" class="welcome-steps"> <t-step title="欢迎使用" content="欢迎使用 Ceru Music" /> <t-step title="基础设置" content="配置您的偏好设置" /> <t-step title="完成设置" content="开始您的音乐之旅" /> </t-steps>
<transition name="slide" mode="out-in"> <component :is="currentStepComponent" @next="nextStep" @skip="skipWelcome" /> </transition> </div></template>
<script setup lang="ts">import { ref, computed } from 'vue'import WelcomeStep1 from './steps/WelcomeStep1.vue'import WelcomeStep2 from './steps/WelcomeStep2.vue'import WelcomeStep3 from './steps/WelcomeStep3.vue'
const currentStep = ref(0)const steps = [WelcomeStep1, WelcomeStep2, WelcomeStep3]
const currentStepComponent = computed(() => steps[currentStep.value])
function nextStep() { if (currentStep.value < steps.length - 1) { currentStep.value++ } else { completeWelcome() }}
function skipWelcome() { appStore.completeWelcome()}</script>
<style scoped>.slide-enter-active,.slide-leave-active { transition: all 0.3s ease;}.slide-enter-from { opacity: 0; transform: translateX(30px);}.slide-leave-to { opacity: 0; transform: translateX(-30px);}</style>
![.\assets\image-20250813180944752.png)
xxxxxxxxxx<template> <router-view v-slot="{ Component, route }"> <transition :name="getTransitionName(route)" mode="out-in"> <component :is="Component" :key="route.path" /> </transition> </router-view></template>
<script setup lang="ts">function getTransitionName(route: any) { // 根据路由层级决定动画方向 const depth = route.path.split('/').length return depth > 2 ? 'slide-left' : 'slide-right'}</script>
<style>/* 滑动动画 */.slide-left-enter-active,.slide-left-leave-active,.slide-right-enter-active,.slide-right-leave-active { transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);}
.slide-left-enter-from { opacity: 0; transform: translateX(100%);}.slide-left-leave-to { opacity: 0; transform: translateX(-100%);}
.slide-right-enter-from { opacity: 0; transform: translateX(-100%);}.slide-right-leave-to { opacity: 0; transform: translateX(100%);}
/* 淡入淡出动画 */.fade-enter-active,.fade-leave-active { transition: opacity 0.3s ease;}.fade-enter-from,.fade-leave-to { opacity: 0;}</style>
xxxxxxxxxx<template> <div class="music-player"> <div class="player-info"> <img :src="currentSong?.pic" class="song-cover" /> <div class="song-details"> <div class="song-name"></div> <div class="song-artist"></div> </div> </div>
<div class="player-controls"> <t-button variant="text" @click="previousSong"> <t-icon name="skip-previous" /> </t-button> <t-button :variant="isPlaying ? 'filled' : 'outline'" @click="togglePlay"> <t-icon :name="isPlaying ? 'pause' : 'play'" /> </t-button> <t-button variant="text" @click="nextSong"> <t-icon name="skip-next" /> </t-button> </div>
<div class="player-progress"> <span class="time-current"></span> <t-slider v-model="progress" :max="duration" @change="seekTo" class="progress-slider" /> <span class="time-duration"></span> </div> </div></template>
xxxxxxxxxxfeat: 新功能fix: 修复bugdocs: 文档更新style: 代码格式调整refactor: 代码重构test: 测试相关chore: 构建过程或辅助工具的变动
本设计文档将随着项目开发进度持续更新和完善。