add 歌曲导入导出
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
10
package.json
@@ -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",
|
||||
|
||||
BIN
resources/icons/1024x1024.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
resources/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
resources/icons/16x16.png
Normal file
|
After Width: | Height: | Size: 437 B |
BIN
resources/icons/24x24.png
Normal file
|
After Width: | Height: | Size: 633 B |
BIN
resources/icons/256x256.png
Normal file
|
After Width: | Height: | Size: 8.1 KiB |
BIN
resources/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 815 B |
BIN
resources/icons/48x48.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
resources/icons/512x512.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
resources/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
resources/icons/icon.icns
Normal file
BIN
resources/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 353 KiB |
BIN
resources/logo.ico
Normal file
|
After Width: | Height: | Size: 264 KiB |
@@ -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,
|
||||
|
||||
4
src/renderer/auto-imports.d.ts
vendored
@@ -5,4 +5,6 @@
|
||||
// Generated by unplugin-auto-import
|
||||
// biome-ignore lint: disable
|
||||
export {}
|
||||
declare global {}
|
||||
declare global {
|
||||
|
||||
}
|
||||
|
||||
5
src/renderer/components.d.ts
vendored
@@ -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']
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
352
src/renderer/src/components/Play/PlaylistActions.vue
Normal 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>
|
||||
525
src/renderer/src/components/Settings/PlaylistSettings.vue
Normal 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>
|
||||
@@ -40,7 +40,7 @@ export async function shouldUseBlackText(imageSrc: string): Promise<boolean> {
|
||||
// 如果与黑色的对比度更高,说明背景较亮,应该使用黑色文字
|
||||
// 如果与白色的对比度更高,说明背景较暗,应该使用白色文字
|
||||
// 但对于中等亮度的颜色,我们需要更精细的判断
|
||||
|
||||
|
||||
// 对于中等亮度的颜色(0.3-0.6),我们更倾向于使用黑色文本,因为黑色文本通常更易读
|
||||
if (luminance > 0.3) {
|
||||
return true // 使用黑色文本
|
||||
|
||||
179
src/renderer/src/utils/playlistExportImport.ts
Normal 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'
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
|
||||
- 多种播放模式(顺序/随机/单曲循环)
|
||||
|
||||
- 播放列表导出/导入功能
|
||||
|
||||
- 播放列表一键清空功能
|
||||
|
||||
## Tech Stack
|
||||
|
||||
{
|
||||
@@ -19,7 +23,7 @@
|
||||
|
||||
## Design
|
||||
|
||||
现代简约风格,深色背景配合高对比度控件,主色调为TDesign主题蓝(#0052D9),包含固定底部播放控制区、垂直弹出式音量控制、图标式播放模式选择器和可拖拽排序的播放列表侧边栏
|
||||
现代简约风格,深色背景配合高对比度控件,主色调为TDesign主题蓝(#0052D9),包含固定底部播放控制区、垂直弹出式音量控制、图标式播放模式选择器和可拖拽排序的播放列表侧边栏。新增导出/导入功能使用TDesign对话框组件,提供文件导出/导入和内容复制/粘贴两种方式,所有导出内容进行加密处理。清空功能添加二次确认对话框防止误操作。
|
||||
|
||||
## Plan
|
||||
|
||||
@@ -50,3 +54,13 @@ Note:
|
||||
[ ] 添加键盘快捷键支持和状态反馈机制
|
||||
|
||||
[ ] 进行组件间通信测试,确保功能协调工作
|
||||
|
||||
[ ] 创建播放列表加密/解密工具函数
|
||||
|
||||
[ ] 实现播放列表导出功能(文件导出和内容复制)
|
||||
|
||||
[ ] 实现播放列表导入功能(文件导入和内容粘贴)
|
||||
|
||||
[ ] 实现播放列表一键清空功能及二次确认机制
|
||||
|
||||
[ ] 测试导出/导入功能的加密解密正确性
|
||||
|
||||