feat: 添加下载设置功能,支持自定义文件名格式和下载路径配置

- 新增下载设置抽屉,允许用户设置下载路径和文件名格式
- 支持多种文件名格式预设和自定义格式
- 实现下载项的显示名称格式化
- 优化下载管理逻辑,避免重复通知
This commit is contained in:
alger
2025-06-05 23:02:41 +08:00
parent a08fbf1ec8
commit b203077cad
5 changed files with 784 additions and 101 deletions
+464 -32
View File
@@ -2,20 +2,26 @@
<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 class="flex items-center gap-3">
<n-button size="small" @click="showSettingsDrawer = true">
<template #icon><i class="iconfont ri-settings-3-line"></i></template>
{{ t('download.settings') }}
</n-button>
<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>
@@ -120,8 +126,8 @@
<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 class="item-name min-w-[160px] max-w-[160px] truncate" :title="item.displayName || item.filename">
{{ item.displayName || 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>
@@ -166,7 +172,7 @@
<span>{{ t('download.delete.title') }}</span>
</div>
<div class="modal-body">
{{ t('download.delete.message', { filename: itemToDelete?.filename }) }}
{{ t('download.delete.message', { filename: itemToDelete?.displayName || itemToDelete?.filename }) }}
</div>
<div class="modal-footer">
<button class="modal-btn cancel" @click="showDeleteConfirm = false">
@@ -199,6 +205,164 @@
</div>
</div>
</div>
<!-- 下载设置抽屉 -->
<n-drawer v-model:show="showSettingsDrawer" :width="380" placement="right" :z-index="999999999">
<n-drawer-content :native-scrollbar="false">
<template #header>
<div class="flex items-center justify-between">
<div class="text-lg font-bold">{{ t('download.settingsPanel.title') }}</div>
<n-button type="primary" @click="saveDownloadSettings">
{{ t('common.save') }}
</n-button>
</div>
</template>
<div class="download-settings">
<!-- 下载路径设置 -->
<div class="setting-item">
<div class="setting-title">{{ t('download.settingsPanel.path') }}</div>
<div class="setting-desc">{{ t('download.settingsPanel.pathDesc') }}</div>
<div class="flex flex-col gap-2 mt-2">
<n-input
v-model:value="downloadSettings.path"
:placeholder="t('download.settingsPanel.pathPlaceholder')"
readonly
class="flex-1"
/>
<div class="flex items-center gap-2">
<n-button class="flex-1" @click="selectDownloadPath">{{ t('download.settingsPanel.select') }}</n-button>
<n-button class="flex-1" @click="openDownloadPath">
{{ t('download.settingsPanel.open') }}
<i class="iconfont ri-folder-open-line"></i>
</n-button>
</div>
</div>
</div>
<!-- 文件名格式设置 -->
<div class="setting-item">
<div class="setting-title">{{ t('download.settingsPanel.fileFormat') }}</div>
<div class="setting-desc">{{ t('download.settingsPanel.fileFormatDesc') }}</div>
<!-- 预设模板 -->
<div class="flex gap-2 my-2">
<n-button
size="small"
:type="downloadSettings.nameFormat === '{songName} - {artistName}' ? 'primary' : 'default'"
@click="downloadSettings.nameFormat = '{songName} - {artistName}'"
>
{{ t('download.settingsPanel.presets.songArtist') }}
</n-button>
<n-button
size="small"
:type="downloadSettings.nameFormat === '{artistName} - {songName}' ? 'primary' : 'default'"
@click="downloadSettings.nameFormat = '{artistName} - {songName}'"
>
{{ t('download.settingsPanel.presets.artistSong') }}
</n-button>
<n-button
size="small"
:type="downloadSettings.nameFormat === '{songName}' ? 'primary' : 'default'"
@click="downloadSettings.nameFormat = '{songName}'"
>
{{ t('download.settingsPanel.presets.songOnly') }}
</n-button>
</div>
<!-- 分隔符设置 -->
<div class="my-3">
<div class="text-sm text-gray-500 mb-2">{{ t('download.settingsPanel.separator') || '分隔符' }}</div>
<div class="flex items-center gap-2">
<n-button
size="small"
:type="downloadSettings.separator === ' - ' ? 'primary' : 'default'"
@click="downloadSettings.separator = ' - '"
>
{{ t('download.settingsPanel.separators.dash') || '空格-空格' }}
</n-button>
<n-button
size="small"
:type="downloadSettings.separator === '_' ? 'primary' : 'default'"
@click="downloadSettings.separator = '_'"
>
{{ t('download.settingsPanel.separators.underscore') || '下划线' }}
</n-button>
<n-button
size="small"
:type="downloadSettings.separator === ' ' ? 'primary' : 'default'"
@click="downloadSettings.separator = ' '"
>
{{ t('download.settingsPanel.separators.space') || '空格' }}
</n-button>
<n-input
v-model:value="downloadSettings.separator"
size="small"
style="width: 100px"
placeholder="自定义"
/>
</div>
</div>
<!-- 组件排序 -->
<div class="my-3">
<div class="text-sm text-gray-500 mb-2">{{ t('download.settingsPanel.dragToArrange') }}</div>
<div class="format-components">
<div v-for="(component, index) in formatComponents" :key="component.id" class="format-item">
<div class="flex items-center justify-between w-full">
<span>{{ t(`download.settingsPanel.components.${component.type}`) }}</span>
<div class="flex items-center">
<n-button quaternary circle size="small" @click="handleMoveUp(index)" :disabled="index === 0">
<template #icon><i class="iconfont ri-arrow-up-s-line"></i></template>
</n-button>
<n-button quaternary circle size="small" @click="handleMoveDown(index)" :disabled="index === formatComponents.length - 1">
<template #icon><i class="iconfont ri-arrow-down-s-line"></i></template>
</n-button>
<n-button quaternary circle size="small" @click="removeFormatComponent(index)" :disabled="formatComponents.length <= 1">
<template #icon><i class="iconfont ri-close-line"></i></template>
</n-button>
</div>
</div>
</div>
<div class="mt-2 flex gap-2">
<n-button size="small" @click="addFormatComponent('songName')" :disabled="formatComponents.some(c => c.type === 'songName')">
+{{ t('download.settingsPanel.components.songName') }}
</n-button>
<n-button size="small" @click="addFormatComponent('artistName')" :disabled="formatComponents.some(c => c.type === 'artistName')">
+{{ t('download.settingsPanel.components.artistName') }}
</n-button>
<n-button size="small" @click="addFormatComponent('albumName')" :disabled="formatComponents.some(c => c.type === 'albumName')">
+{{ t('download.settingsPanel.components.albumName') }}
</n-button>
</div>
</div>
</div>
<!-- 自定义格式 -->
<div class="my-3">
<div class="text-sm text-gray-500 mb-2">{{ t('download.settingsPanel.customFormat') }}</div>
<n-input
v-model:value="downloadSettings.nameFormat"
placeholder="{artistName} - {songName} - {albumName}"
/>
</div>
<div class="mt-2 text-xs text-amber-500">
<i class="iconfont ri-information-line"></i>
{{ t('download.settingsPanel.formatVariables') }}:<br>
{songName}, {artistName}, {albumName}
</div>
<!-- 预览 -->
<div class="format-preview mt-3 bg-gray-100 dark:bg-dark-300 p-2 rounded">
<div class="text-xs text-gray-500 mb-1">{{ t('download.settingsPanel.preview') }}</div>
<div class="preview-content">{{ formatNamePreview }}</div>
</div>
</div>
</div>
</n-drawer-content>
</n-drawer>
</template>
<script setup lang="ts">
@@ -233,6 +397,7 @@ interface DownloadedItem {
id: number;
picUrl: string;
ar: { name: string }[];
displayName?: string;
}
const tabName = ref('downloading');
@@ -330,14 +495,14 @@ const handlePlayMusic = async (item: DownloadedItem) => {
const fileExists = await window.electron.ipcRenderer.invoke('check-file-exists', item.path);
if (!fileExists) {
message.error(t('download.delete.fileNotFound', { name: item.filename }));
message.error(t('download.delete.fileNotFound', { name: item.displayName || item.filename }));
return;
}
// 转换下载项为播放所需的歌曲对象
const song: SongResult = {
id: item.id,
name: item.filename,
name: item.displayName || item.filename,
ar: item.ar?.map(a => ({
id: 0,
name: a.name,
@@ -373,10 +538,10 @@ const handlePlayMusic = async (item: DownloadedItem) => {
playerStore.setPlayMusic(true);
playerStore.setIsPlay(true);
message.success(t('download.playStarted', { name: item.filename }));
message.success(t('download.playStarted', { name: item.displayName || item.filename }));
} catch (error) {
console.error('播放音乐失败:', error);
message.error(t('download.playFailed', { name: item.filename }));
message.error(t('download.playFailed', { name: item.displayName || item.filename }));
}
};
@@ -439,6 +604,25 @@ const clearDownloadRecords = async () => {
// 添加加载状态
const isLoadingDownloaded = ref(false);
// 格式化歌曲名称,应用用户设置的格式
const formatSongName = (songInfo) => {
if (!songInfo) return '';
// 获取格式设置
const nameFormat = downloadSettings.value.nameFormat || '{songName} - {artistName}';
// 准备替换变量
const artistName = songInfo.ar?.map((a) => a.name).join('/') || '未知艺术家';
const songName = songInfo.name || songInfo.filename || '未知歌曲';
const albumName = songInfo.al?.name || '未知专辑';
// 应用自定义格式
return nameFormat
.replace(/\{songName\}/g, songName)
.replace(/\{artistName\}/g, artistName)
.replace(/\{albumName\}/g, albumName);
};
// 获取已下载音乐列表
const refreshDownloadedList = async () => {
if (isLoadingDownloaded.value) return; // 防止重复加载
@@ -455,8 +639,14 @@ const refreshDownloadedList = async () => {
const songIds = list.filter(item => item.id).map(item => item.id);
if (songIds.length === 0) {
downloadedList.value = list;
localStorage.setItem('downloadedList', JSON.stringify(list));
// 处理显示格式化文件名
const updatedList = list.map(item => ({
...item,
displayName: formatSongName(item) || item.filename
}));
downloadedList.value = updatedList;
localStorage.setItem('downloadedList', JSON.stringify(updatedList));
return;
}
@@ -467,18 +657,32 @@ const refreshDownloadedList = async () => {
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') }]
}));
const updatedList = list.map(item => {
const songDetail = songDetails[item.id];
const updatedItem = {
...item,
picUrl: songDetail?.al?.picUrl || item.picUrl || '/images/default_cover.png',
ar: songDetail?.ar || item.ar || [{ name: t('download.localMusic') }],
name: songDetail?.name || item.name || item.filename
};
// 添加格式化的显示名称
updatedItem.displayName = formatSongName(updatedItem) || updatedItem.filename;
return updatedItem;
});
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));
// 处理显示格式化文件名
const updatedList = list.map(item => ({
...item,
displayName: formatSongName(item) || item.filename
}));
downloadedList.value = updatedList;
localStorage.setItem('downloadedList', JSON.stringify(updatedList));
}
} catch (error) {
console.error('Failed to get downloaded music list:', error);
@@ -498,10 +702,13 @@ watch(
}
);
// 监听下载进度
// 初始化
onMounted(() => {
refreshDownloadedList();
// 记录已处理的下载项,避免重复触发事件
const processedDownloads = new Set<string>();
// 监听下载进度
window.electron.ipcRenderer.on('music-download-progress', (_, data) => {
const existingItem = downloadList.value.find((item) => item.filename === data.filename);
@@ -517,7 +724,8 @@ onMounted(() => {
songInfo: data.songInfo || existingItem.songInfo
});
// 如果下载完成,从列表中移除
// 如果下载完成,从列表中移除,但不触发完成通知
// 通知由 music-download-complete 事件处理
if (data.status === 'completed') {
downloadList.value = downloadList.value.filter((item) => item.filename !== data.filename);
}
@@ -531,12 +739,31 @@ onMounted(() => {
// 监听下载完成
window.electron.ipcRenderer.on('music-download-complete', async (_, data) => {
// 如果已经处理过此文件的完成事件,则跳过
if (processedDownloads.has(data.filename)) {
return;
}
// 标记为已处理
processedDownloads.add(data.filename);
// 下载成功处理
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 }));
// 避免通知过多占用内存,设置一个超时来清理已处理的标记
setTimeout(() => {
processedDownloads.delete(data.filename);
}, 10000); // 10秒后清除
} else {
// 下载失败处理
const existingItem = downloadList.value.find(item => item.filename === data.filename);
if (existingItem) {
Object.assign(existingItem, {
@@ -546,8 +773,10 @@ onMounted(() => {
});
setTimeout(() => {
downloadList.value = downloadList.value.filter(item => item.filename !== data.filename);
processedDownloads.delete(data.filename);
}, 3000);
}
message.error(t('download.message.downloadFailed', { filename: data.filename, error: data.error }));
}
});
@@ -568,6 +797,178 @@ onMounted(() => {
}
});
});
// 下载设置
const showSettingsDrawer = ref(false);
const downloadSettings = ref({
path: '',
nameFormat: '{songName} - {artistName}',
separator: ' - '
});
// 格式组件(用于拖拽排序)
const formatComponents = ref([
{ id: 1, type: 'songName' },
{ id: 2, type: 'artistName' },
]);
// 处理组件排序
const handleMoveUp = (index: number) => {
if (index > 0) {
const temp = formatComponents.value.splice(index, 1)[0];
formatComponents.value.splice(index - 1, 0, temp);
}
};
const handleMoveDown = (index: number) => {
if (index < formatComponents.value.length - 1) {
const temp = formatComponents.value.splice(index, 1)[0];
formatComponents.value.splice(index + 1, 0, temp);
}
};
// 添加新的格式组件
const addFormatComponent = (type: string) => {
if (!formatComponents.value.some(item => item.type === type)) {
formatComponents.value.push({
id: Date.now(),
type
});
}
};
// 删除格式组件
const removeFormatComponent = (index: number) => {
formatComponents.value.splice(index, 1);
};
// 监听组件变化更新格式
watch(formatComponents, (newComponents) => {
let format = '';
newComponents.forEach((component, index) => {
format += `{${component.type}}`;
if (index < newComponents.length - 1) {
format += downloadSettings.value.separator;
}
});
downloadSettings.value.nameFormat = format;
}, { deep: true });
// 监听分隔符变化更新格式
watch(() => downloadSettings.value.separator, (newSeparator) => {
if (formatComponents.value.length > 1) {
// 重新构建格式字符串
let format = '';
formatComponents.value.forEach((component, index) => {
format += `{${component.type}}`;
if (index < formatComponents.value.length - 1) {
format += newSeparator;
}
});
downloadSettings.value.nameFormat = format;
}
});
// 格式名称预览
const formatNamePreview = computed(() => {
const format = downloadSettings.value.nameFormat;
return format
.replace(/\{songName\}/g, '莫失莫忘')
.replace(/\{artistName\}/g, '香蜜沉沉烬如霜')
.replace(/\{albumName\}/g, '电视剧原声带');
});
// 选择下载路径
const selectDownloadPath = async () => {
const result = await window.electron.ipcRenderer.invoke('select-directory');
if (result && !result.canceled && result.filePaths.length > 0) {
downloadSettings.value.path = result.filePaths[0];
}
};
// 打开下载路径
const openDownloadPath = () => {
if (downloadSettings.value.path) {
window.electron.ipcRenderer.send('open-directory', downloadSettings.value.path);
} else {
message.warning(t('download.settingsPanel.noPathSelected'));
}
};
// 保存下载设置
const saveDownloadSettings = () => {
// 保存到配置
window.electron.ipcRenderer.send('set-store-value', 'set.downloadPath', downloadSettings.value.path);
window.electron.ipcRenderer.send('set-store-value', 'set.downloadNameFormat', downloadSettings.value.nameFormat);
window.electron.ipcRenderer.send('set-store-value', 'set.downloadSeparator', downloadSettings.value.separator);
// 如果是在已下载页面,刷新列表以更新显示
if (tabName.value === 'downloaded') {
refreshDownloadedList();
}
message.success(t('download.settingsPanel.saveSuccess'));
showSettingsDrawer.value = false;
};
// 初始化下载设置
const initDownloadSettings = async () => {
// 获取当前配置
const path = await window.electron.ipcRenderer.invoke('get-store-value', 'set.downloadPath');
const nameFormat = await window.electron.ipcRenderer.invoke('get-store-value', 'set.downloadNameFormat');
const separator = await window.electron.ipcRenderer.invoke('get-store-value', 'set.downloadSeparator');
downloadSettings.value = {
path: path || await window.electron.ipcRenderer.invoke('get-downloads-path'),
nameFormat: nameFormat || '{songName} - {artistName}',
separator: separator || ' - '
};
// 初始化排序组件
updateFormatComponents();
};
// 根据格式更新组件
const updateFormatComponents = () => {
// 提取格式中的变量
const format = downloadSettings.value.nameFormat;
const matches = Array.from(format.matchAll(/\{(\w+)\}/g));
if (matches.length === 0) {
formatComponents.value = [
{ id: 1, type: 'songName' },
{ id: 2, type: 'artistName' }
];
return;
}
formatComponents.value = matches.map((match, index) => ({
id: index + 1,
type: match[1]
}));
};
// 监听格式变化更新组件
watch(() => downloadSettings.value.nameFormat, updateFormatComponents);
// 监听命名格式变化,更新已下载文件的显示名称
watch(() => downloadSettings.value.nameFormat, () => {
if (downloadedList.value.length > 0) {
// 更新所有已下载项的显示名称
downloadedList.value = downloadedList.value.map(item => ({
...item,
displayName: formatSongName(item) || item.filename
}));
// 保存到本地存储
localStorage.setItem('downloadedList', JSON.stringify(downloadedList.value));
}
});
// 初始化
onMounted(() => {
initDownloadSettings();
});
</script>
<style lang="scss" scoped>
@@ -992,6 +1393,37 @@ onMounted(() => {
transform: rotate(360deg);
}
}
.download-settings {
@apply flex flex-col gap-6;
}
.setting-item {
@apply bg-white dark:bg-dark-200 p-3 rounded-lg shadow-sm;
@apply border border-gray-200/60 dark:border-gray-700/50;
}
.setting-title {
@apply text-base font-medium;
@apply text-gray-800 dark:text-gray-200;
}
.setting-desc {
@apply text-sm text-gray-500 dark:text-gray-400 mt-1;
}
.format-components {
@apply flex flex-col gap-2;
}
.format-item {
@apply flex items-center px-3 py-2 bg-gray-100 dark:bg-dark-300 rounded;
@apply border border-gray-200 dark:border-gray-700;
}
.format-preview {
@apply text-sm;
}
</style>