feat(local-music): 支持从本地列表移除单曲并防止扫描失败误删(#713)

- 右键菜单新增'从本地列表移除'(本地歌曲自动切换文案,仅移除条目不删文件,5 语言文案)
- localMusic store 新增 removeEntry action
- 扫描失败的文件夹不再参与'已删除清理',避免移动盘/网络盘暂时不可用时整夹歌曲被误删
- 注:'刷新不清理已删除歌曲'主症状已由 c28368f 修复,本次补齐评论区诉求与防护

Closes #713
This commit is contained in:
alger
2026-07-05 14:35:00 +08:00
parent dee717a575
commit 33149c6a74
8 changed files with 70 additions and 7 deletions
+3 -1
View File
@@ -9,5 +9,7 @@ export default {
emptyState: 'No local music found. Please select a folder to scan.',
fileNotFound: 'File not found or has been moved',
rescan: 'Rescan',
songCount: '{count} songs'
songCount: '{count} songs',
removeFromLibrary: 'Remove from Library',
removedFromLibrary: 'Removed from library (file not deleted)'
};
+3 -1
View File
@@ -9,5 +9,7 @@ export default {
emptyState: 'ローカル音楽がありません。フォルダを選択してスキャンしてください。',
fileNotFound: 'ファイルが見つからないか、移動されました',
rescan: '再スキャン',
songCount: '{count} 曲'
songCount: '{count} 曲',
removeFromLibrary: 'ライブラリから削除',
removedFromLibrary: 'ライブラリから削除しました(ファイルは削除されません)'
};
+3 -1
View File
@@ -9,5 +9,7 @@ export default {
emptyState: '로컬 음악이 없습니다. 폴더를 선택하여 스캔하세요.',
fileNotFound: '파일을 찾을 수 없거나 이동되었습니다',
rescan: '다시 스캔',
songCount: '{count}곡'
songCount: '{count}곡',
removeFromLibrary: '라이브러리에서 제거',
removedFromLibrary: '라이브러리에서 제거했습니다 (파일은 삭제되지 않음)'
};
+3 -1
View File
@@ -9,5 +9,7 @@ export default {
emptyState: '暂无本地音乐,请先选择文件夹进行扫描',
fileNotFound: '文件不存在或已被移动',
rescan: '重新扫描',
songCount: '{count} 首歌曲'
songCount: '{count} 首歌曲',
removeFromLibrary: '从本地列表移除',
removedFromLibrary: '已从本地列表移除(不删除文件)'
};
+3 -1
View File
@@ -9,5 +9,7 @@ export default {
emptyState: '暫無本地音樂,請先選擇資料夾進行掃描',
fileNotFound: '檔案不存在或已被移動',
rescan: '重新掃描',
songCount: '{count} 首歌曲'
songCount: '{count} 首歌曲',
removeFromLibrary: '從本機清單移除',
removedFromLibrary: '已從本機清單移除(不刪除檔案)'
};
@@ -59,6 +59,12 @@ const userStore = useUserStore();
// 后者不具备响应性,登录/登出后菜单禁用状态不会刷新(#706)
const hasRealAuth = computed(() => !!userStore.user && userStore.loginType !== 'uid');
// 本地歌曲:移除菜单项显示"从本地列表移除"而非"从歌单中删除"#713
const isLocalSong = computed(
() =>
typeof props.item.playMusicUrl === 'string' && props.item.playMusicUrl.startsWith('local://')
);
// 渲染歌曲预览
const renderSongPreview = () => {
return h(
@@ -198,7 +204,9 @@ const dropdownOptions = computed<MenuOption[]>(() => {
key: 'd2'
},
{
label: t('songItem.menu.removeFromPlaylist'),
label: isLocalSong.value
? t('localMusic.removeFromLibrary')
: t('songItem.menu.removeFromPlaylist'),
key: 'remove',
icon: () => h('i', { class: 'iconfont ri-delete-bin-line' })
}
+30 -1
View File
@@ -127,6 +127,9 @@ export const useLocalMusicStore = defineStore(
// 磁盘上实际存在的文件路径集合(扫描时收集)
const diskFilePaths = new Set<string>();
// 扫描失败的文件夹:其下的缓存条目不参与"已删除清理",
// 避免移动盘/网络盘暂时不可用时整个文件夹的歌曲被误删(#713)
const failedFolders: string[] = [];
// 遍历每个文件夹进行扫描
for (const folderPath of folderPaths.value) {
@@ -138,6 +141,7 @@ export const useLocalMusicStore = defineStore(
if ((result as any).error) {
console.error(`扫描文件夹失败: ${folderPath}`, (result as any).error);
message.error(`扫描失败: ${(result as any).error}`);
failedFolders.push(folderPath);
continue;
}
@@ -178,12 +182,23 @@ export const useLocalMusicStore = defineStore(
} catch (error) {
console.error(`扫描文件夹出错: ${folderPath}`, error);
message.error(`扫描文件夹出错: ${folderPath}`);
failedFolders.push(folderPath);
}
}
/** 判断文件路径是否位于某个扫描失败的文件夹下 */
const isUnderFailedFolder = (filePath: string): boolean =>
failedFolders.some((folder) => {
if (!filePath.startsWith(folder)) return false;
if (folder.endsWith('/') || folder.endsWith('\\')) return true;
const next = filePath.charAt(folder.length);
return next === '/' || next === '\\';
});
// 4. 清理已删除文件:从 IndexedDB 移除磁盘上不存在的条目
// (扫描失败的文件夹跳过清理,其文件未被枚举并不代表已删除)
for (const [filePath, entry] of cachedMap) {
if (!diskFilePaths.has(filePath)) {
if (!diskFilePaths.has(filePath) && !isUnderFailedFolder(filePath)) {
await localDB.deleteData(LOCAL_MUSIC_STORE, entry.id);
}
}
@@ -213,6 +228,19 @@ export const useLocalMusicStore = defineStore(
}
}
/**
* 从本地列表移除单个条目(仅软件层面移除,不删除磁盘文件)(#713)
* @param id 条目 IDgenerateId 生成的 hex 字符串)
*/
async function removeEntry(id: string): Promise<void> {
const localDB = await getDB();
await localDB.deleteData(LOCAL_MUSIC_STORE, id);
const index = musicList.value.findIndex((entry) => entry.id === id);
if (index !== -1) {
musicList.value.splice(index, 1);
}
}
/**
* 清理缓存:检查文件存在性,移除已不存在的文件条目
*/
@@ -271,6 +299,7 @@ export const useLocalMusicStore = defineStore(
removeFolder,
scanFolders,
loadFromCache,
removeEntry,
clearCache
};
},
+16
View File
@@ -143,7 +143,9 @@
:key="item.id"
:index="index"
:item="item"
:can-remove="true"
@play="handlePlaySong"
@remove-song="handleRemoveSong"
/>
</div>
</section>
@@ -283,6 +285,20 @@ async function handlePlaySong(_song: SongResult): Promise<void> {
}
}
/**
* 从本地列表移除单曲(仅软件层面移除,不删除磁盘文件)(#713)
* @param id SongResult.id,即 LocalMusicEntry.idhex 字符串)
*/
async function handleRemoveSong(id: number | string): Promise<void> {
try {
await localMusicStore.removeEntry(String(id));
message.success(t('localMusic.removedFromLibrary'));
} catch (error) {
console.error('移除本地歌曲失败:', error);
message.error(String(error));
}
}
/**
* 播放全部
* 将完整列表转换为 SongResult[] 后设置为播放列表并从第一首开始播放