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

436
src/views/CloudMusic.vue Normal file
View File

@ -0,0 +1,436 @@
<template>
<div class="p-8 text-content">
<PageHeader>
<h1 class="text-2xl font-bold">音乐云盘</h1>
<span v-if="totalCount" class="text-xs text-content-3">{{ totalCount }} </span>
<template #actions>
<button
@click="refresh"
class="px-3 py-1 bg-muted hover:bg-emphasis rounded-full text-xs transition"
>
刷新
</button>
<button
@click="pickAndUpload"
:disabled="uploading"
class="px-3 py-1 bg-accent/15 text-accent-text hover:bg-accent/25 rounded-full text-xs transition disabled:opacity-50"
>
{{ uploading ? '上传中...' : '上传歌曲' }}
</button>
<!-- 上传进度 -->
<div v-if="uploading && uploadProgress < 100" class="flex items-center gap-2 text-xs text-content-3">
<div class="w-24 h-1.5 bg-muted rounded-full overflow-hidden">
<div class="h-full bg-accent rounded-full transition-all duration-300" :style="{ width: uploadProgress + '%' }"></div>
</div>
<span>{{ uploadProgress.toFixed(0) }}%</span>
</div>
</template>
</PageHeader>
<!-- 存储空间 -->
<div v-if="cloudSize > 0" class="mb-6 p-4 bg-subtle rounded-xl">
<div class="flex items-center justify-between text-xs mb-2">
<span class="text-content-2">已使用 {{ formatFileSize(cloudSize) }} / {{ formatFileSize(cloudMaxSize) }}</span>
<span class="text-content-3">{{ cloudUsagePercent }}%</span>
</div>
<div class="h-1.5 bg-muted rounded-full overflow-hidden">
<div class="h-full bg-accent rounded-full transition-all duration-500" :style="{ width: cloudUsagePercent + '%' }"></div>
</div>
</div>
<div v-if="!userStore.isLoggedIn" class="text-content-3 py-8">
请先登录后查看云盘音乐
</div>
<div v-else-if="loading && !songs.length" class="space-y-1">
<div v-for="i in 8" :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="loadError" class="flex flex-col items-center justify-center py-16 gap-3">
<p class="text-content-2 text-sm">加载失败</p>
<button @click="refresh" class="px-4 py-2 bg-subtle hover:bg-muted rounded-lg text-sm transition">重试</button>
</div>
<div v-else-if="!songs.length" class="text-content-3 py-8">云盘中暂无音乐</div>
<div v-else class="space-y-1">
<SongListItem
v-for="(song, index) in songs"
:key="song.id"
:song="song"
:index="index"
:is-current="player.currentSong?.id === song.id"
show-index
show-like
show-download
show-duration
show-playing-overlay
:container-class="player.currentSong?.id === song.id ? 'bg-accent-dim hover:bg-accent-dim' : 'hover:bg-subtle'"
@click="player.playFromList(songs, index)"
>
<template #actions>
<span class="text-xs text-content-3 flex-shrink-0">{{ formatFileSize(cloudData[index]?.fileSize || 0) }}</span>
<div class="relative flex-shrink-0">
<button
@click.stop="toggleMenu(song.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 === song.id" class="absolute right-0 top-full mt-1 w-40 bg-surface border border-line rounded-xl shadow-2xl overflow-hidden z-50" @click.stop>
<button @click="showDetail(index)" class="w-full flex items-center gap-2.5 px-4 py-2.5 text-sm text-content-2 hover:bg-subtle transition">
<IconInfo style="font-size: 14px" />
查看详情
</button>
<button @click="confirmDelete(song)" 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>
<!-- 加载更多 -->
<div v-if="hasMore && songs.length" class="flex justify-center py-6">
<button
@click="loadMore"
:disabled="loadingMore"
class="px-6 py-2 bg-subtle hover:bg-muted rounded-full text-sm transition disabled:opacity-50"
>
{{ loadingMore ? '加载中...' : '加载更多' }}
</button>
</div>
<!-- 详情弹窗 -->
<Transition name="fade">
<div v-if="showDetailModal && detailData" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showDetailModal = false">
<div class="bg-surface border border-line rounded-2xl shadow-2xl w-[380px] p-6 select-auto">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold truncate pr-4">{{ detailData.songName }}</h2>
<button @click="showDetailModal = false" class="text-content-3 hover:text-content transition flex-shrink-0">
<IconX class="w-5 h-5" />
</button>
</div>
<div class="space-y-3 text-sm">
<div class="flex justify-between">
<span class="text-content-3">文件名</span>
<span class="text-content-2 text-right max-w-[220px] truncate" :title="detailData.fileName">{{ detailData.fileName }}</span>
</div>
<div class="flex justify-between">
<span class="text-content-3">歌手</span>
<span class="text-content-2">{{ detailData.artist }}</span>
</div>
<div class="flex justify-between">
<span class="text-content-3">专辑</span>
<span class="text-content-2">{{ detailData.album }}</span>
</div>
<div class="flex justify-between">
<span class="text-content-3">文件大小</span>
<span class="text-content-2">{{ formatFileSize(detailData.fileSize) }}</span>
</div>
<div class="flex justify-between">
<span class="text-content-3">比特率</span>
<span class="text-content-2">{{ detailData.bitrate ? (detailData.bitrate / 1000) + ' kbps' : '未知' }}</span>
</div>
<div class="flex justify-between">
<span class="text-content-3">上传时间</span>
<span class="text-content-2">{{ detailData.addTime }}</span>
</div>
</div>
</div>
</div>
</Transition>
<!-- 删除确认 -->
<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">
删除
</button>
</div>
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onActivated, onBeforeUnmount } from 'vue';
import { MusicApi } from '../api';
import { usePlayerStore } from '../stores/player';
import { useUserStore } from '../stores/user';
import { showToast } from '../composables/useToast';
import { normalizeSong, type Song } from '../utils/song';
import { pageCacheGet, pageCacheSet, pageCacheInvalidate } from '../composables/usePageCache';
import { formatFileSize } from '../composables/useLocalMusic';
import { open } from '@tauri-apps/plugin-dialog';
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
import SongListItem from '../components/SongListItem.vue';
import PageHeader from '../components/PageHeader.vue';
import IconEllipsis from '~icons/lucide/ellipsis';
import IconInfo from '~icons/lucide/info';
import IconTrash2 from '~icons/lucide/trash-2';
import IconX from '~icons/lucide/x';
defineOptions({ name: 'CloudMusicView' });
const player = usePlayerStore();
const userStore = useUserStore();
interface CloudItem {
songId: number;
fileSize: number;
fileName: string;
bitrate: number;
addTime: string;
artist: string;
album: string;
songName: string;
}
const songs = ref<Song[]>([]);
const cloudData = ref<CloudItem[]>([]);
const loading = ref(true);
const loadingMore = ref(false);
const loadError = ref(false);
const hasMore = ref(false);
const totalCount = ref(0);
const openMenuId = ref<number | null>(null);
const showDeleteConfirm = ref(false);
const showDetailModal = ref(false);
const detailData = ref<CloudItem | null>(null);
const deleteTarget = ref<Song | null>(null);
const cloudSize = ref(0);
const cloudMaxSize = ref(0);
const uploading = ref(false);
const uploadProgress = ref(0);
let unlistenProgress: UnlistenFn | null = null;
const cloudUsagePercent = computed(() => {
if (cloudMaxSize.value === 0) return 0;
return Math.min(100, Math.round(cloudSize.value / cloudMaxSize.value * 100));
});
const LIMIT = 30;
let currentOffset = 0;
function formatTimestamp(ts: number): string {
if (!ts) return '未知';
return new Date(ts).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
}
function toggleMenu(id: number) {
openMenuId.value = openMenuId.value === id ? null : id;
}
function closeMenu() {
openMenuId.value = null;
}
onMounted(() => {
document.addEventListener('click', closeMenu);
// 监听上传进度事件
listen<{ filename: string; progress: number; uploaded: number; total: number }>('cloud-upload-progress', (e) => {
uploadProgress.value = e.payload.progress;
}).then(fn => { unlistenProgress = fn; });
});
onBeforeUnmount(() => {
document.removeEventListener('click', closeMenu);
unlistenProgress?.();
});
async function fetchCloud(offset = 0, append = false) {
if (!userStore.isLoggedIn) {
loading.value = false;
return;
}
if (!append) {
loading.value = true;
loadError.value = false;
} else {
loadingMore.value = true;
}
try {
const jsonStr = await MusicApi.userCloud(LIMIT, offset);
const data = JSON.parse(jsonStr);
const items = data.data || [];
const newSongs = items.map((item: any) => {
const s = item.simpleSong || {};
return normalizeSong({
...s,
id: s.id || item.songId,
name: s.name || item.fileName,
ar: s.ar || (item.artist ? [{ name: item.artist }] : []),
al: s.al || { name: item.album || '未知专辑' },
dt: s.dt || item.duration,
});
});
const newCloudData: CloudItem[] = items.map((item: any) => ({
songId: item.songId,
fileSize: item.fileSize || 0,
fileName: item.fileName || '',
bitrate: item.bitrate || 0,
addTime: formatTimestamp(item.addTime),
artist: item.artist || (item.simpleSong?.ar || []).map((a: any) => a.name).join(' / ') || '未知歌手',
album: item.album || item.simpleSong?.al?.name || '未知专辑',
songName: item.simpleSong?.name || item.fileName?.replace(/\.\w+$/, '') || '未知歌曲',
}));
if (append) {
songs.value = [...songs.value, ...newSongs];
cloudData.value = [...cloudData.value, ...newCloudData];
} else {
songs.value = newSongs;
cloudData.value = newCloudData;
}
totalCount.value = data.count || songs.value.length;
currentOffset = offset + items.length;
hasMore.value = songs.value.length < totalCount.value;
cloudSize.value = data.size || 0;
cloudMaxSize.value = data.maxSize || 0;
if (!append) {
pageCacheSet('cloudMusic', {
songs: songs.value, cloudData: cloudData.value, totalCount: totalCount.value,
hasMore: hasMore.value, offset: currentOffset,
cloudSize: cloudSize.value, cloudMaxSize: cloudMaxSize.value,
});
}
} catch (e) {
console.error(e);
if (!append) loadError.value = true;
else showToast('加载更多失败', 'error');
} finally {
loading.value = false;
loadingMore.value = false;
}
}
function refresh() {
pageCacheInvalidate('cloudMusic');
currentOffset = 0;
fetchCloud(0, false);
}
function loadMore() {
fetchCloud(currentOffset, true);
}
async function pickAndUpload() {
const selected = await open({
multiple: true,
filters: [{ name: '音频文件', extensions: ['mp3', 'flac', 'wav', 'ogg', 'aac', 'm4a'] }],
title: '选择要上传的歌曲',
});
if (!selected) return;
const paths = Array.isArray(selected) ? selected : [selected];
uploading.value = true;
uploadProgress.value = 0;
for (const filePath of paths) {
uploadProgress.value = 0;
try {
await MusicApi.cloudUpload(filePath);
showToast('上传成功', 'success');
} catch (e: any) {
showToast(`上传失败: ${e || '未知错误'}`, 'error');
}
}
uploading.value = false;
uploadProgress.value = 0;
// 等待服务端完全提交后再刷新列表
setTimeout(() => refresh(), 1000);
}
function showDetail(index: number) {
openMenuId.value = null;
detailData.value = cloudData.value[index] || null;
showDetailModal.value = true;
}
function confirmDelete(song: Song) {
openMenuId.value = null;
deleteTarget.value = song;
showDeleteConfirm.value = true;
}
async function doDelete() {
if (!deleteTarget.value) return;
try {
await MusicApi.userCloudDel(deleteTarget.value.id);
const targetId = deleteTarget.value.id;
const idx = songs.value.findIndex(s => s.id === targetId);
songs.value = songs.value.filter(s => s.id !== targetId);
if (idx !== -1) cloudData.value.splice(idx, 1);
totalCount.value = Math.max(0, totalCount.value - 1);
pageCacheInvalidate('cloudMusic');
showToast('已从云盘删除', 'success');
} catch {
showToast('删除失败', 'error');
}
showDeleteConfirm.value = false;
deleteTarget.value = null;
}
onMounted(() => {
if (!userStore.isLoggedIn) {
loading.value = false;
return;
}
const cached = pageCacheGet('cloudMusic');
if (cached) {
songs.value = cached.songs;
cloudData.value = cached.cloudData;
totalCount.value = cached.totalCount;
hasMore.value = cached.hasMore;
currentOffset = cached.offset;
cloudSize.value = cached.cloudSize || 0;
cloudMaxSize.value = cached.cloudMaxSize || 0;
loading.value = false;
return;
}
fetchCloud();
});
onActivated(() => {
if (loadError.value) refresh();
});
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.15s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>