feat: 云盘/下载音乐分离/粘性头部/播放状态同步/歌手关注

新增:
- 音乐云盘页面(列表/详情弹窗/删除/存储空间, NOS multipart上传+LBS区域查询+进度事件)
- 下载音乐页面(独立于本地音乐, 只显示应用下载的歌曲)
- PageHeader粘性头部组件(IntersectionObserver控制显隐, 渐变模糊背景)
- useLocalMusic composable(LocalSong类型/formatFileSize/localSongToSong/fetchMissingCovers)
- 云盘上传完整流程(cloud_upload命令: check->token->LBS->NOS分块上传->info->publish)
- 云盘API(user_cloud/user_cloud_detail/user_cloud_del)
- 歌手关注/取关(artist_sub/artist_sublist命令, ArtistDetail关注按钮+artistSublist查询状态)
- 本地音乐多文件夹扫描(scan_local_folders命令, settings.localMusicPaths, 模态框管理)
- 侧边栏下载音乐和云盘导航项, 路由新增downloaded-music和cloud-music
- md5 crate依赖

改进:
- 路由全部改为懒加载
- keep-alive缓存管理重写(30s TTL+导航栈保护+FavoriteSongs常驻+10s定时清理)
- 播放器状态同步改为轮询isAudioPlaying(替代audio-started事件), 超时后watchForLatePlayback继续监听
- audio.rs新增is_playing原子状态+is_audio_playing命令
- 同步命令改async+spawn_blocking(list_local_songs/delete_local_song/check_local_song/get_default_download_path)
- scan_dir_for_songs抽取为公共函数, 新增downloaded_only参数
- RoamDrawer tab状态从组件本地ref移至store(roamTab替换roamInitialTab)
- App.vue onMounted改为非阻塞
- 多页面添加骨架屏加载态和加载失败重试
- 多页面使用PageHeader替代手动返回按钮
- PlaylistDetail/ArtistDetail添加简介弹窗(溢出时显示查看完整介绍)
- Home推荐/排行榜拆分为独立fetch函数支持分别重试
- Toast去重(3s窗口)+数量限制(最多3条)
- LocalMusic移除删除功能改文件夹模态框, ArtistDetail头像改圆形简介内嵌
- README重写

修复:
- 播放超时后后端实际开始播放但UI显示暂停(watchForLatePlayback+tick定期同步isAudioPlaying)
- FM播放缺少playSeq竞态保护
- scrobble离线时仍发送(添加navigator.onLine检查)
- RoamDrawer已打开时点击评论按钮无法切换(roamTab移至store)
- 关闭RoamDrawer后再打开永远显示评论(closeRoamDrawer重置roamTab)
- 歌手详情页关注状态离开后丢失(artist_detail不返回followed, 改用artistSublist查询)
- audio-ended事件在切歌时误触发(新增_switchingSong标志拦截)
- 路由beforeEach中localStorage key从user改为user_profile
- toggle播放前先同步后端状态
This commit is contained in:
2026-06-04 07:03:27 +08:00
parent e40f82cc51
commit 3535e2e8a0
30 changed files with 2516 additions and 782 deletions

View File

@ -1,19 +1,37 @@
<template>
<div class="p-8 text-content">
<button @click="$router.back()" class="mb-4 text-content-2 hover:text-content transition">
返回
</button>
<div class="flex items-center gap-4 mb-6">
<PageHeader>
<h1 class="text-2xl font-bold">本地音乐</h1>
<span v-if="songs.length" class="text-xs text-content-3">{{ songs.length }} </span>
<button
@click="refresh"
class="px-3 py-1 bg-muted hover:bg-emphasis rounded-full text-xs transition"
>
刷新
</button>
<template #actions>
<button
@click="refresh"
class="px-3 py-1 bg-muted hover:bg-emphasis rounded-full text-xs transition"
>
刷新
</button>
<button
@click="showFolderModal = true"
class="text-content-3 hover:text-content transition p-1 rounded hover:bg-muted"
title="文件夹管理"
>
<IconEllipsis class="w-5 h-5 fill-current" />
</button>
</template>
</PageHeader>
<div v-if="loading" class="space-y-1">
<div v-for="i in 6" :key="i" class="flex items-center gap-3 px-3 py-2">
<div class="w-12 h-12 bg-muted rounded animate-pulse flex-shrink-0"></div>
<div class="flex-1 space-y-2">
<div class="h-4 bg-muted rounded w-2/3 animate-pulse"></div>
<div class="h-3 bg-muted rounded w-1/3 animate-pulse"></div>
</div>
</div>
</div>
<div v-else-if="settings.localMusicPaths.length === 0" class="text-content-3 py-4">
请先添加要扫描的文件夹
</div>
<div v-if="loading" class="text-content-2">加载中...</div>
<div v-else-if="songs.length === 0" class="text-content-3">
当前文件夹下没有音乐文件支持 mp3flacwavoggaacm4a 格式
</div>
@ -32,42 +50,46 @@
>
<template #actions>
<span class="text-xs text-content-3 flex-shrink-0">{{ formatFileSize(songs[index].fileSize) }}</span>
<div class="relative flex-shrink-0">
<button
@click.stop="toggleMenu(songs[index].id)"
class="text-content-3 hover:text-content transition p-1 rounded hover:bg-muted"
title="更多"
>
<IconEllipsis class="w-4 h-4 fill-current" />
</button>
<Transition name="fade">
<div v-if="openMenuId === songs[index].id" class="absolute right-0 top-full mt-1 w-44 bg-surface border border-line rounded-xl shadow-2xl overflow-hidden z-50" @click.stop>
<button @click="confirmDelete(songs[index])" class="w-full flex items-center gap-2.5 px-4 py-2.5 text-sm text-danger/80 hover:bg-danger/10 transition">
<IconTrash2 style="font-size: 14px" />
从磁盘中删除
</button>
</div>
</Transition>
</div>
</template>
</SongListItem>
</div>
<!-- 文件夹管理弹窗 -->
<Transition name="fade">
<div v-if="showDeleteConfirm" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showDeleteConfirm = false">
<div class="bg-surface border border-line rounded-2xl shadow-2xl w-80 p-6 select-auto">
<h2 class="text-lg font-semibold text-content mb-1">确认删除</h2>
<p class="text-sm text-content-2 mb-5">确定要删除{{ deleteTarget?.name }}此操作不可撤销</p>
<div class="flex gap-3">
<button @click="showDeleteConfirm = false"
class="flex-1 py-2 rounded-lg bg-muted hover:bg-emphasis text-sm text-content-2 transition">
取消
</button>
<button @click="doDelete"
class="flex-1 py-2 rounded-lg bg-danger/20 hover:bg-danger/30 text-danger text-sm font-medium transition">
删除
<div v-if="showFolderModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showFolderModal = false">
<div class="bg-surface border border-line rounded-2xl shadow-2xl w-[420px] p-6 select-auto">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold">扫描文件夹</h2>
<button @click="showFolderModal = false" class="text-content-3 hover:text-content transition">
<IconX class="w-5 h-5" />
</button>
</div>
<div v-if="settings.localMusicPaths.length === 0" class="text-sm text-content-3 py-4 text-center">
未添加任何文件夹
</div>
<div v-else class="space-y-1.5 max-h-60 overflow-y-auto mb-4">
<div
v-for="p in settings.localMusicPaths"
:key="p"
class="flex items-center gap-2 px-3 py-2 bg-subtle rounded-lg group"
>
<IconFolder class="w-4 h-4 text-content-3 flex-shrink-0" />
<span class="text-sm text-content-2 truncate flex-1" :title="p">{{ p }}</span>
<button
@click="settings.removeLocalMusicPath(p)"
class="text-content-4 hover:text-danger transition opacity-0 group-hover:opacity-100"
title="移除"
>
<IconX class="w-4 h-4" />
</button>
</div>
</div>
<button
@click="addFolder"
class="w-full py-2.5 rounded-lg bg-accent/15 text-accent-text hover:bg-accent/25 text-sm font-medium transition"
>
添加文件夹
</button>
</div>
</div>
</Transition>
@ -75,64 +97,53 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onActivated, onBeforeUnmount, watch } from 'vue';
import { MusicApi, DownloadApi } from '../api';
import { ref, computed, onMounted, onActivated, watch } from 'vue';
import { DownloadApi } from '../api';
import { usePlayerStore } from '../stores/player';
import { useDownload } from '../composables/useDownload';
import { useSettingsStore } from '../stores/settings';
import { showToast } from '../composables/useToast';
import { pageCacheSet, pageCacheInvalidate, pageCacheIsStale } from '../composables/usePageCache';
import { pageCacheSet, pageCacheIsStale } from '../composables/usePageCache';
import { formatFileSize, localSongToSong, fetchMissingCovers, type LocalSong } from '../composables/useLocalMusic';
import { open } from '@tauri-apps/plugin-dialog';
import SongListItem from '../components/SongListItem.vue';
import PageHeader from '../components/PageHeader.vue';
import IconEllipsis from '~icons/lucide/ellipsis';
import IconTrash2 from '~icons/lucide/trash-2';
import type { Song } from '../utils/song';
import IconFolder from '~icons/lucide/folder';
import IconX from '~icons/lucide/x';
defineOptions({ name: 'LocalMusicView' });
const player = usePlayerStore();
const download = useDownload();
const settings = useSettingsStore();
interface LocalSong {
id: number;
name: string;
artist: string;
album: string;
duration: number;
cover: string | null;
filename: string;
fileSize: number;
path: string;
local: boolean;
}
const songs = ref<LocalSong[]>([]);
const loading = ref(true);
const showDeleteConfirm = ref(false);
const deleteTarget = ref<LocalSong | null>(null);
const openMenuId = ref<number | null>(null);
const showFolderModal = ref(false);
const normalizedSongs = computed(() => songs.value.map(toSong));
const normalizedSongs = computed(() => songs.value.map(localSongToSong));
function toggleMenu(id: number) {
openMenuId.value = openMenuId.value === id ? null : id;
async function addFolder() {
const selected = await open({
directory: true,
multiple: false,
title: '选择音乐文件夹',
});
if (selected) {
settings.addLocalMusicPath(selected);
}
}
function closeMenu() {
openMenuId.value = null;
}
onMounted(() => { document.addEventListener('click', closeMenu); });
onBeforeUnmount(() => { document.removeEventListener('click', closeMenu); });
async function refresh() {
if (settings.localMusicPaths.length === 0) {
songs.value = [];
loading.value = false;
return;
}
loading.value = true;
pageCacheInvalidate('localMusic');
try {
const list = await DownloadApi.listLocalSongs(settings.downloadPath || null);
const list = await DownloadApi.scanLocalFolders(settings.localMusicPaths);
songs.value = list;
pageCacheSet('localMusic', list);
fetchMissingCovers();
fetchMissingCovers(songs.value);
} catch (e) {
console.error(e);
} finally {
@ -140,70 +151,13 @@ async function refresh() {
}
}
async function fetchMissingCovers() {
const missing = songs.value.filter(s => !s.cover && s.id > 0 && s.id < 1e12);
if (missing.length === 0) return;
const ids = [...new Set(missing.map(s => s.id))];
try {
const jsonStr: string = await MusicApi.getSongDetail(JSON.stringify(ids));
const data = JSON.parse(jsonStr);
const detailMap = new Map<number, string>();
for (const s of data.songs || []) {
const url = s.al?.picUrl;
if (url && s.id) detailMap.set(s.id, url + '?param=100y100');
}
for (const song of missing) {
const url = detailMap.get(song.id);
if (url) song.cover = url;
}
} catch { /* 忽略 */ }
}
onMounted(refresh);
onActivated(() => {
if (pageCacheIsStale('localMusic')) refresh();
});
watch(() => settings.downloadPath, () => { refresh(); });
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return (bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0) + ' ' + units[i];
}
function toSong(local: LocalSong): Song {
return {
id: local.id,
name: local.name,
ar: local.artist ? [{ name: local.artist }] : [],
al: { picUrl: local.cover || '', name: local.album || undefined },
dt: local.duration || undefined,
localPath: local.path,
};
}
function confirmDelete(song: LocalSong) {
openMenuId.value = null;
deleteTarget.value = song;
showDeleteConfirm.value = true;
}
async function doDelete() {
if (!deleteTarget.value) return;
try {
await DownloadApi.deleteLocalSong({ id: deleteTarget.value.id, filename: deleteTarget.value.filename, downloadPath: settings.downloadPath || null });
songs.value = songs.value.filter(s => s.id !== deleteTarget.value!.id);
download.localSongIds.delete(deleteTarget.value.id);
showToast('已删除', 'success');
} catch (e) {
showToast('删除失败', 'error');
}
showDeleteConfirm.value = false;
deleteTarget.value = null;
}
watch(() => settings.localMusicPaths, () => { refresh(); }, { deep: true });
</script>
<style scoped>