feat: v0.8.0 详情页架构重构、皮肤系统、侧边栏交互升级、底层模块拆分

- 详情页统一 DetailLayout 组件(常驻头部 + 独立滚动 + 紧凑态动画)
- 歌曲/评论 tab 切换(懒加载 + 保活),专辑模糊搜索
- 皮肤系统 skins.ts(19 语义色 + 14 预设 + 自定义皮肤 + 壁纸)
- 设置页皮肤模块(外观/主题色/皮肤编辑器/壁纸选择)
- 可折叠侧边栏 + TopBar + 折叠态歌单浮层(高度不越 PlayerBar)
- 首页扩展至 6 区块 + 通用卡片组件 + 虚拟歌曲列表
- audio.rs 拆为 8 模块,api.ts 拆为 api/ 目录,player store 拆为 4 store
- 修复播放竞态、seek 暂停、评论提前加载、紧凑态卡死等
This commit is contained in:
2026-06-26 21:05:51 +08:00
parent dcfada6940
commit 263e9b31d9
74 changed files with 5843 additions and 2449 deletions
+166 -189
View File
@@ -1,206 +1,183 @@
<template>
<nav class="w-56 flex-shrink-0 flex flex-col" :style="sidebarBgStyle">
<div class="flex-1 p-4 overflow-y-auto min-h-0">
<div class="flex flex-col min-h-full">
<div class="space-y-0.5">
<router-link to="/"
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-content-2 hover:text-content hover:bg-subtle"
active-class="!text-content !bg-muted">
<IconHome class="w-[18px] h-[18px]" />
推荐
</router-link>
<router-link to="/discover"
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-content-2 hover:text-content hover:bg-subtle"
active-class="!text-content !bg-muted">
<IconSearch class="w-[18px] h-[18px]" />
发现
</router-link>
<button
@click="openRoamFromSidebar"
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-content-2 hover:text-content hover:bg-subtle w-full text-left"
>
<IconRadio class="w-[18px] h-[18px]" />
漫游
</button>
<!-- 抽屉模式遮罩 + 滑出面板 -->
<template v-if="mode === 'drawer'">
<Transition name="fade">
<div v-if="ui.drawerOpen" class="fixed inset-0 bg-black/50 z-40" @click="ui.closeDrawer()"></div>
</Transition>
<Transition name="slide">
<nav v-if="ui.drawerOpen" class="fixed left-0 top-0 bottom-0 w-64 z-50 flex flex-col shadow-2xl backdrop-blur-md" :style="sidebarBgStyle">
<!-- Logo 区域 -->
<div class="h-12 flex items-center flex-shrink-0 px-4 gap-2" data-tauri-drag-region>
<IconMusic class="w-7 h-7 text-accent flex-shrink-0" />
<span class="text-base font-bold text-content truncate">Nekosonic</span>
</div>
<SidebarContent :collapsed="false" @navigate="ui.closeDrawer()" />
</nav>
</Transition>
</template>
<div class="mt-4 mb-1 pt-2">
<p class="text-xs text-content-3 px-3 mb-1">我的</p>
<div class="space-y-0.5">
<router-link to="/favorites"
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-content-2 hover:text-content hover:bg-subtle"
active-class="!text-content !bg-muted">
<IconHeart class="w-[18px] h-[18px]" />
我喜欢的音乐
</router-link>
<router-link to="/recent"
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-content-2 hover:text-content hover:bg-subtle"
active-class="!text-content !bg-muted">
<IconClock class="w-[18px] h-[18px]" />
最近播放
</router-link>
<router-link to="/local-music"
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-content-2 hover:text-content hover:bg-subtle"
active-class="!text-content !bg-muted">
<IconMusic class="w-[18px] h-[18px]" />
本地音乐
</router-link>
<router-link to="/downloaded-music"
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-content-2 hover:text-content hover:bg-subtle"
active-class="!text-content !bg-muted">
<IconDownload class="w-[18px] h-[18px]" />
下载音乐
</router-link>
<router-link v-if="userStore.isLoggedIn" to="/cloud-music"
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-content-2 hover:text-content hover:bg-subtle"
active-class="!text-content !bg-muted">
<IconCloud class="w-[18px] h-[18px]" />
音乐云盘
</router-link>
</div>
</div>
<div class="mt-4 mb-1 pt-2" v-if="userStore.isLoggedIn">
<div class="flex items-center justify-between px-3 mb-1 cursor-pointer group"
@click="showCreatedPlaylists = !showCreatedPlaylists">
<p class="text-xs text-content-3">我的歌单</p>
<IconChevronRight class="w-3 h-3 text-content-3 transition-transform" :class="{ 'rotate-90': showCreatedPlaylists }" />
</div>
<div v-show="showCreatedPlaylists" class="space-y-0.5">
<div v-for="pl in createdPlaylists" :key="pl.id" @click="goPlaylist(pl.id)"
class="flex items-center gap-2.5 px-2 py-1.5 rounded-lg cursor-pointer transition-all duration-200"
:class="isPlaylistActive(pl.id) ? 'bg-muted' : 'hover:bg-subtle'">
<img :src="pl.coverImgUrl + '?param=80y80'" class="w-8 h-8 rounded object-cover flex-shrink-0" />
<span class="text-sm truncate"
:class="isPlaylistActive(pl.id) ? 'text-content font-medium' : 'text-content-2'">{{ pl.name }}</span>
</div>
</div>
</div>
<div class="mt-4 mb-1 pt-2" v-if="userStore.isLoggedIn">
<div class="flex items-center justify-between px-3 mb-1 cursor-pointer group"
@click="showSubPlaylists = !showSubPlaylists">
<p class="text-xs text-content-3">收藏的歌单</p>
<IconChevronRight class="w-3 h-3 text-content-3 transition-transform" :class="{ 'rotate-90': showSubPlaylists }" />
</div>
<div v-show="showSubPlaylists" class="space-y-0.5">
<div v-for="pl in subPlaylists" :key="pl.id" @click="goPlaylist(pl.id)"
class="flex items-center gap-2.5 px-2 py-1.5 rounded-lg cursor-pointer transition-all duration-200"
:class="isPlaylistActive(pl.id) ? 'bg-muted' : 'hover:bg-subtle'">
<img :src="pl.coverImgUrl + '?param=80y80'" class="w-8 h-8 rounded object-cover flex-shrink-0" />
<span class="text-sm truncate"
:class="isPlaylistActive(pl.id) ? 'text-content font-medium' : 'text-content-2'">{{ pl.name }}</span>
</div>
</div>
</div>
<div class="mt-auto pt-4" :class="player.currentSong ? 'pb-20' : 'pb-2'">
<div class="px-1">
<router-link to="/settings"
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-content-2 hover:text-content hover:bg-subtle"
active-class="!text-content !bg-muted">
<IconSettings class="w-[18px] h-[18px]" />
设置
</router-link>
</div>
<div v-if="!userStore.isLoggedIn" class="mt-3 p-3 rounded-xl bg-subtle/60">
<p class="text-xs text-content-3 mb-2">强烈建议登录以提升体验</p>
<router-link to="/login"
class="flex items-center justify-center gap-2 w-full px-4 py-2 rounded-lg bg-accent hover:bg-accent-hover transition text-sm font-medium text-white">
<IconLogIn class="w-4 h-4" />
立即登录
</router-link>
</div>
<div v-else class="flex items-center gap-3 px-2 mt-3">
<img :src="userStore.user?.avatarUrl" class="w-8 h-8 rounded-full ring-2 ring-accent/50" />
<div class="min-w-0">
<p class="text-sm font-medium truncate">{{ userStore.user?.nickname }}</p>
<button @click="userStore.logout(); player.stop()"
class="text-xs text-content-3 hover:text-danger transition">退出登录</button>
</div>
</div>
</div>
</div>
<!-- 展开/折叠模式常驻侧栏透明背景靠右边框分隔 -->
<nav
v-else
class="relative flex-shrink-0 flex flex-col transition-all duration-300 border-r border-line-2/40"
:class="mode === 'collapsed' ? 'w-16' : 'w-56'"
>
<!-- Logo 区域高度与 TopBar 一致用于对齐 -->
<div
class="h-12 flex items-center flex-shrink-0"
:class="mode === 'collapsed' ? 'justify-center' : 'px-4 gap-2'"
data-tauri-drag-region
>
<IconMusic class="w-7 h-7 text-accent flex-shrink-0 cursor-pointer" @click="router.push('/')" />
<span v-if="mode === 'expanded'" class="text-base font-bold text-content truncate cursor-pointer" @click="router.push('/')">Nekosonic</span>
</div>
<!-- 导航内容 -->
<SidebarContent :collapsed="mode === 'collapsed'" />
<!-- 折叠/展开触发器复刻 naive-ui n-layout-toggle-barSPlayer 同款
两根竖条中间 4px 重叠无可见间隙默认含蓄低饱和不抢聚焦
hover 时两根竖条各自旋转在中间分开形成 < > 箭头
展开态 <指向左折叠方向折叠态 >指向右展开方向
命中区 44×88加大 hover 容错无背景色仅靠竖条亮度变化反馈 -->
<button
@click="ui.toggleSidebar()"
class="sider-trigger"
:class="mode === 'expanded' ? 'is-expanded' : 'is-collapsed'"
:title="mode === 'expanded' ? '折叠侧边栏' : '展开侧边栏'"
aria-label="切换侧边栏"
>
<span class="toggle-bar">
<span class="bar bar-top"></span>
<span class="bar bar-bottom"></span>
</span>
</button>
</nav>
</template>
<script setup lang="ts">
import { ref, watch, onMounted, computed } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useUserStore } from '../stores/user';
import { usePlayerStore } from '../stores/player';
import { computed } from 'vue';
import { useRouter } from 'vue-router';
import { useUiStore, type SidebarMode } from '../stores/ui';
import { useSettingsStore } from '../stores/settings';
import { MusicApi } from '../api';
import IconHome from '~icons/lucide/home';
import IconSearch from '~icons/lucide/search';
import IconRadio from '~icons/lucide/radio';
import IconHeart from '~icons/lucide/heart';
import IconSettings from '~icons/lucide/settings';
import IconLogIn from '~icons/lucide/log-in';
import IconChevronRight from '~icons/lucide/chevron-right';
import IconClock from '~icons/lucide/clock';
import SidebarContent from './SidebarContent.vue';
import IconMusic from '~icons/lucide/music';
import IconCloud from '~icons/lucide/cloud';
import IconDownload from '~icons/lucide/download';
const router = useRouter();
const route = useRoute();
const userStore = useUserStore();
const player = usePlayerStore();
const ui = useUiStore();
const settings = useSettingsStore();
const router = useRouter();
// 有壁纸时侧栏轻微半透明区分区域,无壁纸时保持原样
defineProps<{
mode: SidebarMode;
}>();
// 侧边栏透明:无壁纸时继承主容器背景,有壁纸时透出壁纸
// 仅抽屉模式需要半透明背景 + 模糊(浮在主内容之上)
const sidebarBgStyle = computed(() => {
if (settings.currentWallpaper.path) return {}; // 有壁纸时透明,由遮罩层统一提供背景
return { backgroundColor: settings.currentColors.surface };
});
const createdPlaylists = ref<any[]>([]);
const subPlaylists = ref<any[]>([]);
const showCreatedPlaylists = ref(true);
const showSubPlaylists = ref(true);
async function loadPlaylists() {
if (!userStore.isLoggedIn || !userStore.user) return;
try {
const jsonStr: string = await MusicApi.userPlaylist(userStore.user.userId);
const data = JSON.parse(jsonStr);
createdPlaylists.value = (data.playlist || []).filter((p: any) => !p.subscribed).slice(1);
subPlaylists.value = (data.playlist || []).filter((p: any) => p.subscribed);
} catch { /* 忽略 */ }
}
function goPlaylist(id: number) {
router.push({ name: 'playlist', params: { id } });
}
function isPlaylistActive(id: number): boolean {
return route.name === 'playlist' && Number(route.params.id) === id;
}
async function openRoamFromSidebar() {
if (!userStore.isLoggedIn) {
router.push('/login');
return;
}
if (player.isFmMode) {
player.openRoamDrawer();
} else {
await player.loadFm();
}
}
watch(() => userStore.isLoggedIn, (val) => {
if (val) {
loadPlaylists();
player.loadLikedIds();
}
});
onMounted(() => {
if (userStore.isLoggedIn) {
loadPlaylists();
if (settings.currentWallpaper.path) {
return { backgroundColor: 'rgba(0, 0, 0, 0.2)' };
}
return {};
});
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.slide-enter-active,
.slide-leave-active {
transition: transform 0.25s ease;
}
.slide-enter-from,
.slide-leave-to {
transform: translateX(-100%);
}
/* 复刻 naive-ui n-layout-toggle-barSPlayer 同款)实现细节
参考源码:naive-ui es/layout/src/styles/layout-sider.cssr.mjs
关键点:
1. 两根竖条 width:4 height:38 left:14top=0 / top=34 → 中间 4px 重叠(无可见间隙)
2. hover 时各自旋转 ±12deg + scale(1.15) + translateY(±2px)
两根在中间分开、两端反向旋转,拼出 < 或 > 箭头
3. 展开态(左 sider 未折叠)→ <(指向左,折叠方向)
折叠态 → >(指向右,展开方向)
4. 默认低饱和(--c-content-3 + opacity),hover 变亮;无背景色
5. 命中区 44×88,加大 hover 容错 */
.sider-trigger {
position: absolute;
top: 50%;
right: -22px;
transform: translateY(-50%);
z-index: 30;
width: 44px;
height: 88px;
padding: 0;
border: none;
background: transparent;
cursor: pointer;
}
/* 双竖条容器:居中于命中区(尺寸沿用 naive-ui 32×72 比例,整体放大到 44×88)
透明度加在容器上而非每根竖条上:两根实色竖条重叠时颜色不会叠加变深,
整体随容器统一淡入淡出(参考 SPlayer .n-layout-toggle-bar { opacity:.3; &:hover{opacity:1} } */
.toggle-bar {
position: relative;
width: 44px;
height: 88px;
opacity: 0.5;
transition: opacity 0.3s ease;
}
.sider-trigger:hover .toggle-bar {
opacity: 1;
}
/* 单根竖条:4×38,圆角,水平居中于 44 宽容器(left:20)
实色背景(无自身 opacity),两根垂直排列,中间 4px 重叠(无可见间隙):
总高度 = 38 + 38 - 4 = 72,在 88 容器中垂直居中 → 起始 top = (88-72)/2 = 8
bar-top: top 8(占 8..46);bar-bottom: top 42(占 42..80),重叠区 42..46 */
.bar {
position: absolute;
width: 4px;
height: 38px;
left: 20px;
border-radius: 2px;
background-color: var(--c-content-3);
transition:
transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
background-color 0.3s ease;
}
.bar-top { top: 8px; }
.bar-bottom { top: 42px; }
/* hover 时两根竖条变亮(透明度由容器统一处理) */
.sider-trigger:hover .bar {
background-color: var(--c-content);
}
/* 展开态 hover → < 箭头(指向左,折叠方向)
naive-ui 公式:top = rotate(12deg) scale(1.15) translateY(-2px)
bottom = rotate(-12deg) scale(1.15) translateY(2px)
两根中间端反向旋转分开、远端继续向外,形成 < */
.is-expanded:hover .bar-top {
transform: rotate(12deg) scale(1.15) translateY(-2px);
}
.is-expanded:hover .bar-bottom {
transform: rotate(-12deg) scale(1.15) translateY(2px);
}
/* 折叠态 hover → > 箭头(指向右,展开方向)
naive-ui 公式:top = rotate(-12deg) scale(1.15) translateY(-2px)
bottom = rotate(12deg) scale(1.15) translateY(2px) */
.is-collapsed:hover .bar-top {
transform: rotate(-12deg) scale(1.15) translateY(-2px);
}
.is-collapsed:hover .bar-bottom {
transform: rotate(12deg) scale(1.15) translateY(2px);
}
</style>