add 歌曲导入导出

This commit is contained in:
sqj
2025-08-18 20:42:34 +08:00
parent aba2814faa
commit 874dc24579
28 changed files with 6183 additions and 1180 deletions

View File

@@ -3,10 +3,12 @@
"features": [
"播放列表管理(添加/删除/排序)",
"音量调节控件",
"多种播放模式(顺序/随机/单曲循环)"
"多种播放模式(顺序/随机/单曲循环)",
"播放列表导出/导入功能",
"播放列表一键清空功能"
],
"tech": { "Web": { "arch": "vue", "component": "tdesign" } },
"design": "现代简约风格深色背景配合高对比度控件主色调为TDesign主题蓝(#0052D9),包含固定底部播放控制区、垂直弹出式音量控制、图标式播放模式选择器和可拖拽排序的播放列表侧边栏",
"design": "现代简约风格深色背景配合高对比度控件主色调为TDesign主题蓝(#0052D9),包含固定底部播放控制区、垂直弹出式音量控制、图标式播放模式选择器和可拖拽排序的播放列表侧边栏。新增导出/导入功能使用TDesign对话框组件提供文件导出/导入和内容复制/粘贴两种方式,所有导出内容进行加密处理。清空功能添加二次确认对话框防止误操作。",
"plan": {
"扩展Pinia状态管理在ControlAudioStore中添加音量控制、播放模式和播放列表相关状态": "holding",
"创建VolumeControl.vue组件实现音量调节滑动条和静音按钮功能": "holding",
@@ -17,6 +19,11 @@
"将新组件集成到主播放器界面,调整布局确保合理性": "holding",
"实现播放列表与音频控制的联动,确保播放状态正确反映": "holding",
"添加键盘快捷键支持和状态反馈机制": "holding",
"进行组件间通信测试,确保功能协调工作": "holding"
"进行组件间通信测试,确保功能协调工作": "holding",
"创建播放列表加密/解密工具函数": "holding",
"实现播放列表导出功能(文件导出和内容复制)": "holding",
"实现播放列表导入功能(文件导入和内容粘贴)": "holding",
"实现播放列表一键清空功能及二次确认机制": "holding",
"测试导出/导入功能的加密解密正确性": "holding"
}
}

View File

@@ -1,5 +1,5 @@
appId: com.cerumusic.app
productName: ceru-music
productName: 澜音
directories:
buildResources: build
asar: true
@@ -14,6 +14,7 @@ asarUnpack:
- resources/**
win:
executableName: ceru-music
icon: 'resources/icons/icon.ico'
# 如果有证书文件,取消注释以下配置
# certificateFile: path/to/certificate.p12
# certificatePassword: your-password
@@ -24,6 +25,8 @@ nsis:
shortcutName: ${productName}
uninstallDisplayName: ${productName}
createDesktopShortcut: always
installerIcon: 'resources/icons/icon.ico'
uninstallerIcon: 'resources/icons/icon.ico'
oneClick: false
allowToChangeInstallationDirectory: true
allowElevation: true

2289
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{
"name": "ceru-music",
"version": "1.0.0",
"description": "An Electron application with Vue and TypeScript",
"description": "一款简洁优雅的音乐播放器",
"main": "./out/main/index.js",
"author": "sqj,wldss,star",
"license": "MIT",
@@ -21,7 +21,8 @@
"build:win": "pnpm run build && electron-builder --win --x64 --config",
"build:mac": "pnpm run build && electron-builder --mac",
"build:linux": "pnpm run build && electron-builder --linux",
"build:deps": "electron-builder install-app-deps && pnpm run build && electron-builder --win --x64 --config"
"build:deps": "electron-builder install-app-deps && pnpm run build && electron-builder --win --x64 --config",
"buildico": "electron-icon-builder --input=./resources/logo.png --output=resources --flatten"
},
"dependencies": {
"@applemusic-like-lyrics/lyric": "^0.2.4",
@@ -39,15 +40,16 @@
"@pixi/filter-bulge-pinch": "^5.1.1",
"@pixi/filter-color-matrix": "^7.4.3",
"@pixi/sprite": "^7.4.3",
"NeteaseCloudMusicApi": "^4.27.0",
"axios": "^1.11.0",
"color-extraction": "^1.0.8",
"crypto-js": "^4.2.0",
"dompurify": "^3.2.6",
"electron-updater": "^6.3.9",
"jss": "^10.10.0",
"jss-preset-default": "^10.10.0",
"marked": "^16.1.2",
"mitt": "^3.0.1",
"NeteaseCloudMusicApi": "^4.27.0",
"pinia": "^3.0.3",
"tdesign-vue-next": "^1.15.2",
"vue-router": "^4.5.1"
@@ -59,10 +61,12 @@
"@electron-toolkit/eslint-config-ts": "^3.0.0",
"@electron-toolkit/tsconfig": "^1.0.1",
"@tdesign-vue-next/auto-import-resolver": "^0.1.1",
"@types/crypto-js": "^4.2.2",
"@types/node": "^22.16.5",
"@vitejs/plugin-vue": "^6.0.0",
"electron": "^37.2.3",
"electron-builder": "^25.1.8",
"electron-icon-builder": "^2.0.1",
"electron-vite": "^4.0.0",
"eslint": "^9.31.0",
"eslint-plugin-vue": "^10.3.0",

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

BIN
resources/icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

BIN
resources/icons/16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 437 B

BIN
resources/icons/24x24.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 633 B

BIN
resources/icons/256x256.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

BIN
resources/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 815 B

BIN
resources/icons/48x48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
resources/icons/512x512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

BIN
resources/icons/64x64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
resources/icons/icon.icns Normal file

Binary file not shown.

BIN
resources/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 353 KiB

BIN
resources/logo.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

View File

@@ -1,4 +1,4 @@
import { app, shell, BrowserWindow, ipcMain, Tray, Menu, ipcRenderer } from 'electron'
import { app, shell, BrowserWindow, ipcMain, Tray, Menu } from 'electron'
import { join } from 'path'
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import icon from '../../resources/logo.png?asset'
@@ -74,7 +74,7 @@ function createWindow(): void {
titleBarStyle: 'hidden',
...(process.platform === 'linux' ? { icon } : {}),
// ...(process.platform !== 'darwin' ? { titleBarOverlay: true } : {}),
icon: path.join(__dirname, '../../resources/logo.png'),
icon: path.join(__dirname, '../../resources/logo.ico'),
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false,

View File

@@ -5,4 +5,6 @@
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {}
declare global {
}

View File

@@ -11,6 +11,8 @@ declare module 'vue' {
FloatBall: typeof import('./src/components/AI/FloatBall.vue')['default']
FullPlay: typeof import('./src/components/Play/FullPlay.vue')['default']
GlobalAudio: typeof import('./src/components/Play/GlobalAudio.vue')['default']
PlaylistActions: typeof import('./src/components/Play/PlaylistActions.vue')['default']
PlaylistSettings: typeof import('./src/components/Settings/PlaylistSettings.vue')['default']
PlayMusic: typeof import('./src/components/Play/PlayMusic.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
@@ -18,7 +20,10 @@ declare module 'vue' {
ShaderBackground: typeof import('./src/components/Play/ShaderBackground.vue')['default']
TAside: typeof import('tdesign-vue-next')['Aside']
TButton: typeof import('tdesign-vue-next')['Button']
TCard: typeof import('tdesign-vue-next')['Card']
TContent: typeof import('tdesign-vue-next')['Content']
TDialog: typeof import('tdesign-vue-next')['Dialog']
TIcon: typeof import('tdesign-vue-next')['Icon']
TImage: typeof import('tdesign-vue-next')['Image']
TInput: typeof import('tdesign-vue-next')['Input']
TitleBarControls: typeof import('./src/components/TitleBarControls.vue')['default']

View File

@@ -51,27 +51,17 @@ const toggleFullscreen = () => {
onMounted(() => {
// 添加事件监听器检测全屏状态变化
document.addEventListener('fullscreenchange', handleFullscreenChange)
document.addEventListener('webkitfullscreenchange', handleFullscreenChange)
document.addEventListener('mozfullscreenchange', handleFullscreenChange)
document.addEventListener('MSFullscreenChange', handleFullscreenChange)
})
onBeforeUnmount(() => {
// 移除事件监听器
document.removeEventListener('fullscreenchange', handleFullscreenChange)
document.removeEventListener('webkitfullscreenchange', handleFullscreenChange)
document.removeEventListener('mozfullscreenchange', handleFullscreenChange)
document.removeEventListener('MSFullscreenChange', handleFullscreenChange)
})
// 处理全屏状态变化
const handleFullscreenChange = () => {
// 检查当前是否处于全屏状态
const fullscreenElement =
document.fullscreenElement ||
document.webkitFullscreenElement ||
document.mozFullScreenElement ||
document.msFullscreenElement
const fullscreenElement = document.fullscreenElement
// 更新状态
isFullscreen.value = !!fullscreenElement

View File

@@ -123,7 +123,7 @@ let pendingRestoreSongId: number | null = null
// 记录组件被停用前的播放状态
let wasPlaying = false
// eslint-disable-next-line @typescript-eslint/no-unused-vars
let playbackPosition = 0
// let playbackPosition = 0
let isFull = false
// 播放指定歌曲
@@ -510,7 +510,7 @@ onDeactivated(() => {
console.log('PlayMusic组件被停用')
// 保存当前播放状态
wasPlaying = Audio.value.isPlay
playbackPosition = Audio.value.currentTime
// playbackPosition = Audio.value.currentTime
isFull = showFullPlay.value
// 如果正在播放,暂停播放但不改变状态标志
if (wasPlaying && Audio.value.audio) {
@@ -829,9 +829,8 @@ watch(songInfo, setColor, { deep: true, immediate: true })
<div class="playlist-content">
<div v-if="list.length === 0" class="playlist-empty">
<p>播放列表为空</p>
<p>请添加歌曲到播放列表</p>
<p>请添加歌曲到播放列表也可在设置中导入歌曲列表</p>
</div>
<div v-else class="playlist-songs">
<div
v-for="song in list"

View File

@@ -0,0 +1,352 @@
<script setup lang="ts">
import { ref } from 'vue'
import { MessagePlugin, DialogPlugin } from 'tdesign-vue-next'
import { LocalUserDetailStore } from '@renderer/store/LocalUserDetail'
import {
exportPlaylistToFile,
copyPlaylistToClipboard,
importPlaylistFromFile,
importPlaylistFromClipboard,
validateImportedPlaylist
} from '@renderer/utils/playlistExportImport'
import { CloudDownloadIcon } from 'tdesign-icons-vue-next'
import type { SongList } from '@renderer/types/audio'
import { storeToRefs } from 'pinia'
const localUserStore = LocalUserDetailStore()
const { list } = storeToRefs(localUserStore)
// 对话框控制
const exportDialogVisible = ref(false)
const importDialogVisible = ref(false)
const clearDialogVisible = ref(false)
// 文件上传相关
const fileInputRef = ref<HTMLInputElement | null>(null)
const uploadedFile = ref<File | null>(null)
// 导出播放列表
const handleExportToFile = async () => {
try {
if (list.value.length === 0) {
MessagePlugin.warning('播放列表为空,无法导出')
return
}
const fileName = exportPlaylistToFile(list.value)
MessagePlugin.success(`播放列表已成功导出为 ${fileName}`)
exportDialogVisible.value = false
} catch (error) {
MessagePlugin.error(`导出失败: ${(error as Error).message}`)
}
}
// 复制播放列表到剪贴板
const handleCopyToClipboard = async () => {
try {
if (list.value.length === 0) {
MessagePlugin.warning('播放列表为空,无法复制')
return
}
await copyPlaylistToClipboard(list.value)
MessagePlugin.success('播放列表已复制到剪贴板')
exportDialogVisible.value = false
} catch (error) {
MessagePlugin.error(`复制失败: ${(error as Error).message}`)
}
}
// 触发文件选择
const triggerFileInput = () => {
if (fileInputRef.value) {
fileInputRef.value.click()
}
}
// 处理文件选择
const handleFileChange = (event: Event) => {
const input = event.target as HTMLInputElement
if (input.files && input.files.length > 0) {
uploadedFile.value = input.files[0]
}
}
// 从文件导入播放列表
const handleImportFromFile = async () => {
try {
if (!uploadedFile.value) {
MessagePlugin.warning('请先选择文件')
return
}
const importedPlaylist = await importPlaylistFromFile(uploadedFile.value)
if (!validateImportedPlaylist(importedPlaylist)) {
throw new Error('导入的播放列表格式不正确')
}
// 合并播放列表,避免重复
const mergedList = mergePlaylist(list.value, importedPlaylist)
// 更新播放列表
list.value = mergedList
MessagePlugin.success(`成功导入 ${importedPlaylist.length} 首歌曲`)
importDialogVisible.value = false
uploadedFile.value = null
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
} catch (error) {
MessagePlugin.error(`导入失败: ${(error as Error).message}`)
}
}
// 从剪贴板导入播放列表
const handleImportFromClipboard = async () => {
try {
const importedPlaylist = await importPlaylistFromClipboard()
if (!validateImportedPlaylist(importedPlaylist)) {
throw new Error('剪贴板中的播放列表格式不正确')
}
// 合并播放列表,避免重复
const mergedList = mergePlaylist(list.value, importedPlaylist)
// 更新播放列表
list.value = mergedList
MessagePlugin.success(`成功导入 ${importedPlaylist.length} 首歌曲`)
importDialogVisible.value = false
} catch (error) {
MessagePlugin.error(`从剪贴板导入失败: ${(error as Error).message}`)
}
}
// 合并播放列表,避免重复
const mergePlaylist = (currentList: SongList[], importedList: SongList[]): SongList[] => {
const result = [...currentList]
const existingIds = new Set(currentList.map((song) => song.id))
for (const song of importedList) {
if (!existingIds.has(song.id)) {
result.push(song)
existingIds.add(song.id)
}
}
return result
}
// 清空播放列表
const handleClearPlaylist = () => {
DialogPlugin.confirm({
header: '确认清空',
body: '确定要清空播放列表吗?此操作不可恢复。',
theme: 'warning',
confirmBtn: {
theme: 'danger',
content: '清空'
},
cancelBtn: '取消',
onConfirm: () => {
list.value = []
MessagePlugin.success('播放列表已清空')
clearDialogVisible.value = false
}
})
}
</script>
<template>
<div class="playlist-actions">
<!-- 操作按钮 -->
<div class="action-buttons">
<t-button theme="primary" variant="outline" @click="exportDialogVisible = true">
<CloudDownloadIcon />
导出播放列表
</t-button>
<t-button theme="primary" variant="outline" @click="importDialogVisible = true">
导入播放列表
</t-button>
<t-button theme="danger" variant="outline" @click="handleClearPlaylist">
清空播放列表
</t-button>
</div>
<!-- 导出对话框 -->
<t-dialog
v-model:visible="exportDialogVisible"
header="导出播放列表"
:on-close="() => (exportDialogVisible = false)"
width="500px"
attach="body"
>
<template #body>
<div class="dialog-content">
<p class="dialog-description">请选择导出方式:</p>
<div class="export-options">
<t-card
title="导出为文件"
description="将播放列表导出为加密文件可用于备份或分享"
class="export-option-card"
@click="handleExportToFile"
>
<template #avatar>
<t-icon name="file" size="large" />
</template>
</t-card>
<t-card
title="复制到剪贴板"
description="将加密的播放列表数据复制到剪贴板方便快速分享"
class="export-option-card"
@click="handleCopyToClipboard"
>
<template #avatar>
<t-icon name="copy" size="large" />
</template>
</t-card>
</div>
</div>
</template>
<template #footer>
<t-button theme="default" @click="exportDialogVisible = false">取消</t-button>
</template>
</t-dialog>
<!-- 导入对话框 -->
<t-dialog
v-model:visible="importDialogVisible"
header="导入播放列表"
:on-close="() => (importDialogVisible = false)"
width="500px"
attach="body"
>
<template #body>
<div class="dialog-content">
<p class="dialog-description">请选择导入方式:</p>
<div class="import-options">
<t-card
title="从文件导入"
description=".cpl格式的加密文件中导入播放列表"
class="import-option-card"
>
<template #avatar>
<t-icon name="file" size="large" />
</template>
<template #footer>
<div class="file-upload-area">
<input
ref="fileInputRef"
type="file"
accept=".cpl"
style="display: none"
@change="handleFileChange"
/>
<t-button theme="primary" variant="outline" @click="triggerFileInput">
选择文件
</t-button>
<t-button theme="primary" :disabled="!uploadedFile" @click="handleImportFromFile">
导入
</t-button>
</div>
</template>
</t-card>
<t-card
title="从剪贴板导入"
description="从剪贴板中导入加密的播放列表数据"
class="import-option-card"
>
<template #avatar>
<t-icon name="paste" size="large" />
</template>
<template #footer>
<t-button theme="primary" @click="handleImportFromClipboard">
从剪贴板导入
</t-button>
</template>
</t-card>
</div>
</div>
</template>
<template #footer>
<t-button theme="default" @click="importDialogVisible = false">取消</t-button>
</template>
</t-dialog>
</div>
</template>
<style lang="scss" scoped>
.playlist-actions {
padding: 16px;
}
.action-buttons {
display: flex;
gap: 12px;
margin-bottom: 16px;
}
.dialog-content {
padding: 16px 0;
}
.dialog-description {
margin-bottom: 16px;
font-size: 14px;
color: var(--td-text-color-secondary);
}
.export-options,
.import-options {
display: flex;
gap: 16px;
@media (max-width: 500px) {
flex-direction: column;
}
}
.export-option-card,
.import-option-card {
flex: 1;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
transform: translateY(-4px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
}
}
.file-upload-area {
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
}
.file-name {
font-size: 12px;
color: var(--td-text-color-secondary);
margin: 8px 0;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,525 @@
<script setup lang="ts">
import { ref } from 'vue'
import { MessagePlugin, DialogPlugin } from 'tdesign-vue-next'
import { LocalUserDetailStore } from '@renderer/store/LocalUserDetail'
import { storeToRefs } from 'pinia'
import {
exportPlaylistToFile,
copyPlaylistToClipboard,
importPlaylistFromFile,
importPlaylistFromClipboard,
validateImportedPlaylist
} from '@renderer/utils/playlistExportImport'
import type { SongList } from '@renderer/types/audio'
import { CloudDownloadIcon, DeleteIcon, CloudUploadIcon } from 'tdesign-icons-vue-next'
const localUserStore = LocalUserDetailStore()
const { list } = storeToRefs(localUserStore)
// 对话框控制
const exportDialogVisible = ref(false)
const importDialogVisible = ref(false)
// 文件上传相关
const fileInputRef = ref<HTMLInputElement | null>(null)
const uploadedFile = ref<File | null>(null)
// 导出播放列表
const handleExportToFile = async () => {
try {
if (list.value.length === 0) {
MessagePlugin.warning('播放列表为空,无法导出')
return
}
const fileName = exportPlaylistToFile(list.value)
MessagePlugin.success(`播放列表已成功导出为 ${fileName}`)
exportDialogVisible.value = false
} catch (error) {
MessagePlugin.error(`导出失败: ${(error as Error).message}`)
}
}
// 复制播放列表到剪贴板
const handleCopyToClipboard = async () => {
try {
if (list.value.length === 0) {
MessagePlugin.warning('播放列表为空,无法复制')
return
}
await copyPlaylistToClipboard(list.value)
MessagePlugin.success('播放列表已复制到剪贴板')
exportDialogVisible.value = false
} catch (error) {
MessagePlugin.error(`复制失败: ${(error as Error).message}`)
}
}
// 触发文件选择
const triggerFileInput = () => {
if (fileInputRef.value) {
fileInputRef.value.click()
}
}
// 处理文件选择
const handleFileChange = (event: Event) => {
const input = event.target as HTMLInputElement
if (input.files && input.files.length > 0) {
uploadedFile.value = input.files[0]
}
}
// 从文件导入播放列表
const handleImportFromFile = async () => {
try {
if (!uploadedFile.value) {
MessagePlugin.warning('请先选择文件')
return
}
const importedPlaylist = await importPlaylistFromFile(uploadedFile.value)
if (!validateImportedPlaylist(importedPlaylist)) {
throw new Error('导入的播放列表格式不正确')
}
// 合并播放列表,避免重复
const mergedList = mergePlaylist(list.value, importedPlaylist)
// 更新播放列表
list.value = mergedList
MessagePlugin.success(`成功导入 ${importedPlaylist.length} 首歌曲`)
importDialogVisible.value = false
uploadedFile.value = null
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
} catch (error) {
MessagePlugin.error(`导入失败: ${(error as Error).message}`)
}
}
// 从剪贴板导入播放列表
const handleImportFromClipboard = async () => {
try {
const importedPlaylist = await importPlaylistFromClipboard()
if (!validateImportedPlaylist(importedPlaylist)) {
throw new Error('剪贴板中的播放列表格式不正确')
}
// 合并播放列表,避免重复
const mergedList = mergePlaylist(list.value, importedPlaylist)
// 更新播放列表
list.value = mergedList
MessagePlugin.success(`成功导入 ${importedPlaylist.length} 首歌曲`)
importDialogVisible.value = false
} catch (error) {
MessagePlugin.error(`从剪贴板导入失败: ${(error as Error).message}`)
}
}
// 合并播放列表,避免重复
const mergePlaylist = (currentList: SongList[], importedList: SongList[]): SongList[] => {
const result = [...currentList]
const existingIds = new Set(currentList.map((song) => song.id))
for (const song of importedList) {
if (!existingIds.has(song.id)) {
result.push(song)
existingIds.add(song.id)
}
}
return result
}
// 清空播放列表
const handleClearPlaylist = () => {
const confirm = DialogPlugin.confirm({
header: '确认清空',
body: '确定要清空播放列表吗?此操作不可恢复。',
theme: 'warning',
confirmBtn: {
theme: 'danger',
content: '清空'
},
cancelBtn: '取消',
onConfirm: () => {
list.value = []
confirm.destroy()
MessagePlugin.success('播放列表已清空')
}
})
}
// 获取播放列表统计信息
const playlistStats = ref({
totalSongs: 0,
totalDuration: 0,
artists: new Set<string>()
})
// 计算播放列表统计信息
const updatePlaylistStats = () => {
const stats = {
totalSongs: list.value?.length || 0,
totalDuration: 0,
artists: new Set<string>()
}
if (list.value && list.value.length > 0) {
list.value.forEach((song) => {
stats.totalDuration += song.duration / 1000 // 转换为秒
if (song.artist && Array.isArray(song.artist)) {
song.artist.forEach((artist) => stats.artists.add(artist))
}
})
}
playlistStats.value = stats
}
// 格式化时间
const formatDuration = (seconds: number): string => {
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const remainingSeconds = Math.floor(seconds % 60)
if (hours > 0) {
return `${hours}小时${minutes}分钟`
} else {
return `${minutes}分钟${remainingSeconds}`
}
}
// 监听播放列表变化
import { onMounted, watch } from 'vue'
onMounted(() => {
updatePlaylistStats()
})
watch(
() => list.value,
() => {
updatePlaylistStats()
},
{ deep: true }
)
</script>
<template>
<div class="playlist-settings">
<div class="playlist-stats-card">
<t-card title="播放列表统计" hover-shadow>
<div class="stats-content">
<div class="stat-item">
<span class="iconfont icon-bofang"></span>
<div class="stat-info">
<div class="stat-label">歌曲数量</div>
<div class="stat-value">{{ playlistStats.totalSongs }} </div>
</div>
</div>
<div class="stat-item">
<t-icon name="time" />
<div class="stat-info">
<div class="stat-label">总时长</div>
<div class="stat-value">{{ formatDuration(playlistStats.totalDuration) }}</div>
</div>
</div>
<div class="stat-item">
<t-icon name="user-circle" />
<div class="stat-info">
<div class="stat-label">艺术家</div>
<div class="stat-value">{{ playlistStats.artists.size }} </div>
</div>
</div>
</div>
</t-card>
</div>
<div class="playlist-actions-card">
<t-card title="播放列表管理" hover-shadow>
<div class="action-buttons">
<t-button theme="primary" @click="exportDialogVisible = true">
<template #icon>
<CloudDownloadIcon />
</template>
导出播放列表
</t-button>
<t-button theme="primary" @click="importDialogVisible = true">
<template #icon>
<CloudUploadIcon />
</template>
导入播放列表
</t-button>
<t-button theme="danger" @click="handleClearPlaylist">
<template #icon>
<DeleteIcon />
</template>
清空播放列表
</t-button>
</div>
<div class="feature-description">
<h4>功能说明</h4>
<ul>
<li>
<strong>导出播放列表</strong>
将当前播放列表导出为加密文件或复制到剪贴板方便备份和分享
</li>
<li>
<strong>导入播放列表</strong> 从加密文件或剪贴板导入播放列表支持与现有列表合并
</li>
<li><strong>清空播放列表</strong> 一键清空当前播放列表操作前会有确认提示</li>
</ul>
</div>
</t-card>
</div>
<!-- 导出对话框 -->
<t-dialog
v-model:visible="exportDialogVisible"
header="导出播放列表"
:on-close="() => (exportDialogVisible = false)"
width="500px"
attach="body"
>
<template #body>
<div class="dialog-content">
<p class="dialog-description">请选择导出方式:</p>
<div class="export-options">
<t-card
title="导出为文件"
description="将播放列表导出为加密文件可用于备份或分享"
class="export-option-card"
@click="handleExportToFile"
>
</t-card>
<t-card
title="复制到剪贴板"
description="将加密的播放列表数据复制到剪贴板方便快速分享"
class="export-option-card"
@click="handleCopyToClipboard"
>
<template #avatar>
<t-icon name="copy" size="large" />
</template>
</t-card>
</div>
</div>
</template>
<template #footer>
<t-button theme="default" @click="exportDialogVisible = false">取消</t-button>
</template>
</t-dialog>
<!-- 导入对话框 -->
<t-dialog
v-model:visible="importDialogVisible"
header="导入播放列表"
:on-close="() => (importDialogVisible = false)"
width="700px"
attach="body"
>
<template #body>
<div class="dialog-content">
<p class="dialog-description">请选择导入方式:</p>
<div class="import-options">
<t-card
title="从文件导入"
description=".cpl格式的加密文件中导入播放列表"
class="import-option-card"
>
<template #footer>
<div class="file-upload-area">
<input
ref="fileInputRef"
type="file"
accept=".cpl"
style="display: none"
@change="handleFileChange"
/>
<t-button theme="primary" variant="outline" @click="triggerFileInput">
选择文件
</t-button>
<span v-if="uploadedFile" class="file-name">
已选择: {{ uploadedFile.name }}
</span>
<t-button theme="primary" :disabled="!uploadedFile" @click="handleImportFromFile">
导入
</t-button>
</div>
</template>
</t-card>
<t-card
title="从剪贴板导入"
description="从剪贴板中导入加密的播放列表数据"
class="import-option-card"
>
<template #footer>
<t-button theme="primary" @click="handleImportFromClipboard">
从剪贴板导入
</t-button>
</template>
</t-card>
</div>
</div>
</template>
<template #footer>
<t-button theme="default" @click="importDialogVisible = false">取消</t-button>
</template>
</t-dialog>
</div>
</template>
<style lang="scss" scoped>
.playlist-settings {
margin-bottom: 2rem;
}
.playlist-stats-card,
.playlist-actions-card {
margin-bottom: 1.5rem;
}
.stats-content {
display: flex;
flex-wrap: wrap;
gap: 24px;
margin-top: 8px;
}
.stat-item {
display: flex;
align-items: center;
gap: 12px;
.t-icon {
font-size: 24px;
color: var(--td-brand-color);
}
.stat-info {
.stat-label {
font-size: 14px;
color: var(--td-text-color-secondary);
margin-bottom: 4px;
}
.stat-value {
font-size: 18px;
font-weight: 600;
color: var(--td-text-color-primary);
}
}
}
.action-buttons {
display: flex;
flex-wrap: wrap;
gap: 16px;
margin-bottom: 24px;
}
.feature-description {
background-color: var(--td-bg-color-container-hover);
padding: 16px;
border-radius: 8px;
h4 {
font-size: 16px;
margin-top: 0;
margin-bottom: 12px;
color: var(--td-text-color-primary);
}
ul {
margin: 0;
padding-left: 20px;
li {
margin-bottom: 8px;
color: var(--td-text-color-secondary);
&:last-child {
margin-bottom: 0;
}
strong {
color: var(--td-text-color-primary);
}
}
}
}
.dialog-content {
padding: 16px 0;
}
.dialog-description {
margin-bottom: 16px;
font-size: 14px;
color: var(--td-text-color-secondary);
}
.export-options,
.import-options {
display: flex;
gap: 16px;
@media (max-width: 500px) {
flex-direction: column;
}
}
.export-option-card,
.import-option-card {
flex: 1;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
transform: translateY(-4px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
}
}
.file-upload-area {
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
}
.file-name {
font-size: 12px;
color: var(--td-text-color-secondary);
margin: 8px 0;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@@ -40,7 +40,7 @@ export async function shouldUseBlackText(imageSrc: string): Promise<boolean> {
// 如果与黑色的对比度更高,说明背景较亮,应该使用黑色文字
// 如果与白色的对比度更高,说明背景较暗,应该使用白色文字
// 但对于中等亮度的颜色,我们需要更精细的判断
// 对于中等亮度的颜色(0.3-0.6),我们更倾向于使用黑色文本,因为黑色文本通常更易读
if (luminance > 0.3) {
return true // 使用黑色文本

View File

@@ -0,0 +1,179 @@
import type { SongList } from '@renderer/types/audio'
import CryptoJS from 'crypto-js'
// 加密密钥,实际应用中应该使用更安全的方式存储
const SECRET_KEY = 'CeruMusic-PlaylistSecretKey'
/**
* 加密播放列表数据
* @param data 要加密的数据
* @returns 加密后的字符串
*/
export function encryptPlaylist(data: SongList[]): string {
try {
const jsonString = JSON.stringify(data)
const encrypted = CryptoJS.AES.encrypt(jsonString, SECRET_KEY).toString()
return encrypted
} catch (error) {
console.error('加密播放列表失败:', error)
throw new Error('加密播放列表失败')
}
}
/**
* 解密播放列表数据
* @param encryptedData 加密的数据字符串
* @returns 解密后的播放列表数据
*/
export function decryptPlaylist(encryptedData: string): SongList[] {
try {
const decrypted = CryptoJS.AES.decrypt(encryptedData, SECRET_KEY).toString(CryptoJS.enc.Utf8)
return JSON.parse(decrypted) as SongList[]
} catch (error) {
console.error('解密播放列表失败:', error)
throw new Error('解密播放列表失败或数据格式不正确')
}
}
/**
* 导出播放列表到文件
* @param playlist 播放列表数据
* @returns 下载的文件名
*/
export function exportPlaylistToFile(playlist: SongList[]): string {
try {
if (!playlist || playlist.length === 0) {
throw new Error('播放列表为空')
}
const encryptedData = encryptPlaylist(playlist)
const fileName = `cerumusic-playlist-${new Date().toISOString().slice(0, 10)}.cpl`
// 创建Blob对象
const blob = new Blob([encryptedData], { type: 'application/octet-stream' })
// 创建下载链接
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = fileName
// 触发下载
document.body.appendChild(link)
link.click()
// 清理
setTimeout(() => {
document.body.removeChild(link)
URL.revokeObjectURL(url)
}, 100)
return fileName
} catch (error) {
console.error('导出播放列表失败:', error)
throw error
}
}
/**
* 将播放列表数据复制到剪贴板
* @param playlist 播放列表数据
*/
export async function copyPlaylistToClipboard(playlist: SongList[]): Promise<void> {
try {
if (!playlist || playlist.length === 0) {
throw new Error('播放列表为空')
}
const encryptedData = encryptPlaylist(playlist)
// 复制到剪贴板
await navigator.clipboard.writeText(encryptedData)
} catch (error) {
console.error('复制播放列表到剪贴板失败:', error)
throw error
}
}
/**
* 从文件导入播放列表
* @param file 导入的文件
* @returns Promise<SongList[]> 解析后的播放列表数据
*/
export function importPlaylistFromFile(file: File): Promise<SongList[]> {
return new Promise((resolve, reject) => {
if (!file) {
reject(new Error('未选择文件'))
return
}
if (!file.name.endsWith('.cpl')) {
reject(new Error('文件格式不正确,请选择.cpl格式的播放列表文件'))
return
}
const reader = new FileReader()
reader.onload = (event) => {
try {
if (!event.target || typeof event.target.result !== 'string') {
throw new Error('读取文件失败')
}
const encryptedData = event.target.result
const playlist = decryptPlaylist(encryptedData)
resolve(playlist)
} catch (error) {
reject(error)
}
}
reader.onerror = () => {
reject(new Error('读取文件失败'))
}
reader.readAsText(file)
})
}
/**
* 从剪贴板导入播放列表
* @returns Promise<SongList[]> 解析后的播放列表数据
*/
export async function importPlaylistFromClipboard(): Promise<SongList[]> {
try {
const clipboardText = await navigator.clipboard.readText()
if (!clipboardText) {
throw new Error('剪贴板为空')
}
return decryptPlaylist(clipboardText)
} catch (error) {
console.error('从剪贴板导入播放列表失败:', error)
throw error
}
}
/**
* 验证导入的播放列表数据是否有效
* @param playlist 播放列表数据
* @returns boolean 是否有效
*/
export function validateImportedPlaylist(playlist: any[]): boolean {
if (!Array.isArray(playlist) || playlist.length === 0) {
return false
}
// 验证每个歌曲对象是否包含必要的字段
return playlist.every(
(song) =>
typeof song === 'object' &&
typeof song.id === 'number' &&
typeof song.name === 'string' &&
typeof song.coverUrl === 'string' &&
Array.isArray(song.artist) &&
typeof song.duration === 'number' &&
typeof song.artistName === 'string'
)
}

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import TitleBarControls from '@renderer/components/TitleBarControls.vue'
import PlaylistSettings from '@renderer/components/Settings/PlaylistSettings.vue'
import { ref } from 'vue'
import { LocalUserDetailStore } from '@renderer/store/LocalUserDetail'
import { storeToRefs } from 'pinia'
@@ -173,6 +174,13 @@ const clearAPIKey = (): void => {
</div>
</div>
<!-- 播放列表管理部分 -->
<div class="demo-section">
<h3>播放列表管理</h3>
<PlaylistSettings />
<!-- <PlaylistActions></PlaylistActions> -->
</div>
<div class="demo-section">
<h3>功能说明</h3>
<div class="feature-list">

3932
yarn.lock

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,10 @@
- 多种播放模式(顺序/随机/单曲循环)
- 播放列表导出/导入功能
- 播放列表一键清空功能
## Tech Stack
{
@@ -19,7 +23,7 @@
## Design
现代简约风格深色背景配合高对比度控件主色调为TDesign主题蓝(#0052D9),包含固定底部播放控制区、垂直弹出式音量控制、图标式播放模式选择器和可拖拽排序的播放列表侧边栏
现代简约风格深色背景配合高对比度控件主色调为TDesign主题蓝(#0052D9),包含固定底部播放控制区、垂直弹出式音量控制、图标式播放模式选择器和可拖拽排序的播放列表侧边栏。新增导出/导入功能使用TDesign对话框组件提供文件导出/导入和内容复制/粘贴两种方式,所有导出内容进行加密处理。清空功能添加二次确认对话框防止误操作。
## Plan
@@ -50,3 +54,13 @@ Note:
[ ] 添加键盘快捷键支持和状态反馈机制
[ ] 进行组件间通信测试,确保功能协调工作
[ ] 创建播放列表加密/解密工具函数
[ ] 实现播放列表导出功能(文件导出和内容复制)
[ ] 实现播放列表导入功能(文件导入和内容粘贴)
[ ] 实现播放列表一键清空功能及二次确认机制
[ ] 测试导出/导入功能的加密解密正确性