mirror of
https://github.com/atdunbg/Nekosonic-Music.git
synced 2026-08-26 00:54:17 +08:00
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:
+166
-189
@@ -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-bar(SPlayer 同款)
|
||||
两根竖条,中间 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-bar(SPlayer 同款)实现细节
|
||||
参考源码:naive-ui es/layout/src/styles/layout-sider.cssr.mjs
|
||||
关键点:
|
||||
1. 两根竖条 width:4 height:38 left:14,top=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>
|
||||
|
||||
Reference in New Issue
Block a user