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:36:00 +08:00
parent e40f82cc51
commit 3535e2e8a0
30 changed files with 2516 additions and 782 deletions
+21 -1
View File
@@ -66,6 +66,7 @@ export const defaultShortcuts: Record<string, ShortcutBinding> = {
interface SettingsData {
audioQuality: AudioQuality;
downloadPath: string;
localMusicPaths: string[];
theme: ThemeColor;
appearance: Appearance;
closeAction: CloseAction;
@@ -87,6 +88,7 @@ function loadSettings(): SettingsData {
return {
audioQuality: parsed.audioQuality || 'standard',
downloadPath: parsed.downloadPath || '',
localMusicPaths: parsed.localMusicPaths || [],
theme: validThemes.includes(parsed.theme.slice(6)) ? parsed.theme.slice(6) : 'blue',
appearance: 'light',
closeAction: parsed.closeAction || 'ask',
@@ -98,6 +100,7 @@ function loadSettings(): SettingsData {
return {
audioQuality: parsed.audioQuality || 'standard',
downloadPath: parsed.downloadPath || '',
localMusicPaths: parsed.localMusicPaths || [],
theme: validThemes.includes(theme) ? theme : 'blue',
appearance,
closeAction: parsed.closeAction || 'ask',
@@ -110,6 +113,7 @@ function loadSettings(): SettingsData {
return {
audioQuality: 'standard',
downloadPath: '',
localMusicPaths: [],
theme: 'blue',
appearance: 'dark',
closeAction: 'ask',
@@ -124,6 +128,7 @@ export const useSettingsStore = defineStore('settings', () => {
const audioQuality = ref<AudioQuality>(saved.audioQuality);
const downloadPath = ref<string>(saved.downloadPath);
const localMusicPaths = ref<string[]>(saved.localMusicPaths);
const theme = ref<ThemeColor>(saved.theme);
const appearance = ref<Appearance>(saved.appearance);
const closeAction = ref<CloseAction>(saved.closeAction || 'ask');
@@ -143,6 +148,16 @@ export const useSettingsStore = defineStore('settings', () => {
downloadPath.value = p;
}
function addLocalMusicPath(p: string) {
if (!localMusicPaths.value.includes(p)) {
localMusicPaths.value = [...localMusicPaths.value, p];
}
}
function removeLocalMusicPath(p: string) {
localMusicPaths.value = localMusicPaths.value.filter(v => v !== p);
}
function setTheme(t: ThemeColor) {
theme.value = t;
}
@@ -170,6 +185,7 @@ export const useSettingsStore = defineStore('settings', () => {
function resetAll() {
audioQuality.value = 'standard';
downloadPath.value = '';
localMusicPaths.value = [];
theme.value = 'blue';
appearance.value = 'dark';
closeAction.value = 'ask';
@@ -178,10 +194,11 @@ export const useSettingsStore = defineStore('settings', () => {
volume.value = 100;
}
watch([audioQuality, downloadPath, theme, appearance, closeAction, shortcuts, outputDevice, volume], () => {
watch([audioQuality, downloadPath, localMusicPaths, theme, appearance, closeAction, shortcuts, outputDevice, volume], () => {
const data: SettingsData = {
audioQuality: audioQuality.value,
downloadPath: downloadPath.value,
localMusicPaths: localMusicPaths.value,
theme: theme.value,
appearance: appearance.value,
closeAction: closeAction.value,
@@ -195,6 +212,7 @@ export const useSettingsStore = defineStore('settings', () => {
return {
audioQuality,
downloadPath,
localMusicPaths,
theme,
appearance,
dataTheme,
@@ -204,6 +222,8 @@ export const useSettingsStore = defineStore('settings', () => {
volume,
setAudioQuality,
setDownloadPath,
addLocalMusicPath,
removeLocalMusicPath,
setTheme,
setAppearance,
setCloseAction,