mirror of
https://github.com/algerkong/AlgerMusicPlayer.git
synced 2026-04-16 07:50:50 +08:00
feat: 添加下载管理页面, 引入文件类型检测库以支持多种音频格式
This commit is contained in:
@@ -30,8 +30,10 @@
|
||||
"electron-updater": "^6.6.2",
|
||||
"electron-window-state": "^5.0.3",
|
||||
"express": "^4.18.2",
|
||||
"file-type": "^21.0.0",
|
||||
"font-list": "^1.5.1",
|
||||
"husky": "^9.1.7",
|
||||
"music-metadata": "^11.2.3",
|
||||
"netease-cloud-music-api-alger": "^4.26.1",
|
||||
"node-id3": "^0.2.9",
|
||||
"node-machine-id": "^1.1.12",
|
||||
|
||||
@@ -45,5 +45,11 @@ export default {
|
||||
downloadComplete: '{filename} download completed',
|
||||
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} 下载完成',
|
||||
downloadFailed: '{filename} 下载失败: {error}'
|
||||
},
|
||||
loading: '加载中...'
|
||||
loading: '加载中...',
|
||||
playStarted: '开始播放: {name}',
|
||||
playFailed: '播放失败: {name}',
|
||||
path: {
|
||||
copied: '路径已复制到剪贴板',
|
||||
copyFailed: '复制路径失败'
|
||||
}
|
||||
};
|
||||
|
||||
@@ -6,6 +6,11 @@ import * as http from 'http';
|
||||
import * as https from 'https';
|
||||
import * as NodeID3 from 'node-id3';
|
||||
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';
|
||||
|
||||
@@ -36,9 +41,18 @@ export function initializeFileManager() {
|
||||
// 注册本地文件协议
|
||||
protocol.registerFileProtocol('local', (request, callback) => {
|
||||
try {
|
||||
const decodedUrl = decodeURIComponent(request.url);
|
||||
const filePath = decodedUrl.replace('local://', '');
|
||||
|
||||
let url = request.url;
|
||||
// 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)) {
|
||||
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 () => {
|
||||
const result = await dialog.showOpenDialog({
|
||||
@@ -311,6 +350,7 @@ async function downloadMusic(
|
||||
) {
|
||||
let finalFilePath = '';
|
||||
let writer: fs.WriteStream | null = null;
|
||||
let tempFilePath = '';
|
||||
|
||||
try {
|
||||
// 使用配置Store来获取设置
|
||||
@@ -322,25 +362,21 @@ async function downloadMusic(
|
||||
// 清理文件名中的非法字符
|
||||
const sanitizedFilename = sanitizeFilename(filename);
|
||||
|
||||
// 从URL中获取文件扩展名,如果没有则使用传入的type或默认mp3
|
||||
const urlExt = type ? `.${type}` : '.mp3';
|
||||
const filePath = path.join(downloadPath, `${sanitizedFilename}${urlExt}`);
|
||||
|
||||
// 检查文件是否已存在,如果存在则添加序号
|
||||
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++;
|
||||
// 创建临时文件路径 (在系统临时目录中创建)
|
||||
const tempDir = path.join(os.tmpdir(), 'AlgerMusicPlayerTemp');
|
||||
|
||||
// 确保临时目录存在
|
||||
if (!fs.existsSync(tempDir)) {
|
||||
fs.mkdirSync(tempDir, { recursive: true });
|
||||
}
|
||||
|
||||
tempFilePath = path.join(tempDir, `${Date.now()}_${sanitizedFilename}.tmp`);
|
||||
|
||||
// 先获取文件大小
|
||||
const headResponse = await axios.head(url);
|
||||
const totalSize = parseInt(headResponse.headers['content-length'] || '0', 10);
|
||||
|
||||
// 开始下载
|
||||
// 开始下载到临时文件
|
||||
const response = await axios({
|
||||
url,
|
||||
method: 'GET',
|
||||
@@ -350,7 +386,7 @@ async function downloadMusic(
|
||||
httpsAgent: new https.Agent({ keepAlive: true })
|
||||
});
|
||||
|
||||
writer = fs.createWriteStream(finalFilePath);
|
||||
writer = fs.createWriteStream(tempFilePath);
|
||||
let downloadedSize = 0;
|
||||
|
||||
// 使用 data 事件来跟踪下载进度
|
||||
@@ -362,7 +398,7 @@ async function downloadMusic(
|
||||
progress,
|
||||
loaded: downloadedSize,
|
||||
total: totalSize,
|
||||
path: finalFilePath,
|
||||
path: tempFilePath,
|
||||
status: progress === 100 ? 'completed' : 'downloading',
|
||||
songInfo: songInfo || {
|
||||
name: filename,
|
||||
@@ -380,11 +416,77 @@ async function downloadMusic(
|
||||
});
|
||||
|
||||
// 验证文件是否完整下载
|
||||
const stats = fs.statSync(finalFilePath);
|
||||
const stats = fs.statSync(tempFilePath);
|
||||
if (stats.size !== totalSize) {
|
||||
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 lyricsContent = '';
|
||||
@@ -413,8 +515,7 @@ async function downloadMusic(
|
||||
}
|
||||
}
|
||||
|
||||
// 不再单独写入歌词文件,只保存在ID3标签中
|
||||
console.log('歌词已准备好,将写入ID3标签');
|
||||
console.log('歌词已准备好,将写入元数据');
|
||||
}
|
||||
}
|
||||
} catch (lyricError) {
|
||||
@@ -437,9 +538,7 @@ async function downloadMusic(
|
||||
|
||||
// 获取封面图片的buffer
|
||||
coverImageBuffer = Buffer.from(coverResponse.data);
|
||||
|
||||
// 不再单独保存封面文件,只保存在ID3标签中
|
||||
console.log('封面已准备好,将写入ID3标签');
|
||||
console.log('封面已准备好,将写入元数据');
|
||||
}
|
||||
}
|
||||
} catch (coverError) {
|
||||
@@ -447,54 +546,58 @@ async function downloadMusic(
|
||||
// 继续处理,不影响音乐下载
|
||||
}
|
||||
|
||||
// 在写入ID3标签前,先移除可能存在的旧标签
|
||||
try {
|
||||
NodeID3.removeTags(finalFilePath);
|
||||
} catch (err) {
|
||||
console.error('Error removing existing ID3 tags:', err);
|
||||
}
|
||||
|
||||
// 强化ID3标签的写入格式
|
||||
|
||||
const fileFormat = fileExtension.toLowerCase();
|
||||
const artistNames =
|
||||
(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 (!success) {
|
||||
console.error('Failed to write ID3 tags');
|
||||
} else {
|
||||
console.log('ID3 tags written successfully');
|
||||
// 根据文件类型处理元数据
|
||||
if (['.mp3'].includes(fileFormat)) {
|
||||
// 对MP3文件使用NodeID3处理ID3标签
|
||||
try {
|
||||
// 在写入ID3标签前,先移除可能存在的旧标签
|
||||
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) {
|
||||
console.error('Error writing ID3 tags:', err);
|
||||
} else {
|
||||
// 对于非MP3文件,使用music-metadata来写入元数据可能需要专门的库
|
||||
// 或者根据不同文件类型使用专用工具,暂时只记录但不处理
|
||||
console.log(`文件类型 ${fileFormat} 不支持使用NodeID3写入标签,跳过元数据写入`);
|
||||
}
|
||||
|
||||
// 保存下载信息
|
||||
@@ -519,7 +622,7 @@ async function downloadMusic(
|
||||
size: totalSize,
|
||||
path: finalFilePath,
|
||||
downloadTime: Date.now(),
|
||||
type: type || 'mp3',
|
||||
type: fileExtension.substring(1), // 去掉前面的点号,只保留扩展名
|
||||
lyric: lyricData
|
||||
};
|
||||
|
||||
@@ -571,6 +674,17 @@ async function downloadMusic(
|
||||
if (writer) {
|
||||
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)) {
|
||||
try {
|
||||
fs.unlinkSync(finalFilePath);
|
||||
|
||||
@@ -1,475 +1,34 @@
|
||||
<template>
|
||||
<div class="download-drawer-trigger">
|
||||
<n-badge :value="downloadingCount" :max="99" :show="downloadingCount > 0">
|
||||
<n-button circle @click="settingsStore.showDownloadDrawer = true">
|
||||
<n-button circle @click="navigateToDownloads">
|
||||
<template #icon>
|
||||
<i class="iconfont ri-download-cloud-2-line"></i>
|
||||
</template>
|
||||
</n-button>
|
||||
</n-badge>
|
||||
</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>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ProgressStatus } from 'naive-ui';
|
||||
import { useMessage } from 'naive-ui';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { getMusicDetail } from '@/api/music';
|
||||
// import { usePlayerStore } from '@/store/modules/player';
|
||||
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 router = useRouter();
|
||||
const downloadList = ref<any[]>([]);
|
||||
|
||||
// 计算下载中的任务数量
|
||||
const downloadingCount = computed(() => {
|
||||
return downloadList.value.filter((item) => item.status === 'downloading').length;
|
||||
});
|
||||
|
||||
// 计算总进度
|
||||
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 getStatusType = (item: DownloadItem) => {
|
||||
switch (item.status) {
|
||||
case 'downloading':
|
||||
return 'info';
|
||||
case 'completed':
|
||||
return 'success';
|
||||
case 'error':
|
||||
return 'error';
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
// 导航到下载页面
|
||||
const navigateToDownloads = () => {
|
||||
router.push('/downloads');
|
||||
};
|
||||
|
||||
// 获取状态文本
|
||||
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(() => {
|
||||
refreshDownloadedList();
|
||||
|
||||
// 监听下载进度
|
||||
window.electron.ipcRenderer.on('music-download-progress', (_, data) => {
|
||||
const existingItem = downloadList.value.find((item) => item.filename === data.filename);
|
||||
@@ -501,9 +60,6 @@ onMounted(() => {
|
||||
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) {
|
||||
@@ -516,7 +72,6 @@ onMounted(() => {
|
||||
downloadList.value = downloadList.value.filter(item => item.filename !== data.filename);
|
||||
}, 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>
|
||||
|
||||
<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>
|
||||
|
||||
@@ -21,6 +21,18 @@ const otherRouter = [
|
||||
},
|
||||
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',
|
||||
name: 'userDetail',
|
||||
|
||||
998
src/renderer/views/download/DownloadPage.vue
Normal file
998
src/renderer/views/download/DownloadPage.vue
Normal file
@@ -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