Files
Nekosonic-Music/src/views/CloudMusic.vue
Atdunbg 3535e2e8a0 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播放前先同步后端状态
2026-06-04 07:36:00 +08:00

437 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>