mirror of
https://github.com/algerkong/AlgerMusicPlayer.git
synced 2026-04-28 10:57:23 +08:00
feat: 添加下载管理页面, 引入文件类型检测库以支持多种音频格式
This commit is contained in:
@@ -30,8 +30,10 @@
|
|||||||
"electron-updater": "^6.6.2",
|
"electron-updater": "^6.6.2",
|
||||||
"electron-window-state": "^5.0.3",
|
"electron-window-state": "^5.0.3",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
|
"file-type": "^21.0.0",
|
||||||
"font-list": "^1.5.1",
|
"font-list": "^1.5.1",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
|
"music-metadata": "^11.2.3",
|
||||||
"netease-cloud-music-api-alger": "^4.26.1",
|
"netease-cloud-music-api-alger": "^4.26.1",
|
||||||
"node-id3": "^0.2.9",
|
"node-id3": "^0.2.9",
|
||||||
"node-machine-id": "^1.1.12",
|
"node-machine-id": "^1.1.12",
|
||||||
|
|||||||
@@ -45,5 +45,11 @@ export default {
|
|||||||
downloadComplete: '{filename} download completed',
|
downloadComplete: '{filename} download completed',
|
||||||
downloadFailed: '{filename} download failed: {error}'
|
downloadFailed: '{filename} download failed: {error}'
|
||||||
},
|
},
|
||||||
loading: 'Loading...'
|
loading: 'Loading...',
|
||||||
|
playStarted: 'Play started: {name}',
|
||||||
|
playFailed: 'Play failed: {name}',
|
||||||
|
path: {
|
||||||
|
copied: 'Path copied to clipboard',
|
||||||
|
copyFailed: 'Failed to copy path'
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -44,5 +44,11 @@ export default {
|
|||||||
downloadComplete: '{filename} 下载完成',
|
downloadComplete: '{filename} 下载完成',
|
||||||
downloadFailed: '{filename} 下载失败: {error}'
|
downloadFailed: '{filename} 下载失败: {error}'
|
||||||
},
|
},
|
||||||
loading: '加载中...'
|
loading: '加载中...',
|
||||||
|
playStarted: '开始播放: {name}',
|
||||||
|
playFailed: '播放失败: {name}',
|
||||||
|
path: {
|
||||||
|
copied: '路径已复制到剪贴板',
|
||||||
|
copyFailed: '复制路径失败'
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
+183
-69
@@ -6,6 +6,11 @@ import * as http from 'http';
|
|||||||
import * as https from 'https';
|
import * as https from 'https';
|
||||||
import * as NodeID3 from 'node-id3';
|
import * as NodeID3 from 'node-id3';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as mm from 'music-metadata';
|
||||||
|
// 导入文件类型库,这里使用CommonJS兼容方式导入
|
||||||
|
// 对于file-type v21.0.0需要这样导入
|
||||||
|
import { fileTypeFromFile } from 'file-type';
|
||||||
|
|
||||||
import { getStore } from './config';
|
import { getStore } from './config';
|
||||||
|
|
||||||
@@ -36,9 +41,18 @@ export function initializeFileManager() {
|
|||||||
// 注册本地文件协议
|
// 注册本地文件协议
|
||||||
protocol.registerFileProtocol('local', (request, callback) => {
|
protocol.registerFileProtocol('local', (request, callback) => {
|
||||||
try {
|
try {
|
||||||
const decodedUrl = decodeURIComponent(request.url);
|
let url = request.url;
|
||||||
const filePath = decodedUrl.replace('local://', '');
|
// local://C:/Users/xxx.mp3
|
||||||
|
let filePath = decodeURIComponent(url.replace('local:///', ''));
|
||||||
|
|
||||||
|
// 兼容 local:///C:/Users/xxx.mp3 这种情况
|
||||||
|
if (/^\/[a-zA-Z]:\//.test(filePath)) {
|
||||||
|
filePath = filePath.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 还原为系统路径格式
|
||||||
|
filePath = path.normalize(filePath);
|
||||||
|
|
||||||
// 检查文件是否存在
|
// 检查文件是否存在
|
||||||
if (!fs.existsSync(filePath)) {
|
if (!fs.existsSync(filePath)) {
|
||||||
console.error('File not found:', filePath);
|
console.error('File not found:', filePath);
|
||||||
@@ -53,6 +67,31 @@ export function initializeFileManager() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 检查文件是否存在
|
||||||
|
ipcMain.handle('check-file-exists', (_, filePath) => {
|
||||||
|
try {
|
||||||
|
return fs.existsSync(filePath);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking if file exists:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取支持的音频格式列表
|
||||||
|
ipcMain.handle('get-supported-audio-formats', () => {
|
||||||
|
return {
|
||||||
|
formats: [
|
||||||
|
{ ext: 'mp3', name: 'MP3' },
|
||||||
|
{ ext: 'm4a', name: 'M4A/AAC' },
|
||||||
|
{ ext: 'flac', name: 'FLAC' },
|
||||||
|
{ ext: 'wav', name: 'WAV' },
|
||||||
|
{ ext: 'ogg', name: 'OGG Vorbis' },
|
||||||
|
{ ext: 'aac', name: 'AAC' }
|
||||||
|
],
|
||||||
|
default: 'mp3'
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// 通用的选择目录处理
|
// 通用的选择目录处理
|
||||||
ipcMain.handle('select-directory', async () => {
|
ipcMain.handle('select-directory', async () => {
|
||||||
const result = await dialog.showOpenDialog({
|
const result = await dialog.showOpenDialog({
|
||||||
@@ -311,6 +350,7 @@ async function downloadMusic(
|
|||||||
) {
|
) {
|
||||||
let finalFilePath = '';
|
let finalFilePath = '';
|
||||||
let writer: fs.WriteStream | null = null;
|
let writer: fs.WriteStream | null = null;
|
||||||
|
let tempFilePath = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 使用配置Store来获取设置
|
// 使用配置Store来获取设置
|
||||||
@@ -322,25 +362,21 @@ async function downloadMusic(
|
|||||||
// 清理文件名中的非法字符
|
// 清理文件名中的非法字符
|
||||||
const sanitizedFilename = sanitizeFilename(filename);
|
const sanitizedFilename = sanitizeFilename(filename);
|
||||||
|
|
||||||
// 从URL中获取文件扩展名,如果没有则使用传入的type或默认mp3
|
// 创建临时文件路径 (在系统临时目录中创建)
|
||||||
const urlExt = type ? `.${type}` : '.mp3';
|
const tempDir = path.join(os.tmpdir(), 'AlgerMusicPlayerTemp');
|
||||||
const filePath = path.join(downloadPath, `${sanitizedFilename}${urlExt}`);
|
|
||||||
|
// 确保临时目录存在
|
||||||
// 检查文件是否已存在,如果存在则添加序号
|
if (!fs.existsSync(tempDir)) {
|
||||||
finalFilePath = filePath;
|
fs.mkdirSync(tempDir, { recursive: true });
|
||||||
let counter = 1;
|
|
||||||
while (fs.existsSync(finalFilePath)) {
|
|
||||||
const ext = path.extname(filePath);
|
|
||||||
const nameWithoutExt = filePath.slice(0, -ext.length);
|
|
||||||
finalFilePath = `${nameWithoutExt} (${counter})${ext}`;
|
|
||||||
counter++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tempFilePath = path.join(tempDir, `${Date.now()}_${sanitizedFilename}.tmp`);
|
||||||
|
|
||||||
// 先获取文件大小
|
// 先获取文件大小
|
||||||
const headResponse = await axios.head(url);
|
const headResponse = await axios.head(url);
|
||||||
const totalSize = parseInt(headResponse.headers['content-length'] || '0', 10);
|
const totalSize = parseInt(headResponse.headers['content-length'] || '0', 10);
|
||||||
|
|
||||||
// 开始下载
|
// 开始下载到临时文件
|
||||||
const response = await axios({
|
const response = await axios({
|
||||||
url,
|
url,
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
@@ -350,7 +386,7 @@ async function downloadMusic(
|
|||||||
httpsAgent: new https.Agent({ keepAlive: true })
|
httpsAgent: new https.Agent({ keepAlive: true })
|
||||||
});
|
});
|
||||||
|
|
||||||
writer = fs.createWriteStream(finalFilePath);
|
writer = fs.createWriteStream(tempFilePath);
|
||||||
let downloadedSize = 0;
|
let downloadedSize = 0;
|
||||||
|
|
||||||
// 使用 data 事件来跟踪下载进度
|
// 使用 data 事件来跟踪下载进度
|
||||||
@@ -362,7 +398,7 @@ async function downloadMusic(
|
|||||||
progress,
|
progress,
|
||||||
loaded: downloadedSize,
|
loaded: downloadedSize,
|
||||||
total: totalSize,
|
total: totalSize,
|
||||||
path: finalFilePath,
|
path: tempFilePath,
|
||||||
status: progress === 100 ? 'completed' : 'downloading',
|
status: progress === 100 ? 'completed' : 'downloading',
|
||||||
songInfo: songInfo || {
|
songInfo: songInfo || {
|
||||||
name: filename,
|
name: filename,
|
||||||
@@ -380,11 +416,77 @@ async function downloadMusic(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 验证文件是否完整下载
|
// 验证文件是否完整下载
|
||||||
const stats = fs.statSync(finalFilePath);
|
const stats = fs.statSync(tempFilePath);
|
||||||
if (stats.size !== totalSize) {
|
if (stats.size !== totalSize) {
|
||||||
throw new Error('文件下载不完整');
|
throw new Error('文件下载不完整');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检测文件类型
|
||||||
|
let fileExtension = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 首先尝试使用file-type库检测
|
||||||
|
const fileType = await fileTypeFromFile(tempFilePath);
|
||||||
|
if (fileType && fileType.ext) {
|
||||||
|
fileExtension = `.${fileType.ext}`;
|
||||||
|
console.log(`文件类型检测结果: ${fileType.mime}, 扩展名: ${fileExtension}`);
|
||||||
|
} else {
|
||||||
|
// 如果file-type无法识别,尝试使用music-metadata
|
||||||
|
const metadata = await mm.parseFile(tempFilePath);
|
||||||
|
if (metadata && metadata.format) {
|
||||||
|
// 根据format.container或codec判断扩展名
|
||||||
|
const formatInfo = metadata.format;
|
||||||
|
const container = formatInfo.container || '';
|
||||||
|
const codec = formatInfo.codec || '';
|
||||||
|
|
||||||
|
// 音频格式映射表
|
||||||
|
const formatMap = {
|
||||||
|
'mp3': ['MPEG', 'MP3', 'mp3'],
|
||||||
|
'aac': ['AAC'],
|
||||||
|
'flac': ['FLAC'],
|
||||||
|
'ogg': ['Ogg', 'Vorbis'],
|
||||||
|
'wav': ['WAV', 'PCM'],
|
||||||
|
'm4a': ['M4A', 'MP4']
|
||||||
|
};
|
||||||
|
|
||||||
|
// 查找匹配的格式
|
||||||
|
const format = Object.entries(formatMap).find(([_, keywords]) =>
|
||||||
|
keywords.some(keyword => container.includes(keyword) || codec.includes(keyword))
|
||||||
|
);
|
||||||
|
|
||||||
|
// 设置文件扩展名,如果没找到则默认为mp3
|
||||||
|
fileExtension = format ? `.${format[0]}` : '.mp3';
|
||||||
|
|
||||||
|
console.log(`music-metadata检测结果: 容器:${container}, 编码:${codec}, 扩展名: ${fileExtension}`);
|
||||||
|
} else {
|
||||||
|
// 两种方法都失败,使用传入的type或默认mp3
|
||||||
|
fileExtension = type ? `.${type}` : '.mp3';
|
||||||
|
console.log(`无法检测文件类型,使用默认扩展名: ${fileExtension}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('检测文件类型失败:', err);
|
||||||
|
// 检测失败,使用传入的type或默认mp3
|
||||||
|
fileExtension = type ? `.${type}` : '.mp3';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用检测到的文件扩展名创建最终文件路径
|
||||||
|
const filePath = path.join(downloadPath, `${sanitizedFilename}${fileExtension}`);
|
||||||
|
|
||||||
|
// 检查文件是否已存在,如果存在则添加序号
|
||||||
|
finalFilePath = filePath;
|
||||||
|
let counter = 1;
|
||||||
|
while (fs.existsSync(finalFilePath)) {
|
||||||
|
const ext = path.extname(filePath);
|
||||||
|
const nameWithoutExt = filePath.slice(0, -ext.length);
|
||||||
|
finalFilePath = `${nameWithoutExt} (${counter})${ext}`;
|
||||||
|
counter++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将临时文件移动到最终位置
|
||||||
|
fs.copyFileSync(tempFilePath, finalFilePath);
|
||||||
|
fs.unlinkSync(tempFilePath); // 删除临时文件
|
||||||
|
|
||||||
// 下载歌词
|
// 下载歌词
|
||||||
let lyricData = null;
|
let lyricData = null;
|
||||||
let lyricsContent = '';
|
let lyricsContent = '';
|
||||||
@@ -413,8 +515,7 @@ async function downloadMusic(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 不再单独写入歌词文件,只保存在ID3标签中
|
console.log('歌词已准备好,将写入元数据');
|
||||||
console.log('歌词已准备好,将写入ID3标签');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (lyricError) {
|
} catch (lyricError) {
|
||||||
@@ -437,9 +538,7 @@ async function downloadMusic(
|
|||||||
|
|
||||||
// 获取封面图片的buffer
|
// 获取封面图片的buffer
|
||||||
coverImageBuffer = Buffer.from(coverResponse.data);
|
coverImageBuffer = Buffer.from(coverResponse.data);
|
||||||
|
console.log('封面已准备好,将写入元数据');
|
||||||
// 不再单独保存封面文件,只保存在ID3标签中
|
|
||||||
console.log('封面已准备好,将写入ID3标签');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (coverError) {
|
} catch (coverError) {
|
||||||
@@ -447,54 +546,58 @@ async function downloadMusic(
|
|||||||
// 继续处理,不影响音乐下载
|
// 继续处理,不影响音乐下载
|
||||||
}
|
}
|
||||||
|
|
||||||
// 在写入ID3标签前,先移除可能存在的旧标签
|
const fileFormat = fileExtension.toLowerCase();
|
||||||
try {
|
|
||||||
NodeID3.removeTags(finalFilePath);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error removing existing ID3 tags:', err);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 强化ID3标签的写入格式
|
|
||||||
|
|
||||||
const artistNames =
|
const artistNames =
|
||||||
(songInfo?.ar || songInfo?.song?.artists)?.map((a: any) => a.name).join('/ ') || '未知艺术家';
|
(songInfo?.ar || songInfo?.song?.artists)?.map((a: any) => a.name).join('/ ') || '未知艺术家';
|
||||||
const tags = {
|
|
||||||
title: filename,
|
|
||||||
artist: artistNames,
|
|
||||||
TPE1: artistNames,
|
|
||||||
TPE2: artistNames,
|
|
||||||
album: songInfo?.al?.name || songInfo?.song?.album?.name || songInfo?.name || filename,
|
|
||||||
APIC: {
|
|
||||||
// 专辑封面
|
|
||||||
imageBuffer: coverImageBuffer,
|
|
||||||
type: {
|
|
||||||
id: 3,
|
|
||||||
name: 'front cover'
|
|
||||||
},
|
|
||||||
description: 'Album cover',
|
|
||||||
mime: 'image/jpeg'
|
|
||||||
},
|
|
||||||
USLT: {
|
|
||||||
// 歌词
|
|
||||||
language: 'chi',
|
|
||||||
description: 'Lyrics',
|
|
||||||
text: lyricsContent || ''
|
|
||||||
},
|
|
||||||
trackNumber: songInfo?.no || undefined,
|
|
||||||
year: songInfo?.publishTime
|
|
||||||
? new Date(songInfo.publishTime).getFullYear().toString()
|
|
||||||
: undefined
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
// 根据文件类型处理元数据
|
||||||
const success = NodeID3.write(tags, finalFilePath);
|
if (['.mp3'].includes(fileFormat)) {
|
||||||
if (!success) {
|
// 对MP3文件使用NodeID3处理ID3标签
|
||||||
console.error('Failed to write ID3 tags');
|
try {
|
||||||
} else {
|
// 在写入ID3标签前,先移除可能存在的旧标签
|
||||||
console.log('ID3 tags written successfully');
|
NodeID3.removeTags(finalFilePath);
|
||||||
|
|
||||||
|
const tags = {
|
||||||
|
title: filename,
|
||||||
|
artist: artistNames,
|
||||||
|
TPE1: artistNames,
|
||||||
|
TPE2: artistNames,
|
||||||
|
album: songInfo?.al?.name || songInfo?.song?.album?.name || songInfo?.name || filename,
|
||||||
|
APIC: {
|
||||||
|
// 专辑封面
|
||||||
|
imageBuffer: coverImageBuffer,
|
||||||
|
type: {
|
||||||
|
id: 3,
|
||||||
|
name: 'front cover'
|
||||||
|
},
|
||||||
|
description: 'Album cover',
|
||||||
|
mime: 'image/jpeg'
|
||||||
|
},
|
||||||
|
USLT: {
|
||||||
|
// 歌词
|
||||||
|
language: 'chi',
|
||||||
|
description: 'Lyrics',
|
||||||
|
text: lyricsContent || ''
|
||||||
|
},
|
||||||
|
trackNumber: songInfo?.no || undefined,
|
||||||
|
year: songInfo?.publishTime
|
||||||
|
? new Date(songInfo.publishTime).getFullYear().toString()
|
||||||
|
: undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
const success = NodeID3.write(tags, finalFilePath);
|
||||||
|
if (!success) {
|
||||||
|
console.error('Failed to write ID3 tags');
|
||||||
|
} else {
|
||||||
|
console.log('ID3 tags written successfully');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error writing ID3 tags:', err);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} else {
|
||||||
console.error('Error writing ID3 tags:', err);
|
// 对于非MP3文件,使用music-metadata来写入元数据可能需要专门的库
|
||||||
|
// 或者根据不同文件类型使用专用工具,暂时只记录但不处理
|
||||||
|
console.log(`文件类型 ${fileFormat} 不支持使用NodeID3写入标签,跳过元数据写入`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保存下载信息
|
// 保存下载信息
|
||||||
@@ -519,7 +622,7 @@ async function downloadMusic(
|
|||||||
size: totalSize,
|
size: totalSize,
|
||||||
path: finalFilePath,
|
path: finalFilePath,
|
||||||
downloadTime: Date.now(),
|
downloadTime: Date.now(),
|
||||||
type: type || 'mp3',
|
type: fileExtension.substring(1), // 去掉前面的点号,只保留扩展名
|
||||||
lyric: lyricData
|
lyric: lyricData
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -571,6 +674,17 @@ async function downloadMusic(
|
|||||||
if (writer) {
|
if (writer) {
|
||||||
writer.end();
|
writer.end();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 清理临时文件
|
||||||
|
if (tempFilePath && fs.existsSync(tempFilePath)) {
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(tempFilePath);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to delete temporary file:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理未完成的最终文件
|
||||||
if (finalFilePath && fs.existsSync(finalFilePath)) {
|
if (finalFilePath && fs.existsSync(finalFilePath)) {
|
||||||
try {
|
try {
|
||||||
fs.unlinkSync(finalFilePath);
|
fs.unlinkSync(finalFilePath);
|
||||||
|
|||||||
@@ -1,475 +1,34 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="download-drawer-trigger">
|
<div class="download-drawer-trigger">
|
||||||
<n-badge :value="downloadingCount" :max="99" :show="downloadingCount > 0">
|
<n-badge :value="downloadingCount" :max="99" :show="downloadingCount > 0">
|
||||||
<n-button circle @click="settingsStore.showDownloadDrawer = true">
|
<n-button circle @click="navigateToDownloads">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<i class="iconfont ri-download-cloud-2-line"></i>
|
<i class="iconfont ri-download-cloud-2-line"></i>
|
||||||
</template>
|
</template>
|
||||||
</n-button>
|
</n-button>
|
||||||
</n-badge>
|
</n-badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<n-drawer
|
|
||||||
v-model:show="showDrawer"
|
|
||||||
:height="'80%'"
|
|
||||||
placement="bottom"
|
|
||||||
@after-leave="handleDrawerClose"
|
|
||||||
>
|
|
||||||
<n-drawer-content :title="t('download.title')" closable :native-scrollbar="false">
|
|
||||||
<div class="drawer-container">
|
|
||||||
<n-tabs type="line" animated class="h-full" v-model:value="tabName">
|
|
||||||
<!-- 下载列表 -->
|
|
||||||
<n-tab-pane name="downloading" :tab="t('download.tabs.downloading')" class="h-full">
|
|
||||||
<div class="download-list">
|
|
||||||
<div v-if="downloadList.length === 0" class="empty-tip">
|
|
||||||
<n-empty :description="t('download.empty.noTasks')" />
|
|
||||||
</div>
|
|
||||||
<template v-else>
|
|
||||||
<div class="total-progress">
|
|
||||||
<div class="total-progress-text">
|
|
||||||
{{ t('download.progress.total', { progress: totalProgress.toFixed(1) }) }}
|
|
||||||
</div>
|
|
||||||
<n-progress
|
|
||||||
type="line"
|
|
||||||
:percentage="Number(totalProgress.toFixed(1))"
|
|
||||||
:height="12"
|
|
||||||
:border-radius="6"
|
|
||||||
:indicator-placement="'inside'"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="download-content">
|
|
||||||
<div class="download-items">
|
|
||||||
<div v-for="item in downloadList" :key="item.path" class="download-item">
|
|
||||||
<div class="download-item-content">
|
|
||||||
<div class="download-item-cover">
|
|
||||||
<n-image
|
|
||||||
:src="getImgUrl(item.songInfo?.picUrl, '200y200')"
|
|
||||||
preview-disabled
|
|
||||||
:object-fit="'cover'"
|
|
||||||
class="cover-image"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="download-item-info">
|
|
||||||
<div class="download-item-name" :title="item.filename">
|
|
||||||
{{ item.filename }}
|
|
||||||
</div>
|
|
||||||
<div class="download-item-artist">
|
|
||||||
{{
|
|
||||||
item.songInfo?.ar?.map((a) => a.name).join(', ') ||
|
|
||||||
t('download.artist.unknown')
|
|
||||||
}}
|
|
||||||
</div>
|
|
||||||
<div class="download-item-progress">
|
|
||||||
<n-progress
|
|
||||||
type="line"
|
|
||||||
:percentage="item.progress"
|
|
||||||
:processing="item.status === 'downloading'"
|
|
||||||
:status="getProgressStatus(item)"
|
|
||||||
:height="8"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="download-item-size">
|
|
||||||
<span
|
|
||||||
>{{ formatSize(item.loaded) }} / {{ formatSize(item.total) }}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="download-item-status">
|
|
||||||
<n-tag :type="getStatusType(item)" size="small">
|
|
||||||
{{ getStatusText(item) }}
|
|
||||||
</n-tag>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</n-tab-pane>
|
|
||||||
|
|
||||||
<!-- 已下载列表 -->
|
|
||||||
<n-tab-pane name="downloaded" :tab="t('download.tabs.downloaded')" class="h-full">
|
|
||||||
<div class="downloaded-list">
|
|
||||||
<div v-if="isLoadingDownloaded" class="loading-tip">
|
|
||||||
<n-spin size="medium" />
|
|
||||||
<span class="loading-text">{{ t('download.loading') }}</span>
|
|
||||||
</div>
|
|
||||||
<div v-else-if="downloadedList.length === 0" class="empty-tip">
|
|
||||||
<n-empty :description="t('download.empty.noDownloaded')" />
|
|
||||||
</div>
|
|
||||||
<div v-else class="downloaded-content">
|
|
||||||
<div class="downloaded-header">
|
|
||||||
<div class="header-title">
|
|
||||||
{{ t('download.count', { count: downloadedList.length }) }}
|
|
||||||
</div>
|
|
||||||
<n-button secondary size="small" @click="showClearConfirm = true">
|
|
||||||
<template #icon>
|
|
||||||
<i class="iconfont ri-delete-bin-line mr-1"></i>
|
|
||||||
</template>
|
|
||||||
{{ t('download.clearAll') }}
|
|
||||||
</n-button>
|
|
||||||
</div>
|
|
||||||
<div class="downloaded-items">
|
|
||||||
<div v-for="item in downList" :key="item.path" class="downloaded-item">
|
|
||||||
<div class="downloaded-item-content">
|
|
||||||
<div class="downloaded-item-cover">
|
|
||||||
<n-image
|
|
||||||
:src="getImgUrl(item.picUrl, '200y200')"
|
|
||||||
preview-disabled
|
|
||||||
:object-fit="'cover'"
|
|
||||||
class="cover-image"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="downloaded-item-info">
|
|
||||||
<div class="downloaded-item-name" :title="item.filename">
|
|
||||||
{{ item.filename }}
|
|
||||||
</div>
|
|
||||||
<div class="downloaded-item-artist">
|
|
||||||
{{ item.ar?.map((a) => a.name).join(', ') }}
|
|
||||||
</div>
|
|
||||||
<div class="downloaded-item-size">{{ formatSize(item.size) }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="downloaded-item-actions">
|
|
||||||
<!-- <n-button text type="primary" size="large" @click="handlePlayMusic(item)">
|
|
||||||
<template #icon>
|
|
||||||
<i class="iconfont ri-play-circle-line text-xl"></i>
|
|
||||||
</template>
|
|
||||||
</n-button> -->
|
|
||||||
<n-button
|
|
||||||
text
|
|
||||||
type="primary"
|
|
||||||
size="large"
|
|
||||||
@click="openDirectory(item.path)"
|
|
||||||
>
|
|
||||||
<template #icon>
|
|
||||||
<i class="iconfont ri-folder-open-line text-xl"></i>
|
|
||||||
</template>
|
|
||||||
</n-button>
|
|
||||||
<n-button text type="error" size="large" @click="handleDelete(item)">
|
|
||||||
<template #icon>
|
|
||||||
<i class="iconfont ri-delete-bin-line text-xl"></i>
|
|
||||||
</template>
|
|
||||||
</n-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</n-tab-pane>
|
|
||||||
</n-tabs>
|
|
||||||
</div>
|
|
||||||
</n-drawer-content>
|
|
||||||
</n-drawer>
|
|
||||||
|
|
||||||
<!-- 删除确认对话框 -->
|
|
||||||
<n-modal
|
|
||||||
v-model:show="showDeleteConfirm"
|
|
||||||
preset="dialog"
|
|
||||||
type="warning"
|
|
||||||
:title="t('download.delete.title')"
|
|
||||||
>
|
|
||||||
<template #header>
|
|
||||||
<div class="flex items-center">
|
|
||||||
<i class="iconfont ri-error-warning-line mr-2 text-xl"></i>
|
|
||||||
<span>{{ t('download.delete.title') }}</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<div class="delete-confirm-content">
|
|
||||||
{{ t('download.delete.message', { filename: itemToDelete?.filename }) }}
|
|
||||||
</div>
|
|
||||||
<template #action>
|
|
||||||
<n-button size="small" @click="showDeleteConfirm = false">{{
|
|
||||||
t('download.delete.cancel')
|
|
||||||
}}</n-button>
|
|
||||||
<n-button size="small" type="warning" @click="confirmDelete">{{
|
|
||||||
t('download.delete.confirm')
|
|
||||||
}}</n-button>
|
|
||||||
</template>
|
|
||||||
</n-modal>
|
|
||||||
|
|
||||||
<!-- 清空确认对话框 -->
|
|
||||||
<n-modal
|
|
||||||
v-model:show="showClearConfirm"
|
|
||||||
preset="dialog"
|
|
||||||
type="warning"
|
|
||||||
:title="t('download.clear.title')"
|
|
||||||
>
|
|
||||||
<template #header>
|
|
||||||
<div class="flex items-center">
|
|
||||||
<i class="iconfont ri-delete-bin-line mr-2 text-xl"></i>
|
|
||||||
<span>{{ t('download.clear.title') }}</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<div class="delete-confirm-content">
|
|
||||||
{{ t('download.clear.message') }}
|
|
||||||
</div>
|
|
||||||
<template #action>
|
|
||||||
<n-button size="small" @click="showClearConfirm = false">{{
|
|
||||||
t('download.clear.cancel')
|
|
||||||
}}</n-button>
|
|
||||||
<n-button size="small" type="warning" @click="clearDownloadRecords">{{
|
|
||||||
t('download.clear.confirm')
|
|
||||||
}}</n-button>
|
|
||||||
</template>
|
|
||||||
</n-modal>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { ProgressStatus } from 'naive-ui';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import { useMessage } from 'naive-ui';
|
import { useRouter } from 'vue-router';
|
||||||
import { computed, onMounted, ref, watch } from 'vue';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
|
|
||||||
import { getMusicDetail } from '@/api/music';
|
const router = useRouter();
|
||||||
// import { usePlayerStore } from '@/store/modules/player';
|
const downloadList = ref<any[]>([]);
|
||||||
import { useSettingsStore } from '@/store/modules/settings';
|
|
||||||
// import { audioService } from '@/services/audioService';
|
|
||||||
import { getImgUrl } from '@/utils';
|
|
||||||
// import { SongResult } from '@/type/music';
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
|
||||||
|
|
||||||
interface DownloadItem {
|
|
||||||
filename: string;
|
|
||||||
progress: number;
|
|
||||||
loaded: number;
|
|
||||||
total: number;
|
|
||||||
path: string;
|
|
||||||
status: 'downloading' | 'completed' | 'error';
|
|
||||||
error?: string;
|
|
||||||
songInfo?: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DownloadedItem {
|
|
||||||
filename: string;
|
|
||||||
path: string;
|
|
||||||
size: number;
|
|
||||||
id: number;
|
|
||||||
picUrl: string;
|
|
||||||
ar: { name: string }[];
|
|
||||||
}
|
|
||||||
const tabName = ref('downloading');
|
|
||||||
const message = useMessage();
|
|
||||||
// const playerStore = usePlayerStore();
|
|
||||||
const settingsStore = useSettingsStore();
|
|
||||||
|
|
||||||
const showDrawer = computed({
|
|
||||||
get: () => settingsStore.showDownloadDrawer,
|
|
||||||
set: (val) => {
|
|
||||||
settingsStore.showDownloadDrawer = val;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const downloadList = ref<DownloadItem[]>([]);
|
|
||||||
const downloadedList = ref<DownloadedItem[]>(
|
|
||||||
JSON.parse(localStorage.getItem('downloadedList') || '[]')
|
|
||||||
);
|
|
||||||
|
|
||||||
const downList = computed(() => downloadedList.value);
|
|
||||||
|
|
||||||
// 计算下载中的任务数量
|
// 计算下载中的任务数量
|
||||||
const downloadingCount = computed(() => {
|
const downloadingCount = computed(() => {
|
||||||
return downloadList.value.filter((item) => item.status === 'downloading').length;
|
return downloadList.value.filter((item) => item.status === 'downloading').length;
|
||||||
});
|
});
|
||||||
|
|
||||||
// 计算总进度
|
// 导航到下载页面
|
||||||
const totalProgress = computed(() => {
|
const navigateToDownloads = () => {
|
||||||
if (downloadList.value.length === 0) return 0;
|
router.push('/downloads');
|
||||||
const total = downloadList.value.reduce((sum, item) => sum + item.progress, 0);
|
|
||||||
return total / downloadList.value.length;
|
|
||||||
});
|
|
||||||
|
|
||||||
watch(totalProgress, (newVal) => {
|
|
||||||
if (newVal === 100) {
|
|
||||||
refreshDownloadedList();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 获取状态类型
|
|
||||||
const getStatusType = (item: DownloadItem) => {
|
|
||||||
switch (item.status) {
|
|
||||||
case 'downloading':
|
|
||||||
return 'info';
|
|
||||||
case 'completed':
|
|
||||||
return 'success';
|
|
||||||
case 'error':
|
|
||||||
return 'error';
|
|
||||||
default:
|
|
||||||
return 'default';
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 获取状态文本
|
|
||||||
const getStatusText = (item: DownloadItem) => {
|
|
||||||
switch (item.status) {
|
|
||||||
case 'downloading':
|
|
||||||
return t('download.status.downloading');
|
|
||||||
case 'completed':
|
|
||||||
return t('download.status.completed');
|
|
||||||
case 'error':
|
|
||||||
return t('download.status.failed');
|
|
||||||
default:
|
|
||||||
return t('download.status.unknown');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 获取进度条状态
|
|
||||||
const getProgressStatus = (item: DownloadItem): ProgressStatus => {
|
|
||||||
switch (item.status) {
|
|
||||||
case 'completed':
|
|
||||||
return 'success';
|
|
||||||
case 'error':
|
|
||||||
return 'error';
|
|
||||||
default:
|
|
||||||
return 'info';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 格式化文件大小
|
|
||||||
const formatSize = (bytes: number) => {
|
|
||||||
if (!bytes) return '0 B';
|
|
||||||
const k = 1024;
|
|
||||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
||||||
return `${(bytes / k ** i).toFixed(1)} ${sizes[i]}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 打开目录
|
|
||||||
const openDirectory = (path: string) => {
|
|
||||||
window.electron.ipcRenderer.send('open-directory', path);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 删除相关
|
|
||||||
const showDeleteConfirm = ref(false);
|
|
||||||
const itemToDelete = ref<DownloadedItem | null>(null);
|
|
||||||
|
|
||||||
// 处理删除点击
|
|
||||||
const handleDelete = (item: DownloadedItem) => {
|
|
||||||
itemToDelete.value = item;
|
|
||||||
showDeleteConfirm.value = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 确认删除
|
|
||||||
const confirmDelete = async () => {
|
|
||||||
const item = itemToDelete.value;
|
|
||||||
if (!item) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const success = await window.electron.ipcRenderer.invoke(
|
|
||||||
'delete-downloaded-music',
|
|
||||||
item.path
|
|
||||||
);
|
|
||||||
|
|
||||||
if (success) {
|
|
||||||
const newList = downloadedList.value.filter(i => i.id !== item.id);
|
|
||||||
downloadedList.value = newList;
|
|
||||||
localStorage.setItem('downloadedList', JSON.stringify(newList));
|
|
||||||
message.success(t('download.delete.success'));
|
|
||||||
} else {
|
|
||||||
message.warning(t('download.delete.fileNotFound'));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to delete music:', error);
|
|
||||||
message.warning(t('download.delete.recordRemoved'));
|
|
||||||
} finally {
|
|
||||||
showDeleteConfirm.value = false;
|
|
||||||
itemToDelete.value = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 清空下载记录相关
|
|
||||||
const showClearConfirm = ref(false);
|
|
||||||
|
|
||||||
// 清空下载记录
|
|
||||||
const clearDownloadRecords = async () => {
|
|
||||||
try {
|
|
||||||
downloadedList.value = [];
|
|
||||||
localStorage.setItem('downloadedList', '[]');
|
|
||||||
await window.electron.ipcRenderer.invoke('clear-downloaded-music');
|
|
||||||
message.success(t('download.clear.success'));
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to clear download records:', error);
|
|
||||||
message.error(t('download.clear.failed'));
|
|
||||||
} finally {
|
|
||||||
showClearConfirm.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 播放音乐
|
|
||||||
// const handlePlay = async (musicInfo: SongResult) => {
|
|
||||||
// await playerStore.setPlay(musicInfo);
|
|
||||||
// playerStore.setPlayMusic(true);
|
|
||||||
// playerStore.setIsPlay(true);
|
|
||||||
// };
|
|
||||||
|
|
||||||
// 添加加载状态
|
|
||||||
const isLoadingDownloaded = ref(false);
|
|
||||||
|
|
||||||
// 获取已下载音乐列表
|
|
||||||
const refreshDownloadedList = async () => {
|
|
||||||
if (isLoadingDownloaded.value) return; // 防止重复加载
|
|
||||||
|
|
||||||
try {
|
|
||||||
isLoadingDownloaded.value = true;
|
|
||||||
const list = await window.electron.ipcRenderer.invoke('get-downloaded-music');
|
|
||||||
|
|
||||||
if (!Array.isArray(list) || list.length === 0) {
|
|
||||||
downloadedList.value = [];
|
|
||||||
localStorage.setItem('downloadedList', '[]');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const songIds = list.filter(item => item.id).map(item => item.id);
|
|
||||||
if (songIds.length === 0) {
|
|
||||||
downloadedList.value = list;
|
|
||||||
localStorage.setItem('downloadedList', JSON.stringify(list));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const detailRes = await getMusicDetail(songIds);
|
|
||||||
const songDetails = detailRes.data.songs.reduce((acc, song) => {
|
|
||||||
acc[song.id] = song;
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
const updatedList = list.map(item => ({
|
|
||||||
...item,
|
|
||||||
picUrl: songDetails[item.id]?.al?.picUrl || item.picUrl || '/images/default_cover.png',
|
|
||||||
ar: songDetails[item.id]?.ar || item.ar || [{ name: t('download.localMusic') }]
|
|
||||||
}));
|
|
||||||
|
|
||||||
downloadedList.value = updatedList;
|
|
||||||
localStorage.setItem('downloadedList', JSON.stringify(updatedList));
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to get music details:', error);
|
|
||||||
downloadedList.value = list;
|
|
||||||
localStorage.setItem('downloadedList', JSON.stringify(list));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to get downloaded music list:', error);
|
|
||||||
downloadedList.value = [];
|
|
||||||
localStorage.setItem('downloadedList', '[]');
|
|
||||||
} finally {
|
|
||||||
isLoadingDownloaded.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 监听抽屉显示状态
|
|
||||||
watch(
|
|
||||||
() => showDrawer.value,
|
|
||||||
(newVal) => {
|
|
||||||
if (newVal && !isLoadingDownloaded.value) {
|
|
||||||
refreshDownloadedList();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// 监听下载进度
|
// 监听下载进度
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
refreshDownloadedList();
|
|
||||||
|
|
||||||
// 监听下载进度
|
// 监听下载进度
|
||||||
window.electron.ipcRenderer.on('music-download-progress', (_, data) => {
|
window.electron.ipcRenderer.on('music-download-progress', (_, data) => {
|
||||||
const existingItem = downloadList.value.find((item) => item.filename === data.filename);
|
const existingItem = downloadList.value.find((item) => item.filename === data.filename);
|
||||||
@@ -501,9 +60,6 @@ onMounted(() => {
|
|||||||
window.electron.ipcRenderer.on('music-download-complete', async (_, data) => {
|
window.electron.ipcRenderer.on('music-download-complete', async (_, data) => {
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
downloadList.value = downloadList.value.filter(item => item.filename !== data.filename);
|
downloadList.value = downloadList.value.filter(item => item.filename !== data.filename);
|
||||||
// 延迟刷新已下载列表,避免文件系统未完全写入
|
|
||||||
setTimeout(() => refreshDownloadedList(), 500);
|
|
||||||
message.success(t('download.message.downloadComplete', { filename: data.filename }));
|
|
||||||
} else {
|
} else {
|
||||||
const existingItem = downloadList.value.find(item => item.filename === data.filename);
|
const existingItem = downloadList.value.find(item => item.filename === data.filename);
|
||||||
if (existingItem) {
|
if (existingItem) {
|
||||||
@@ -516,7 +72,6 @@ onMounted(() => {
|
|||||||
downloadList.value = downloadList.value.filter(item => item.filename !== data.filename);
|
downloadList.value = downloadList.value.filter(item => item.filename !== data.filename);
|
||||||
}, 3000);
|
}, 3000);
|
||||||
}
|
}
|
||||||
message.error(t('download.message.downloadFailed', { filename: data.filename, error: data.error }));
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -536,20 +91,6 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleDrawerClose = () => {
|
|
||||||
settingsStore.showDownloadDrawer = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => tabName.value,
|
|
||||||
(newVal) => {
|
|
||||||
if (newVal) {
|
|
||||||
refreshDownloadedList();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@@ -568,117 +109,4 @@ watch(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.drawer-container {
|
|
||||||
@apply h-full;
|
|
||||||
}
|
|
||||||
|
|
||||||
.download-list,
|
|
||||||
.downloaded-list {
|
|
||||||
@apply flex flex-col h-full;
|
|
||||||
|
|
||||||
.empty-tip {
|
|
||||||
@apply flex-1 flex items-center justify-center;
|
|
||||||
@apply text-gray-400 dark:text-gray-600;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.download-content,
|
|
||||||
.downloaded-content {
|
|
||||||
@apply flex-1 overflow-hidden pb-40;
|
|
||||||
}
|
|
||||||
|
|
||||||
.downloaded-header {
|
|
||||||
@apply flex items-center justify-between p-4 bg-light-100 dark:bg-dark-200 sticky top-0 z-10;
|
|
||||||
@apply border-b border-gray-100 dark:border-gray-800;
|
|
||||||
|
|
||||||
.header-title {
|
|
||||||
@apply text-sm font-medium text-gray-600 dark:text-gray-400;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.download-items,
|
|
||||||
.downloaded-items {
|
|
||||||
@apply space-y-3 p-4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.total-progress {
|
|
||||||
@apply px-4 py-3 bg-light-100 dark:bg-dark-200 backdrop-blur-sm;
|
|
||||||
@apply border-b border-gray-100 dark:border-gray-800;
|
|
||||||
@apply sticky top-0 z-10;
|
|
||||||
|
|
||||||
&-text {
|
|
||||||
@apply mb-2 text-sm font-medium text-gray-600 dark:text-gray-400;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.download-item,
|
|
||||||
.downloaded-item {
|
|
||||||
@apply p-3 rounded-lg;
|
|
||||||
@apply bg-light-100 dark:bg-dark-200 backdrop-blur-sm;
|
|
||||||
@apply border border-gray-100 dark:border-gray-700;
|
|
||||||
@apply transition-all duration-300;
|
|
||||||
@apply hover:bg-light-300 dark:hover:bg-dark-300;
|
|
||||||
@apply hover:shadow-md;
|
|
||||||
|
|
||||||
&-content {
|
|
||||||
@apply flex items-center gap-3;
|
|
||||||
}
|
|
||||||
|
|
||||||
&-cover {
|
|
||||||
@apply w-10 h-10 flex-shrink-0 rounded-lg overflow-hidden;
|
|
||||||
@apply shadow-md;
|
|
||||||
|
|
||||||
.cover-image {
|
|
||||||
@apply w-full h-full object-cover;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&-info {
|
|
||||||
@apply flex-1 min-w-0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&-name {
|
|
||||||
@apply text-sm font-medium truncate;
|
|
||||||
@apply text-gray-900 dark:text-gray-100;
|
|
||||||
}
|
|
||||||
|
|
||||||
&-artist {
|
|
||||||
@apply text-xs text-gray-500 dark:text-gray-400 truncate;
|
|
||||||
}
|
|
||||||
|
|
||||||
&-progress {
|
|
||||||
@apply mt-1;
|
|
||||||
}
|
|
||||||
|
|
||||||
&-size {
|
|
||||||
@apply text-xs text-gray-500 dark:text-gray-400 mt-1;
|
|
||||||
}
|
|
||||||
|
|
||||||
&-status {
|
|
||||||
@apply flex-shrink-0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.downloaded-item {
|
|
||||||
&-actions {
|
|
||||||
@apply flex items-center gap-1;
|
|
||||||
|
|
||||||
.n-button {
|
|
||||||
@apply p-2;
|
|
||||||
@apply hover:bg-gray-200/80 dark:hover:bg-gray-600/80;
|
|
||||||
@apply rounded-lg;
|
|
||||||
@apply transition-colors duration-300;
|
|
||||||
|
|
||||||
.iconfont {
|
|
||||||
@apply text-xl;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.delete-confirm-content {
|
|
||||||
@apply py-6 px-4;
|
|
||||||
@apply text-base text-gray-600 dark:text-gray-400;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -21,6 +21,18 @@ const otherRouter = [
|
|||||||
},
|
},
|
||||||
component: () => import('@/views/user/followers.vue')
|
component: () => import('@/views/user/followers.vue')
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/downloads',
|
||||||
|
name: 'downloads',
|
||||||
|
meta: {
|
||||||
|
title: '下载管理',
|
||||||
|
keepAlive: true,
|
||||||
|
showInMenu: true,
|
||||||
|
back: true,
|
||||||
|
icon: 'ri-download-cloud-2-line'
|
||||||
|
},
|
||||||
|
component: () => import('@/views/download/DownloadPage.vue')
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/user/detail/:uid',
|
path: '/user/detail/:uid',
|
||||||
name: 'userDetail',
|
name: 'userDetail',
|
||||||
|
|||||||
@@ -0,0 +1,998 @@
|
|||||||
|
<template>
|
||||||
|
<div class="download-page">
|
||||||
|
<div class="page-header">
|
||||||
|
<h1 class="page-title">{{ t('download.title') }}</h1>
|
||||||
|
<div class="segment-control">
|
||||||
|
<div
|
||||||
|
class="segment-item"
|
||||||
|
:class="{ 'active': tabName === 'downloading' }"
|
||||||
|
@click="tabName = 'downloading'"
|
||||||
|
>
|
||||||
|
{{ t('download.tabs.downloading') }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="segment-item"
|
||||||
|
:class="{ 'active': tabName === 'downloaded' }"
|
||||||
|
@click="tabName = 'downloaded'"
|
||||||
|
>
|
||||||
|
{{ t('download.tabs.downloaded') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="page-content">
|
||||||
|
<!-- 下载列表 -->
|
||||||
|
<div v-show="tabName === 'downloading'" class="tab-content">
|
||||||
|
<div class="download-list">
|
||||||
|
<div v-if="downloadList.length === 0" class="empty-state">
|
||||||
|
<div class="empty-icon">
|
||||||
|
<i class="iconfont ri-download-cloud-2-line"></i>
|
||||||
|
</div>
|
||||||
|
<h3 class="empty-title">{{ t('download.empty.noTasks') }}</h3>
|
||||||
|
</div>
|
||||||
|
<template v-else>
|
||||||
|
<div class="total-progress">
|
||||||
|
<div class="progress-header">
|
||||||
|
<div class="progress-title">
|
||||||
|
{{ t('download.progress.total', { progress: totalProgress.toFixed(1) }) }}
|
||||||
|
</div>
|
||||||
|
<div class="progress-info">
|
||||||
|
{{ downloadList.length }} {{ t('download.items') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="progress-bar-wrapper">
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div
|
||||||
|
class="progress-fill"
|
||||||
|
:style="{ width: `${totalProgress}%` }"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="download-items">
|
||||||
|
<div v-for="item in downloadList" :key="item.path" class="download-item">
|
||||||
|
<div class="item-left flex items-center gap-3">
|
||||||
|
<div class="item-cover">
|
||||||
|
<img :src="getImgUrl(item.songInfo?.picUrl, '200y200')" alt="Cover" />
|
||||||
|
</div>
|
||||||
|
<div class="item-info flex items-center gap-4 w-full">
|
||||||
|
<div class="item-name min-w-[160px] max-w-[160px] truncate" :title="item.filename">
|
||||||
|
{{ item.filename }}
|
||||||
|
</div>
|
||||||
|
<div class="item-artist min-w-[120px] max-w-[120px] truncate">
|
||||||
|
{{ item.songInfo?.ar?.map((a) => a.name).join(', ') || t('download.artist.unknown') }}
|
||||||
|
</div>
|
||||||
|
<div class="item-progress flex-1 min-w-0">
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div
|
||||||
|
class="progress-fill"
|
||||||
|
:class="[`status-${item.status}`]"
|
||||||
|
:style="{ width: `${item.progress}%` }"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="item-details min-w-[120px] max-w-[120px] flex flex-col items-end">
|
||||||
|
<span class="item-size">
|
||||||
|
{{ formatSize(item.loaded) }} / {{ formatSize(item.total) }}
|
||||||
|
</span>
|
||||||
|
<span class="item-status-badge" :class="[`status-${item.status}`]">
|
||||||
|
{{ getStatusText(item) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 已下载列表 -->
|
||||||
|
<div v-show="tabName === 'downloaded'" class="tab-content">
|
||||||
|
<div class="downloaded-list">
|
||||||
|
<div v-if="isLoadingDownloaded" class="loading-state">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<span class="loading-text">{{ t('download.loading') }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="downloadedList.length === 0" class="empty-state">
|
||||||
|
<div class="empty-icon">
|
||||||
|
<i class="iconfont ri-inbox-archive-line"></i>
|
||||||
|
</div>
|
||||||
|
<h3 class="empty-title">{{ t('download.empty.noDownloaded') }}</h3>
|
||||||
|
<p class="empty-text">{{ t('download.empty.noDownloadedHint') }}</p>
|
||||||
|
</div>
|
||||||
|
<template v-else>
|
||||||
|
<div class="downloaded-header">
|
||||||
|
<div class="header-info">
|
||||||
|
<i class="iconfont ri-archive-line"></i>
|
||||||
|
<span>{{ t('download.count', { count: downloadedList.length }) }}</span>
|
||||||
|
</div>
|
||||||
|
<button class="clear-button" @click="showClearConfirm = true">
|
||||||
|
<i class="iconfont ri-delete-bin-line"></i>
|
||||||
|
<span>{{ t('download.clearAll') }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="downloaded-items">
|
||||||
|
<div v-for="item in downList" :key="item.path" class="downloaded-item">
|
||||||
|
<div class="item-cover">
|
||||||
|
<img :src="getImgUrl(item.picUrl, '200y200')" alt="Cover" />
|
||||||
|
</div>
|
||||||
|
<div class="item-info flex items-center gap-4 w-full">
|
||||||
|
<div class="item-name min-w-[160px] max-w-[160px] truncate" :title="item.filename">
|
||||||
|
{{ item.filename }}
|
||||||
|
</div>
|
||||||
|
<div class="item-artist min-w-[120px] max-w-[120px] flex items-center gap-1 truncate">
|
||||||
|
<i class="iconfont ri-user-line"></i>
|
||||||
|
<span>{{ item.ar?.map((a) => a.name).join(', ') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="item-size min-w-[80px] max-w-[80px] flex items-center gap-1">
|
||||||
|
<i class="iconfont ri-file-line"></i>
|
||||||
|
<span>{{ formatSize(item.size) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="item-path min-w-[220px] max-w-[220px] flex items-center gap-1" :title="item.path">
|
||||||
|
<i class="iconfont ri-folder-path-line"></i>
|
||||||
|
<span>{{ shortenPath(item.path) }}</span>
|
||||||
|
<button class="copy-button" @click="copyPath(item.path)">
|
||||||
|
<i class="iconfont ri-file-copy-line"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="item-actions flex gap-1 ml-2">
|
||||||
|
<button class="action-btn play" @click="handlePlayMusic(item)">
|
||||||
|
<i class="iconfont ri-play-circle-line"></i>
|
||||||
|
</button>
|
||||||
|
<button class="action-btn open" @click="openDirectory(item.path)">
|
||||||
|
<i class="iconfont ri-folder-open-line"></i>
|
||||||
|
</button>
|
||||||
|
<button class="action-btn delete" @click="handleDelete(item)">
|
||||||
|
<i class="iconfont ri-delete-bin-line"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 删除确认对话框 -->
|
||||||
|
<div class="modal-overlay" v-if="showDeleteConfirm" @click="showDeleteConfirm = false">
|
||||||
|
<div class="modal-content" @click.stop>
|
||||||
|
<div class="modal-header">
|
||||||
|
<i class="iconfont ri-error-warning-line"></i>
|
||||||
|
<span>{{ t('download.delete.title') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
{{ t('download.delete.message', { filename: itemToDelete?.filename }) }}
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="modal-btn cancel" @click="showDeleteConfirm = false">
|
||||||
|
{{ t('download.delete.cancel') }}
|
||||||
|
</button>
|
||||||
|
<button class="modal-btn confirm" @click="confirmDelete">
|
||||||
|
{{ t('download.delete.confirm') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 清空确认对话框 -->
|
||||||
|
<div class="modal-overlay" v-if="showClearConfirm" @click="showClearConfirm = false">
|
||||||
|
<div class="modal-content" @click.stop>
|
||||||
|
<div class="modal-header">
|
||||||
|
<i class="iconfont ri-delete-bin-line"></i>
|
||||||
|
<span>{{ t('download.clear.title') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
{{ t('download.clear.message') }}
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="modal-btn cancel" @click="showClearConfirm = false">
|
||||||
|
{{ t('download.clear.cancel') }}
|
||||||
|
</button>
|
||||||
|
<button class="modal-btn confirm" @click="clearDownloadRecords">
|
||||||
|
{{ t('download.clear.confirm') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useMessage } from 'naive-ui';
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
import { getMusicDetail } from '@/api/music';
|
||||||
|
import { usePlayerStore } from '@/store/modules/player';
|
||||||
|
import { getImgUrl } from '@/utils';
|
||||||
|
import type { SongResult } from '@/type/music';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const playerStore = usePlayerStore();
|
||||||
|
const message = useMessage();
|
||||||
|
|
||||||
|
interface DownloadItem {
|
||||||
|
filename: string;
|
||||||
|
progress: number;
|
||||||
|
loaded: number;
|
||||||
|
total: number;
|
||||||
|
path: string;
|
||||||
|
status: 'downloading' | 'completed' | 'error';
|
||||||
|
error?: string;
|
||||||
|
songInfo?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DownloadedItem {
|
||||||
|
filename: string;
|
||||||
|
path: string;
|
||||||
|
size: number;
|
||||||
|
id: number;
|
||||||
|
picUrl: string;
|
||||||
|
ar: { name: string }[];
|
||||||
|
}
|
||||||
|
const tabName = ref('downloading');
|
||||||
|
|
||||||
|
const downloadList = ref<DownloadItem[]>([]);
|
||||||
|
const downloadedList = ref<DownloadedItem[]>(
|
||||||
|
JSON.parse(localStorage.getItem('downloadedList') || '[]')
|
||||||
|
);
|
||||||
|
|
||||||
|
const downList = computed(() => downloadedList.value);
|
||||||
|
|
||||||
|
// 计算总进度
|
||||||
|
const totalProgress = computed(() => {
|
||||||
|
if (downloadList.value.length === 0) return 0;
|
||||||
|
const total = downloadList.value.reduce((sum, item) => sum + item.progress, 0);
|
||||||
|
return total / downloadList.value.length;
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(totalProgress, (newVal) => {
|
||||||
|
if (newVal === 100) {
|
||||||
|
refreshDownloadedList();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取状态文本
|
||||||
|
const getStatusText = (item: DownloadItem) => {
|
||||||
|
switch (item.status) {
|
||||||
|
case 'downloading':
|
||||||
|
return t('download.status.downloading');
|
||||||
|
case 'completed':
|
||||||
|
return t('download.status.completed');
|
||||||
|
case 'error':
|
||||||
|
return t('download.status.failed');
|
||||||
|
default:
|
||||||
|
return t('download.status.unknown');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 格式化文件大小
|
||||||
|
const formatSize = (bytes: number) => {
|
||||||
|
if (!bytes) return '0 B';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return `${(bytes / k ** i).toFixed(1)} ${sizes[i]}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 复制文件路径
|
||||||
|
const copyPath = (path: string) => {
|
||||||
|
navigator.clipboard.writeText(path)
|
||||||
|
.then(() => {
|
||||||
|
message.success(t('download.path.copied'));
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error('复制失败:', err);
|
||||||
|
message.error(t('download.path.copyFailed'));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 格式化路径
|
||||||
|
const shortenPath = (path: string) => {
|
||||||
|
if (!path) return '';
|
||||||
|
|
||||||
|
// 获取文件名和目录
|
||||||
|
const parts = path.split(/[/\\]/);
|
||||||
|
const fileName = parts.pop() || '';
|
||||||
|
|
||||||
|
// 如果路径很短,直接返回
|
||||||
|
if (path.length < 30) return path;
|
||||||
|
|
||||||
|
// 保留开头的部分目录和结尾的文件名
|
||||||
|
if (parts.length <= 2) return path;
|
||||||
|
|
||||||
|
const start = parts.slice(0, 1).join('/');
|
||||||
|
const end = parts.slice(-1).join('/');
|
||||||
|
|
||||||
|
return `${start}/.../${end}/${fileName}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取本地文件URL
|
||||||
|
const getLocalFilePath = (path: string) => {
|
||||||
|
if (!path) return '';
|
||||||
|
// 确保URL格式正确
|
||||||
|
return `local:///${encodeURIComponent(path)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 打开目录
|
||||||
|
const openDirectory = (path: string) => {
|
||||||
|
window.electron.ipcRenderer.send('open-directory', path);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 播放音乐
|
||||||
|
const handlePlayMusic = async (item: DownloadedItem) => {
|
||||||
|
try {
|
||||||
|
// 先检查文件是否存在
|
||||||
|
const fileExists = await window.electron.ipcRenderer.invoke('check-file-exists', item.path);
|
||||||
|
|
||||||
|
if (!fileExists) {
|
||||||
|
message.error(t('download.delete.fileNotFound', { name: item.filename }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换下载项为播放所需的歌曲对象
|
||||||
|
const song: SongResult = {
|
||||||
|
id: item.id,
|
||||||
|
name: item.filename,
|
||||||
|
ar: item.ar?.map(a => ({
|
||||||
|
id: 0,
|
||||||
|
name: a.name,
|
||||||
|
picId: 0,
|
||||||
|
img1v1Id: 0,
|
||||||
|
briefDesc: '',
|
||||||
|
picUrl: '',
|
||||||
|
img1v1Url: '',
|
||||||
|
albumSize: 0,
|
||||||
|
alias: [],
|
||||||
|
trans: '',
|
||||||
|
musicSize: 0,
|
||||||
|
topicPerson: 0
|
||||||
|
})) || [],
|
||||||
|
al: {
|
||||||
|
name: item.filename,
|
||||||
|
id: 0,
|
||||||
|
picUrl: item.picUrl,
|
||||||
|
pic: 0,
|
||||||
|
picId: 0
|
||||||
|
} as any,
|
||||||
|
picUrl: item.picUrl,
|
||||||
|
// 使用本地文件协议
|
||||||
|
playMusicUrl: getLocalFilePath(item.path),
|
||||||
|
source: 'netease' as 'netease',
|
||||||
|
count: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('开始播放本地音乐:', song.name, '路径:', song.playMusicUrl);
|
||||||
|
|
||||||
|
// 播放歌曲
|
||||||
|
await playerStore.setPlay(song);
|
||||||
|
playerStore.setPlayMusic(true);
|
||||||
|
playerStore.setIsPlay(true);
|
||||||
|
|
||||||
|
message.success(t('download.playStarted', { name: item.filename }));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('播放音乐失败:', error);
|
||||||
|
message.error(t('download.playFailed', { name: item.filename }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 删除相关
|
||||||
|
const showDeleteConfirm = ref(false);
|
||||||
|
const itemToDelete = ref<DownloadedItem | null>(null);
|
||||||
|
|
||||||
|
// 处理删除点击
|
||||||
|
const handleDelete = (item: DownloadedItem) => {
|
||||||
|
itemToDelete.value = item;
|
||||||
|
showDeleteConfirm.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 确认删除
|
||||||
|
const confirmDelete = async () => {
|
||||||
|
const item = itemToDelete.value;
|
||||||
|
if (!item) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const success = await window.electron.ipcRenderer.invoke(
|
||||||
|
'delete-downloaded-music',
|
||||||
|
item.path
|
||||||
|
);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
const newList = downloadedList.value.filter(i => i.id !== item.id);
|
||||||
|
downloadedList.value = newList;
|
||||||
|
localStorage.setItem('downloadedList', JSON.stringify(newList));
|
||||||
|
message.success(t('download.delete.success'));
|
||||||
|
} else {
|
||||||
|
message.warning(t('download.delete.fileNotFound'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete music:', error);
|
||||||
|
message.warning(t('download.delete.recordRemoved'));
|
||||||
|
} finally {
|
||||||
|
showDeleteConfirm.value = false;
|
||||||
|
itemToDelete.value = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 清空下载记录相关
|
||||||
|
const showClearConfirm = ref(false);
|
||||||
|
|
||||||
|
// 清空下载记录
|
||||||
|
const clearDownloadRecords = async () => {
|
||||||
|
try {
|
||||||
|
downloadedList.value = [];
|
||||||
|
localStorage.setItem('downloadedList', '[]');
|
||||||
|
await window.electron.ipcRenderer.invoke('clear-downloaded-music');
|
||||||
|
message.success(t('download.clear.success'));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to clear download records:', error);
|
||||||
|
message.error(t('download.clear.failed'));
|
||||||
|
} finally {
|
||||||
|
showClearConfirm.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 添加加载状态
|
||||||
|
const isLoadingDownloaded = ref(false);
|
||||||
|
|
||||||
|
// 获取已下载音乐列表
|
||||||
|
const refreshDownloadedList = async () => {
|
||||||
|
if (isLoadingDownloaded.value) return; // 防止重复加载
|
||||||
|
|
||||||
|
try {
|
||||||
|
isLoadingDownloaded.value = true;
|
||||||
|
const list = await window.electron.ipcRenderer.invoke('get-downloaded-music');
|
||||||
|
|
||||||
|
if (!Array.isArray(list) || list.length === 0) {
|
||||||
|
downloadedList.value = [];
|
||||||
|
localStorage.setItem('downloadedList', '[]');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const songIds = list.filter(item => item.id).map(item => item.id);
|
||||||
|
if (songIds.length === 0) {
|
||||||
|
downloadedList.value = list;
|
||||||
|
localStorage.setItem('downloadedList', JSON.stringify(list));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const detailRes = await getMusicDetail(songIds);
|
||||||
|
const songDetails = detailRes.data.songs.reduce((acc, song) => {
|
||||||
|
acc[song.id] = song;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
const updatedList = list.map(item => ({
|
||||||
|
...item,
|
||||||
|
picUrl: songDetails[item.id]?.al?.picUrl || item.picUrl || '/images/default_cover.png',
|
||||||
|
ar: songDetails[item.id]?.ar || item.ar || [{ name: t('download.localMusic') }]
|
||||||
|
}));
|
||||||
|
|
||||||
|
downloadedList.value = updatedList;
|
||||||
|
localStorage.setItem('downloadedList', JSON.stringify(updatedList));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get music details:', error);
|
||||||
|
downloadedList.value = list;
|
||||||
|
localStorage.setItem('downloadedList', JSON.stringify(list));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get downloaded music list:', error);
|
||||||
|
downloadedList.value = [];
|
||||||
|
localStorage.setItem('downloadedList', '[]');
|
||||||
|
} finally {
|
||||||
|
isLoadingDownloaded.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => tabName.value,
|
||||||
|
(newVal) => {
|
||||||
|
if (newVal) {
|
||||||
|
refreshDownloadedList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 监听下载进度
|
||||||
|
onMounted(() => {
|
||||||
|
refreshDownloadedList();
|
||||||
|
|
||||||
|
// 监听下载进度
|
||||||
|
window.electron.ipcRenderer.on('music-download-progress', (_, data) => {
|
||||||
|
const existingItem = downloadList.value.find((item) => item.filename === data.filename);
|
||||||
|
|
||||||
|
// 如果进度为100%,将状态设置为已完成
|
||||||
|
if (data.progress === 100) {
|
||||||
|
data.status = 'completed';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingItem) {
|
||||||
|
Object.assign(existingItem, {
|
||||||
|
...data,
|
||||||
|
songInfo: data.songInfo || existingItem.songInfo
|
||||||
|
});
|
||||||
|
|
||||||
|
// 如果下载完成,从列表中移除
|
||||||
|
if (data.status === 'completed') {
|
||||||
|
downloadList.value = downloadList.value.filter((item) => item.filename !== data.filename);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
downloadList.value.push({
|
||||||
|
...data,
|
||||||
|
songInfo: data.songInfo
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听下载完成
|
||||||
|
window.electron.ipcRenderer.on('music-download-complete', async (_, data) => {
|
||||||
|
if (data.success) {
|
||||||
|
downloadList.value = downloadList.value.filter(item => item.filename !== data.filename);
|
||||||
|
// 延迟刷新已下载列表,避免文件系统未完全写入
|
||||||
|
setTimeout(() => refreshDownloadedList(), 500);
|
||||||
|
message.success(t('download.message.downloadComplete', { filename: data.filename }));
|
||||||
|
} else {
|
||||||
|
const existingItem = downloadList.value.find(item => item.filename === data.filename);
|
||||||
|
if (existingItem) {
|
||||||
|
Object.assign(existingItem, {
|
||||||
|
status: 'error',
|
||||||
|
error: data.error,
|
||||||
|
progress: 0
|
||||||
|
});
|
||||||
|
setTimeout(() => {
|
||||||
|
downloadList.value = downloadList.value.filter(item => item.filename !== data.filename);
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
message.error(t('download.message.downloadFailed', { filename: data.filename, error: data.error }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听下载队列
|
||||||
|
window.electron.ipcRenderer.on('music-download-queued', (_, data) => {
|
||||||
|
const existingItem = downloadList.value.find((item) => item.filename === data.filename);
|
||||||
|
if (!existingItem) {
|
||||||
|
downloadList.value.push({
|
||||||
|
filename: data.filename,
|
||||||
|
progress: 0,
|
||||||
|
loaded: 0,
|
||||||
|
total: 0,
|
||||||
|
path: '',
|
||||||
|
status: 'downloading',
|
||||||
|
songInfo: data.songInfo
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
/* macOS style with Neumorphism */
|
||||||
|
.download-page {
|
||||||
|
@apply h-full w-full flex flex-col overflow-hidden;
|
||||||
|
@apply bg-gray-50 dark:bg-dark-200;
|
||||||
|
@apply text-gray-900 dark:text-gray-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
@apply px-4 py-3;
|
||||||
|
@apply bg-white/90 dark:bg-dark-100/90;
|
||||||
|
@apply border-b border-gray-200/50 dark:border-gray-700/50;
|
||||||
|
@apply backdrop-blur-xl backdrop-saturate-150;
|
||||||
|
@apply sticky top-0 z-10;
|
||||||
|
@apply flex items-center justify-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
@apply text-lg font-medium;
|
||||||
|
@apply text-gray-800 dark:text-gray-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segment-control {
|
||||||
|
@apply flex rounded-lg overflow-hidden;
|
||||||
|
@apply bg-gray-100 dark:bg-dark-300;
|
||||||
|
@apply w-max;
|
||||||
|
@apply border border-gray-200/60 dark:border-gray-700/60;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segment-item {
|
||||||
|
@apply px-3 py-1 cursor-pointer text-sm font-medium;
|
||||||
|
@apply text-gray-500 dark:text-gray-400;
|
||||||
|
@apply transition-all duration-200;
|
||||||
|
@apply hover:bg-light-300 dark:hover:bg-dark-300;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
@apply bg-white dark:bg-dark-200;
|
||||||
|
@apply text-gray-900 dark:text-gray-100;
|
||||||
|
@apply shadow-sm;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-content {
|
||||||
|
@apply flex-1 overflow-hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
@apply h-full overflow-auto pb-16;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty & Loading States */
|
||||||
|
.empty-state,
|
||||||
|
.loading-state {
|
||||||
|
@apply flex flex-col items-center justify-center h-full;
|
||||||
|
@apply py-16;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
@apply text-4xl mb-3 text-gray-300 dark:text-gray-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-title {
|
||||||
|
@apply text-base font-medium mb-1;
|
||||||
|
@apply text-gray-500 dark:text-gray-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-text {
|
||||||
|
@apply text-xs text-gray-400 dark:text-gray-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
@apply w-8 h-8 rounded-full border border-t-primary;
|
||||||
|
@apply animate-spin mb-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-text {
|
||||||
|
@apply text-sm text-gray-500 dark:text-gray-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress Bar */
|
||||||
|
.total-progress {
|
||||||
|
@apply px-4 py-3;
|
||||||
|
@apply bg-white/90 dark:bg-dark-100/90;
|
||||||
|
@apply backdrop-blur-lg backdrop-saturate-150;
|
||||||
|
@apply border-b border-gray-200/50 dark:border-gray-700/50;
|
||||||
|
@apply sticky top-0 z-10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-header {
|
||||||
|
@apply flex justify-between items-center mb-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-title {
|
||||||
|
@apply text-xs font-medium;
|
||||||
|
@apply text-gray-700 dark:text-gray-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-info {
|
||||||
|
@apply text-xs;
|
||||||
|
@apply text-gray-500 dark:text-gray-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar-wrapper {
|
||||||
|
@apply w-full;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
@apply h-1.5 rounded-full w-full;
|
||||||
|
@apply bg-gray-200 dark:bg-dark-300;
|
||||||
|
@apply overflow-hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
@apply h-full rounded-full;
|
||||||
|
@apply bg-primary;
|
||||||
|
@apply transition-all duration-300;
|
||||||
|
|
||||||
|
&.status-downloading {
|
||||||
|
@apply bg-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.status-completed {
|
||||||
|
@apply bg-green-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.status-error {
|
||||||
|
@apply bg-red-500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Download Items */
|
||||||
|
.download-items {
|
||||||
|
@apply p-4 space-y-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-item {
|
||||||
|
@apply rounded-lg p-3;
|
||||||
|
@apply bg-white dark:bg-dark-100;
|
||||||
|
@apply border border-gray-200/60 dark:border-gray-700/50;
|
||||||
|
@apply shadow-sm;
|
||||||
|
@apply transition-all duration-300;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
@apply shadow-md;
|
||||||
|
@apply transform -translate-y-0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-left {
|
||||||
|
@apply flex gap-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-cover {
|
||||||
|
@apply w-12 h-12 rounded-md overflow-hidden;
|
||||||
|
@apply flex-shrink-0;
|
||||||
|
@apply bg-gray-100 dark:bg-dark-300;
|
||||||
|
@apply shadow-sm;
|
||||||
|
|
||||||
|
img {
|
||||||
|
@apply w-full h-full object-cover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-info {
|
||||||
|
@apply flex-1 min-w-0 flex items-center justify-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-name {
|
||||||
|
@apply text-sm font-medium truncate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-artist {
|
||||||
|
@apply text-xs text-gray-500 dark:text-gray-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-progress {
|
||||||
|
@apply mb-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-details {
|
||||||
|
@apply flex justify-between items-center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-size {
|
||||||
|
@apply text-xs text-gray-500 dark:text-gray-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-status-badge {
|
||||||
|
@apply text-xs px-2 py-0.5 rounded-full;
|
||||||
|
@apply bg-gray-100 dark:bg-dark-300;
|
||||||
|
|
||||||
|
&.status-downloading {
|
||||||
|
@apply bg-blue-50 text-blue-600 dark:bg-blue-900/20 dark:text-blue-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.status-completed {
|
||||||
|
@apply bg-green-50 text-green-600 dark:bg-green-900/20 dark:text-green-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.status-error {
|
||||||
|
@apply bg-red-50 text-red-600 dark:bg-red-900/20 dark:text-red-400;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Downloaded List */
|
||||||
|
.downloaded-header {
|
||||||
|
@apply px-4 py-3;
|
||||||
|
@apply bg-white/90 dark:bg-dark-100/90;
|
||||||
|
@apply backdrop-blur-lg backdrop-saturate-150;
|
||||||
|
@apply border-b border-gray-200/50 dark:border-gray-700/50;
|
||||||
|
@apply sticky top-0 z-10;
|
||||||
|
@apply flex items-center justify-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-info {
|
||||||
|
@apply flex items-center gap-2;
|
||||||
|
@apply text-xs font-medium;
|
||||||
|
@apply text-gray-700 dark:text-gray-300;
|
||||||
|
|
||||||
|
i {
|
||||||
|
@apply text-gray-400 dark:text-gray-500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-button {
|
||||||
|
@apply flex items-center gap-1;
|
||||||
|
@apply px-2 py-1 rounded-md;
|
||||||
|
@apply text-xs font-medium;
|
||||||
|
@apply bg-gray-100 dark:bg-dark-300;
|
||||||
|
@apply text-gray-700 dark:text-gray-300;
|
||||||
|
@apply hover:bg-gray-200 dark:hover:bg-dark-300;
|
||||||
|
@apply transition-colors duration-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloaded-items {
|
||||||
|
@apply p-4 space-y-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloaded-item {
|
||||||
|
@apply p-3 rounded-lg;
|
||||||
|
@apply bg-white dark:bg-dark-100;
|
||||||
|
@apply border border-gray-200/60 dark:border-gray-700/50;
|
||||||
|
@apply shadow-sm;
|
||||||
|
@apply flex gap-3;
|
||||||
|
@apply transition-all duration-300;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
@apply shadow-md;
|
||||||
|
@apply transform -translate-y-0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-cover {
|
||||||
|
@apply w-14 h-14 rounded-md overflow-hidden;
|
||||||
|
@apply flex-shrink-0;
|
||||||
|
@apply bg-gray-100 dark:bg-dark-300;
|
||||||
|
@apply shadow-sm;
|
||||||
|
|
||||||
|
img {
|
||||||
|
@apply w-full h-full object-cover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-info {
|
||||||
|
@apply flex-1 flex justify-between items-center;
|
||||||
|
@apply min-w-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-primary {
|
||||||
|
@apply flex-1 min-w-0 flex items-center justify-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-name {
|
||||||
|
@apply text-sm font-medium truncate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-artist,
|
||||||
|
.item-size {
|
||||||
|
@apply flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400;
|
||||||
|
|
||||||
|
i {
|
||||||
|
@apply text-gray-400 dark:text-gray-500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-path {
|
||||||
|
@apply flex items-center gap-1 text-xs;
|
||||||
|
@apply text-gray-500 dark:text-gray-400;
|
||||||
|
@apply bg-gray-50 dark:bg-dark-300;
|
||||||
|
@apply rounded-md py-1 px-2;
|
||||||
|
@apply truncate;
|
||||||
|
|
||||||
|
i {
|
||||||
|
@apply text-gray-400 dark:text-gray-500 flex-shrink-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
@apply truncate flex-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-button {
|
||||||
|
@apply ml-1 opacity-0;
|
||||||
|
@apply text-gray-400 hover:text-primary;
|
||||||
|
@apply transition-all duration-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover .copy-button {
|
||||||
|
@apply opacity-100;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-actions {
|
||||||
|
@apply flex gap-1;
|
||||||
|
@apply ml-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
@apply flex items-center gap-1;
|
||||||
|
@apply px-2 py-1 rounded-md;
|
||||||
|
@apply text-xs;
|
||||||
|
@apply transition-colors duration-200;
|
||||||
|
|
||||||
|
&.play {
|
||||||
|
@apply text-primary dark:text-white;
|
||||||
|
@apply hover:bg-gray-100 dark:hover:bg-dark-300 hover:text-green-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.open {
|
||||||
|
@apply text-gray-600 dark:text-gray-300;
|
||||||
|
@apply hover:bg-gray-100 dark:hover:bg-dark-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.delete {
|
||||||
|
@apply text-red-500;
|
||||||
|
@apply hover:bg-red-500/10;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal-overlay {
|
||||||
|
@apply fixed inset-0 z-50;
|
||||||
|
@apply bg-black/40 backdrop-blur-sm;
|
||||||
|
@apply flex items-center justify-center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
@apply bg-white dark:bg-dark-100;
|
||||||
|
@apply rounded-lg overflow-hidden;
|
||||||
|
@apply shadow-xl;
|
||||||
|
@apply w-full max-w-sm;
|
||||||
|
@apply border border-gray-200/60 dark:border-gray-700/50;
|
||||||
|
@apply animate-fade-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
@apply flex items-center gap-2;
|
||||||
|
@apply px-4 py-3;
|
||||||
|
@apply border-b border-gray-100 dark:border-gray-800;
|
||||||
|
@apply text-gray-900 dark:text-gray-100;
|
||||||
|
@apply font-medium;
|
||||||
|
|
||||||
|
i {
|
||||||
|
@apply text-amber-500 dark:text-amber-400;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
@apply px-4 py-4;
|
||||||
|
@apply text-sm text-gray-700 dark:text-gray-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
@apply px-4 py-3;
|
||||||
|
@apply flex justify-end gap-2;
|
||||||
|
@apply border-t border-gray-100 dark:border-gray-800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-btn {
|
||||||
|
@apply px-3 py-1.5 rounded-md;
|
||||||
|
@apply text-sm font-medium;
|
||||||
|
@apply transition-colors duration-200;
|
||||||
|
|
||||||
|
&.cancel {
|
||||||
|
@apply bg-gray-100 dark:bg-dark-300;
|
||||||
|
@apply text-gray-700 dark:text-gray-300;
|
||||||
|
@apply hover:bg-gray-200 dark:hover:bg-dark-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.confirm {
|
||||||
|
@apply bg-amber-500 dark:bg-amber-600;
|
||||||
|
@apply text-white;
|
||||||
|
@apply hover:bg-amber-600 dark:hover:bg-amber-700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fade-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-fade-in {
|
||||||
|
animation: fade-in 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-spin {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user