mirror of
https://github.com/algerkong/AlgerMusicPlayer.git
synced 2026-04-14 06:30:49 +08:00
refactor: 重构首页 UI
This commit is contained in:
@@ -1,166 +0,0 @@
|
||||
<template>
|
||||
<!-- 歌单分类列表 -->
|
||||
<div class="play-list-type">
|
||||
<div class="title" :class="setAnimationClass('animate__fadeInLeft')">
|
||||
{{ t('comp.playlistType.title') }}
|
||||
</div>
|
||||
<div>
|
||||
<template v-for="(item, index) in playlistCategory?.sub" :key="item.name">
|
||||
<span
|
||||
v-show="isShowAllPlaylistCategory || index <= 19 || isHiding"
|
||||
class="play-list-type-item"
|
||||
:class="
|
||||
setAnimationClass(
|
||||
index <= 19
|
||||
? 'animate__bounceIn'
|
||||
: !isShowAllPlaylistCategory
|
||||
? 'animate__backOutLeft'
|
||||
: 'animate__bounceIn'
|
||||
) +
|
||||
' ' +
|
||||
'type-item-' +
|
||||
index
|
||||
"
|
||||
:style="getAnimationDelay(index)"
|
||||
@click="handleClickPlaylistType(item.name)"
|
||||
>{{ item.name }}</span
|
||||
>
|
||||
</template>
|
||||
<div
|
||||
class="play-list-type-showall"
|
||||
:class="setAnimationClass('animate__bounceIn')"
|
||||
:style="
|
||||
setAnimationDelay(
|
||||
!isShowAllPlaylistCategory ? 25 : playlistCategory?.sub.length || 100 + 30
|
||||
)
|
||||
"
|
||||
@click="handleToggleShowAllPlaylistCategory"
|
||||
>
|
||||
{{
|
||||
!isShowAllPlaylistCategory ? t('comp.playlistType.showAll') : t('comp.playlistType.hide')
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { getPlaylistCategory } from '@/api/home';
|
||||
import type { IPlayListSort } from '@/types/playlist';
|
||||
import { setAnimationClass, setAnimationDelay } from '@/utils';
|
||||
|
||||
const { t } = useI18n();
|
||||
// 歌单分类
|
||||
const playlistCategory = ref<IPlayListSort>();
|
||||
// 是否显示全部歌单分类
|
||||
const isShowAllPlaylistCategory = ref<boolean>(false);
|
||||
const DELAY_TIME = 40;
|
||||
const getAnimationDelay = computed(() => {
|
||||
return (index: number) => {
|
||||
if (index <= 19) {
|
||||
return setAnimationDelay(index, DELAY_TIME);
|
||||
}
|
||||
if (!isShowAllPlaylistCategory.value) {
|
||||
const nowIndex = (playlistCategory.value?.sub.length || 0) - index;
|
||||
return setAnimationDelay(nowIndex, DELAY_TIME);
|
||||
}
|
||||
return setAnimationDelay(index - 19, DELAY_TIME);
|
||||
};
|
||||
});
|
||||
|
||||
watch(isShowAllPlaylistCategory, (newVal) => {
|
||||
if (!newVal) {
|
||||
const elements = playlistCategory.value?.sub.map((_, index) =>
|
||||
document.querySelector(`.type-item-${index}`)
|
||||
) as HTMLElement[];
|
||||
elements
|
||||
.slice(20)
|
||||
.reverse()
|
||||
.forEach((element, index) => {
|
||||
if (element) {
|
||||
setTimeout(
|
||||
() => {
|
||||
(element as HTMLElement).style.position = 'absolute';
|
||||
},
|
||||
index * DELAY_TIME + 400
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
setTimeout(
|
||||
() => {
|
||||
isHiding.value = false;
|
||||
document.querySelectorAll('.play-list-type-item').forEach((element) => {
|
||||
if (element) {
|
||||
console.log('element', element);
|
||||
(element as HTMLElement).style.position = 'none';
|
||||
}
|
||||
});
|
||||
},
|
||||
(playlistCategory.value?.sub.length || 0 - 19) * DELAY_TIME
|
||||
);
|
||||
} else {
|
||||
document.querySelectorAll('.play-list-type-item').forEach((element) => {
|
||||
if (element) {
|
||||
(element as HTMLElement).style.position = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 加载歌单分类
|
||||
const loadPlaylistCategory = async () => {
|
||||
const { data } = await getPlaylistCategory();
|
||||
playlistCategory.value = data;
|
||||
};
|
||||
|
||||
const router = useRouter();
|
||||
const handleClickPlaylistType = (type: string) => {
|
||||
router.push({
|
||||
path: '/list',
|
||||
query: {
|
||||
type
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const isHiding = ref<boolean>(false);
|
||||
const handleToggleShowAllPlaylistCategory = () => {
|
||||
isShowAllPlaylistCategory.value = !isShowAllPlaylistCategory.value;
|
||||
if (!isShowAllPlaylistCategory.value) {
|
||||
isHiding.value = true;
|
||||
}
|
||||
};
|
||||
// 页面初始化
|
||||
onMounted(() => {
|
||||
loadPlaylistCategory();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.title {
|
||||
@apply text-lg font-bold mb-4 text-gray-900 dark:text-white;
|
||||
}
|
||||
.play-list-type {
|
||||
width: 250px;
|
||||
@apply mr-4;
|
||||
&-item,
|
||||
&-showall {
|
||||
@apply bg-light dark:bg-black text-gray-900 dark:text-white;
|
||||
@apply py-2 px-3 mr-3 mb-3 inline-block border border-gray-200 dark:border-gray-700 rounded-xl cursor-pointer hover:bg-green-600 hover:text-white transition;
|
||||
}
|
||||
&-showall {
|
||||
@apply block text-center;
|
||||
}
|
||||
}
|
||||
|
||||
.mobile {
|
||||
.play-list-type {
|
||||
@apply mx-0 w-full;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,115 +0,0 @@
|
||||
<template>
|
||||
<div class="recommend-album">
|
||||
<div class="title" :class="setAnimationClass('animate__fadeInRight')">
|
||||
{{ t('comp.recommendAlbum.title') }}
|
||||
</div>
|
||||
<div class="recommend-album-list">
|
||||
<template v-for="(item, index) in albumData?.albums" :key="item.id">
|
||||
<div
|
||||
v-if="index < 6"
|
||||
class="recommend-album-list-item"
|
||||
:class="setAnimationClass('animate__backInUp')"
|
||||
:style="setAnimationDelay(index, 100)"
|
||||
@click="handleClick(item)"
|
||||
>
|
||||
<n-image
|
||||
class="recommend-album-list-item-img"
|
||||
:src="getImgUrl(item.blurPicUrl, '200y200')"
|
||||
lazy
|
||||
preview-disabled
|
||||
/>
|
||||
<div class="recommend-album-list-item-content">{{ item.name }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { getNewAlbum } from '@/api/home';
|
||||
import { getAlbum } from '@/api/list';
|
||||
import { navigateToMusicList } from '@/components/common/MusicListNavigator';
|
||||
import type { IAlbumNew } from '@/types/album';
|
||||
import { getImgUrl, setAnimationClass, setAnimationDelay } from '@/utils';
|
||||
|
||||
const { t } = useI18n();
|
||||
const albumData = ref<IAlbumNew>();
|
||||
const loadAlbumList = async () => {
|
||||
const { data } = await getNewAlbum();
|
||||
albumData.value = data;
|
||||
};
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const handleClick = async (item: any) => {
|
||||
openAlbum(item);
|
||||
};
|
||||
|
||||
const openAlbum = async (album: any) => {
|
||||
if (!album) return;
|
||||
|
||||
try {
|
||||
const res = await getAlbum(album.id);
|
||||
const { songs, album: albumInfo } = res.data;
|
||||
|
||||
const formattedSongs = songs.map((song: any) => {
|
||||
song.al.picUrl = song.al.picUrl || albumInfo.picUrl;
|
||||
song.picUrl = song.al.picUrl || albumInfo.picUrl || song.picUrl;
|
||||
return song;
|
||||
});
|
||||
|
||||
navigateToMusicList(router, {
|
||||
id: album.id,
|
||||
type: 'album',
|
||||
name: album.name,
|
||||
songList: formattedSongs,
|
||||
listInfo: {
|
||||
...albumInfo,
|
||||
creator: {
|
||||
avatarUrl: albumInfo.artist.img1v1Url,
|
||||
nickname: `${albumInfo.artist.name} - ${albumInfo.company}`
|
||||
},
|
||||
description: albumInfo.description
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取专辑详情失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadAlbumList();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.recommend-album {
|
||||
@apply flex-1 mx-5;
|
||||
.title {
|
||||
@apply text-lg font-bold mb-4 text-gray-900 dark:text-white;
|
||||
}
|
||||
|
||||
.recommend-album-list {
|
||||
@apply grid grid-cols-2 grid-rows-3 gap-2;
|
||||
&-item {
|
||||
@apply rounded-xl overflow-hidden relative;
|
||||
&-img {
|
||||
@apply rounded-xl transition w-full h-full;
|
||||
}
|
||||
&:hover img {
|
||||
filter: brightness(50%);
|
||||
}
|
||||
&-content {
|
||||
@apply w-full h-full opacity-0 transition absolute z-10 top-0 left-0 p-4 text-xl text-white bg-opacity-60 bg-black dark:bg-opacity-60 dark:bg-black;
|
||||
}
|
||||
&-content:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,73 +0,0 @@
|
||||
<template>
|
||||
<div class="recommend-music">
|
||||
<div class="title" :class="setAnimationClass('animate__fadeInLeft')">
|
||||
{{ t('comp.recommendSonglist.title') }}
|
||||
</div>
|
||||
<div
|
||||
v-show="recommendMusic?.result"
|
||||
v-loading="loading"
|
||||
class="recommend-music-list"
|
||||
:class="setAnimationClass('animate__bounceInUp')"
|
||||
>
|
||||
<!-- 推荐音乐列表 -->
|
||||
<template v-for="(item, index) in recommendMusic?.result" :key="item.id">
|
||||
<div
|
||||
:class="setAnimationClass('animate__bounceInUp')"
|
||||
:style="setAnimationDelay(index, 100)"
|
||||
>
|
||||
<song-item :item="item" @play="handlePlay" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { getRecommendMusic } from '@/api/home';
|
||||
import SongItem from '@/components/common/SongItem.vue';
|
||||
import { usePlayerStore } from '@/store/modules/player';
|
||||
import type { IRecommendMusic } from '@/types/music';
|
||||
import { setAnimationClass, setAnimationDelay } from '@/utils';
|
||||
|
||||
const { t } = useI18n();
|
||||
const playerStore = usePlayerStore();
|
||||
// 推荐歌曲
|
||||
const recommendMusic = ref<IRecommendMusic>();
|
||||
const loading = ref(false);
|
||||
|
||||
// 加载推荐歌曲
|
||||
const loadRecommendMusic = async () => {
|
||||
loading.value = true;
|
||||
const { data } = await getRecommendMusic({ limit: 10 });
|
||||
recommendMusic.value = data;
|
||||
loading.value = false;
|
||||
};
|
||||
|
||||
// 页面初始化
|
||||
onMounted(() => {
|
||||
loadRecommendMusic();
|
||||
});
|
||||
|
||||
const handlePlay = () => {
|
||||
if (recommendMusic.value?.result) {
|
||||
playerStore.setPlayList(recommendMusic.value.result);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.title {
|
||||
@apply text-lg font-bold mb-4 text-gray-900 dark:text-white;
|
||||
}
|
||||
.recommend-music {
|
||||
@apply flex-auto;
|
||||
.text-ellipsis {
|
||||
width: 100%;
|
||||
}
|
||||
&-list {
|
||||
@apply rounded-3xl p-2 w-full border border-gray-200 dark:border-gray-700 bg-light dark:bg-black;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,577 +0,0 @@
|
||||
<template>
|
||||
<div class="recommend-singer">
|
||||
<div class="recommend-singer-list">
|
||||
<n-carousel
|
||||
v-if="hotSingerData?.artists.length"
|
||||
slides-per-view="auto"
|
||||
:show-dots="false"
|
||||
:space-between="20"
|
||||
draggable
|
||||
show-arrow
|
||||
:autoplay="false"
|
||||
>
|
||||
<n-carousel-item
|
||||
:class="setAnimationClass('animate__backInRight')"
|
||||
:style="getCarouselItemStyle(0, 100, 6)"
|
||||
>
|
||||
<div v-if="dayRecommendData" class="recommend-singer-item relative">
|
||||
<div
|
||||
:style="
|
||||
setBackgroundImg(getImgUrl(dayRecommendData?.dailySongs[0].al.picUrl, '500y500'))
|
||||
"
|
||||
class="recommend-singer-item-bg"
|
||||
></div>
|
||||
<div
|
||||
class="recommend-singer-item-count p-2 text-base text-gray-200 z-10 cursor-pointer"
|
||||
@click="showDayRecommend"
|
||||
>
|
||||
<div class="font-bold text-lg">
|
||||
{{ t('comp.recommendSinger.title') }}
|
||||
</div>
|
||||
|
||||
<div class="mt-2">
|
||||
<p v-for="item in getDisplayDaySongs.slice(0, 5)" :key="item.id" class="text-el">
|
||||
{{ item.name }}
|
||||
<br />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</n-carousel-item>
|
||||
|
||||
<n-carousel-item
|
||||
v-if="userStore.user && userPlaylist.length"
|
||||
:class="setAnimationClass('animate__backInRight')"
|
||||
:style="getCarouselItemStyleForPlaylist(userPlaylist.length)"
|
||||
>
|
||||
<div class="user-play">
|
||||
<div class="user-play-title mb-3">
|
||||
{{ t('comp.userPlayList.title', { name: userStore.user?.nickname }) }}
|
||||
</div>
|
||||
<div class="user-play-list" :class="getPlaylistGridClass(userPlaylist.length)">
|
||||
<div
|
||||
v-for="item in userPlaylist"
|
||||
:key="item.id"
|
||||
class="user-play-item"
|
||||
@click="openPlaylist(item)"
|
||||
>
|
||||
<div class="user-play-item-img">
|
||||
<img :src="getImgUrl(item.coverImgUrl, '200y200')" alt="" />
|
||||
<div class="user-play-item-title">
|
||||
<div class="user-play-item-title-name">{{ item.name }}</div>
|
||||
|
||||
<div class="user-play-item-list">
|
||||
<div
|
||||
v-for="song in item.tracks"
|
||||
:key="song.id"
|
||||
class="user-play-item-list-name"
|
||||
>
|
||||
{{ song.name }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="user-play-item-count">
|
||||
<div class="user-play-item-count-tag">
|
||||
{{ t('common.songCount', { count: item.trackCount }) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="user-play-item-direct-play" @click.stop="handlePlayPlaylist(item.id)">
|
||||
<i class="iconfont icon-playfill text-xl text-white"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</n-carousel-item>
|
||||
<n-carousel-item
|
||||
v-for="(item, index) in hotSingerData?.artists"
|
||||
:key="item.id"
|
||||
:class="setAnimationClass('animate__backInRight')"
|
||||
:style="getCarouselItemStyle(index + 1, 100, 6)"
|
||||
>
|
||||
<div
|
||||
class="recommend-singer-item relative"
|
||||
:class="setAnimationClass('animate__backInRight')"
|
||||
:style="setAnimationDelay(index + 2, 100)"
|
||||
@click="handleArtistClick(item.id)"
|
||||
>
|
||||
<div
|
||||
:style="
|
||||
setBackgroundImg(getImgUrl(item.picUrl || item.avatar || item.cover, '500y500'))
|
||||
"
|
||||
class="recommend-singer-item-bg"
|
||||
></div>
|
||||
<div class="recommend-singer-item-count p-2 text-base text-gray-200 z-10">
|
||||
{{ t('common.songCount', { count: item.musicSize }) }}
|
||||
</div>
|
||||
<div class="recommend-singer-item-info z-10">
|
||||
<div class="recommend-singer-item-info-name text-el text-right line-clamp-1">
|
||||
{{ item.name }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- 播放按钮(hover时显示) -->
|
||||
<div
|
||||
class="recommend-singer-item-play-overlay"
|
||||
@click.stop="handleArtistClick(item.id)"
|
||||
>
|
||||
<div class="recommend-singer-item-play-btn">
|
||||
<i class="iconfont icon-playfill text-4xl"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</n-carousel-item>
|
||||
</n-carousel>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, ref, watchEffect } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { getHotSinger } from '@/api/home';
|
||||
import { getListDetail } from '@/api/list';
|
||||
import { getMusicDetail } from '@/api/music';
|
||||
import { getUserPlaylist } from '@/api/user';
|
||||
import { navigateToMusicList } from '@/components/common/MusicListNavigator';
|
||||
import { useArtist } from '@/hooks/useArtist';
|
||||
import { usePlayerStore, useRecommendStore, useUserStore } from '@/store';
|
||||
import { Playlist } from '@/types/list';
|
||||
import type { IListDetail } from '@/types/listDetail';
|
||||
import { SongResult } from '@/types/music';
|
||||
import type { IHotSinger } from '@/types/singer';
|
||||
import {
|
||||
getImgUrl,
|
||||
isMobile,
|
||||
setAnimationClass,
|
||||
setAnimationDelay,
|
||||
setBackgroundImg
|
||||
} from '@/utils';
|
||||
|
||||
const userStore = useUserStore();
|
||||
const playerStore = usePlayerStore();
|
||||
const recommendStore = useRecommendStore();
|
||||
const router = useRouter();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
// 歌手信息
|
||||
const hotSingerData = ref<IHotSinger>();
|
||||
const dayRecommendData = computed(() => {
|
||||
if (recommendStore.dailyRecommendSongs.length > 0) {
|
||||
return {
|
||||
dailySongs: recommendStore.dailyRecommendSongs
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const userPlaylist = ref<Playlist[]>([]);
|
||||
|
||||
// 为歌单弹窗添加的状态
|
||||
const playlistLoading = ref(false);
|
||||
const playlistItem = ref<Playlist | null>(null);
|
||||
const playlistDetail = ref<IListDetail | null>(null);
|
||||
|
||||
const { navigateToArtist } = useArtist();
|
||||
|
||||
/**
|
||||
* 获取轮播项的样式
|
||||
* @param index 项目索引(用于动画延迟)
|
||||
* @param delayStep 动画延迟的步长(毫秒)
|
||||
* @param totalItems 总共分成几等分(默认为5)
|
||||
* @param maxWidth 最大宽度(可选,单位为px)
|
||||
* @returns 样式字符串
|
||||
*/
|
||||
const getCarouselItemStyle = (
|
||||
index: number,
|
||||
delayStep: number,
|
||||
totalItems: number,
|
||||
maxWidth?: number
|
||||
) => {
|
||||
if (isMobile.value) {
|
||||
return 'width: 30%;';
|
||||
}
|
||||
const animationDelay = setAnimationDelay(index, delayStep);
|
||||
const width = `calc((100% / ${totalItems}) - 16px)`;
|
||||
const maxWidthStyle = maxWidth ? `max-width: ${maxWidth}px;` : '';
|
||||
|
||||
return `${animationDelay}; width: ${width}; ${maxWidthStyle}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据歌单数量获取轮播项的样式
|
||||
* @param playlistCount 歌单数量
|
||||
* @returns 样式字符串
|
||||
*/
|
||||
const getCarouselItemStyleForPlaylist = (playlistCount: number) => {
|
||||
if (isMobile.value) {
|
||||
return 'width: 100%;';
|
||||
}
|
||||
const animationDelay = setAnimationDelay(1, 100);
|
||||
let width = '';
|
||||
let maxWidth = '';
|
||||
|
||||
switch (playlistCount) {
|
||||
case 1:
|
||||
width = 'calc(100% / 4 - 16px)';
|
||||
maxWidth = 'max-width: 180px;';
|
||||
break;
|
||||
case 2:
|
||||
width = 'calc(100% / 3 - 16px)';
|
||||
maxWidth = 'max-width: 380px;';
|
||||
break;
|
||||
case 3:
|
||||
width = 'calc(100% / 2 - 16px)';
|
||||
maxWidth = 'max-width: 520px;';
|
||||
break;
|
||||
default:
|
||||
width = 'calc(100% / 1 - 16px)';
|
||||
maxWidth = 'max-width: 656px;';
|
||||
}
|
||||
|
||||
return `${animationDelay}; width: ${width}; ${maxWidth}`;
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
loadNonUserData();
|
||||
});
|
||||
|
||||
const loadDayRecommendData = async () => {
|
||||
await recommendStore.fetchDailyRecommendSongs();
|
||||
};
|
||||
|
||||
// 加载不需要登录的数据
|
||||
const loadNonUserData = async () => {
|
||||
try {
|
||||
// 获取每日推荐(仅在用户未登录时加载,已登录用户会通过watchEffect触发loadDayRecommendData)
|
||||
if (!userStore.user) {
|
||||
await loadDayRecommendData();
|
||||
}
|
||||
|
||||
// 获取热门歌手
|
||||
const { data: singerData } = await getHotSinger({ offset: 0, limit: 5 });
|
||||
hotSingerData.value = singerData;
|
||||
} catch (error) {
|
||||
console.error('加载热门歌手数据失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 加载需要登录的数据
|
||||
const loadUserData = async () => {
|
||||
try {
|
||||
if (userStore.user) {
|
||||
const { data: playlistData } = await getUserPlaylist(userStore.user?.userId);
|
||||
// 确保最多只显示4个歌单,并按播放次数排序
|
||||
userPlaylist.value = (playlistData.playlist as Playlist[])
|
||||
.sort((a, b) => b.playCount - a.playCount)
|
||||
.slice(0, 4);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载用户数据失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleArtistClick = (id: number) => {
|
||||
navigateToArtist(id);
|
||||
};
|
||||
const getDisplayDaySongs = computed(() => {
|
||||
if (!dayRecommendData.value) {
|
||||
return [];
|
||||
}
|
||||
return dayRecommendData.value.dailySongs.filter(
|
||||
(song) => !playerStore.dislikeList.includes(song.id)
|
||||
);
|
||||
});
|
||||
|
||||
const showDayRecommend = () => {
|
||||
if (!dayRecommendData.value?.dailySongs) return;
|
||||
|
||||
navigateToMusicList(router, {
|
||||
type: 'dailyRecommend',
|
||||
name: t('comp.recommendSinger.songlist'),
|
||||
songList: getDisplayDaySongs.value,
|
||||
canRemove: false
|
||||
});
|
||||
};
|
||||
|
||||
const openPlaylist = (item: any) => {
|
||||
playlistItem.value = item;
|
||||
playlistLoading.value = true;
|
||||
|
||||
getListDetail(item.id).then((res) => {
|
||||
playlistDetail.value = res.data;
|
||||
playlistLoading.value = false;
|
||||
|
||||
navigateToMusicList(router, {
|
||||
id: item.id,
|
||||
type: 'playlist',
|
||||
name: item.name,
|
||||
songList: res.data.playlist.tracks || [],
|
||||
listInfo: res.data.playlist,
|
||||
canRemove: false
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// 添加直接播放歌单的方法
|
||||
const handlePlayPlaylist = async (id: number) => {
|
||||
try {
|
||||
// 先显示加载状态
|
||||
playlistLoading.value = true;
|
||||
|
||||
// 获取歌单详情
|
||||
const { data } = await getListDetail(id);
|
||||
|
||||
if (data?.playlist) {
|
||||
// 先使用已有的tracks开始播放(这些是已经在歌单详情中返回的前几首歌曲)
|
||||
if (data.playlist.tracks?.length > 0) {
|
||||
// 格式化歌曲列表
|
||||
const initialSongs = data.playlist.tracks.map((track) => ({
|
||||
...track,
|
||||
source: 'netease',
|
||||
picUrl: track.al.picUrl
|
||||
})) as unknown as SongResult[];
|
||||
|
||||
// 设置播放列表
|
||||
playerStore.setPlayList(initialSongs);
|
||||
|
||||
// 开始播放第一首
|
||||
await playerStore.setPlay(initialSongs[0]);
|
||||
|
||||
// 如果有trackIds,异步加载完整歌单
|
||||
if (data.playlist.trackIds?.length > initialSongs.length) {
|
||||
loadFullPlaylist(data.playlist.trackIds, initialSongs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭加载状态
|
||||
playlistLoading.value = false;
|
||||
} catch (error) {
|
||||
console.error('播放歌单失败:', error);
|
||||
playlistLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 异步加载完整歌单
|
||||
const loadFullPlaylist = async (trackIds: { id: number }[], initialSongs: SongResult[]) => {
|
||||
try {
|
||||
// 获取已加载歌曲的ID集合,避免重复加载
|
||||
const loadedIds = new Set(initialSongs.map((song) => song.id));
|
||||
|
||||
// 筛选出未加载的ID
|
||||
const unloadedTrackIds = trackIds
|
||||
.filter((item) => !loadedIds.has(item.id as number))
|
||||
.map((item) => item.id);
|
||||
|
||||
if (unloadedTrackIds.length === 0) return;
|
||||
|
||||
// 分批获取歌曲详情,每批最多获取500首
|
||||
const batchSize = 500;
|
||||
const allSongs = [...initialSongs];
|
||||
|
||||
for (let i = 0; i < unloadedTrackIds.length; i += batchSize) {
|
||||
const batchIds = unloadedTrackIds.slice(i, i + batchSize);
|
||||
if (batchIds.length > 0) {
|
||||
try {
|
||||
const { data: songsData } = await getMusicDetail(batchIds);
|
||||
if (songsData?.songs?.length) {
|
||||
const formattedSongs = songsData.songs.map((item) => ({
|
||||
...item,
|
||||
source: 'netease',
|
||||
picUrl: item.al.picUrl
|
||||
})) as unknown as SongResult[];
|
||||
|
||||
allSongs.push(...formattedSongs);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取批次歌曲详情失败:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新完整的播放列表但保持当前播放的歌曲不变
|
||||
if (allSongs.length > initialSongs.length) {
|
||||
console.log('更新播放列表,总歌曲数:', allSongs.length);
|
||||
playerStore.setPlayList(allSongs);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载完整歌单失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 监听登录状态
|
||||
watchEffect(() => {
|
||||
if (userStore.user) {
|
||||
loadUserData();
|
||||
loadDayRecommendData();
|
||||
}
|
||||
});
|
||||
|
||||
const getPlaylistGridClass = (length: number) => {
|
||||
switch (length) {
|
||||
case 1:
|
||||
return 'one-column';
|
||||
case 2:
|
||||
return 'two-columns';
|
||||
case 3:
|
||||
return 'three-columns';
|
||||
default:
|
||||
return 'four-columns';
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.recommend-singer {
|
||||
&-list {
|
||||
@apply flex;
|
||||
height: 220px;
|
||||
margin-right: 20px;
|
||||
}
|
||||
&-item {
|
||||
@apply flex-1 h-full rounded-3xl p-5 flex flex-col justify-between overflow-hidden relative;
|
||||
cursor: pointer;
|
||||
transition: transform 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
|
||||
&-bg {
|
||||
@apply bg-gray-900 dark:bg-gray-800 bg-no-repeat bg-cover bg-center rounded-3xl absolute w-full h-full top-0 left-0 z-0;
|
||||
filter: brightness(60%);
|
||||
}
|
||||
|
||||
&-info {
|
||||
@apply flex flex-col p-2;
|
||||
&-name {
|
||||
@apply text-gray-100 dark:text-gray-100;
|
||||
}
|
||||
}
|
||||
|
||||
&-count {
|
||||
@apply text-gray-100 dark:text-gray-100;
|
||||
}
|
||||
|
||||
&-play {
|
||||
&-overlay {
|
||||
@apply absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-black/20 z-20 opacity-0 transition-all duration-300 flex items-center justify-center;
|
||||
backdrop-filter: blur(1px);
|
||||
|
||||
.recommend-singer-item:hover & {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&-btn {
|
||||
@apply w-20 h-20 bg-transparent flex justify-center items-center text-white;
|
||||
transform: translateY(50px) scale(0.8);
|
||||
transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
|
||||
.recommend-singer-item:hover & {
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.user-play {
|
||||
@apply bg-light-300 dark:bg-dark-300 rounded-3xl px-4 py-3 h-full;
|
||||
backdrop-filter: blur(20px);
|
||||
&-title {
|
||||
@apply text-gray-900 dark:text-gray-100 font-bold text-lg line-clamp-1;
|
||||
}
|
||||
&-list {
|
||||
@apply grid gap-3 h-full;
|
||||
&.one-column {
|
||||
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||
.user-play-item {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
&.two-columns {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
.user-play-item {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
&.three-columns {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
.user-play-item {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
&.four-columns {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
.user-play-item {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
&-item {
|
||||
@apply rounded-2xl overflow-hidden flex flex-col;
|
||||
height: 176px;
|
||||
|
||||
&-img {
|
||||
@apply relative cursor-pointer transition-all duration-300;
|
||||
height: 0;
|
||||
width: 100%;
|
||||
padding-bottom: 100%; /* 确保宽高比为1:1,即正方形 */
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
&:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
img {
|
||||
@apply absolute inset-0 w-full h-full object-cover;
|
||||
}
|
||||
}
|
||||
&-title {
|
||||
@apply absolute top-0 left-0 right-0 p-2 bg-gradient-to-b from-black/70 to-transparent z-10;
|
||||
&-name {
|
||||
@apply text-white font-medium text-sm line-clamp-3;
|
||||
}
|
||||
}
|
||||
&-count {
|
||||
@apply absolute bottom-2 left-2 z-10;
|
||||
&-tag {
|
||||
@apply px-2 py-0.5 text-xs text-white bg-black/50 backdrop-blur-sm rounded-full;
|
||||
}
|
||||
}
|
||||
&-direct-play {
|
||||
@apply absolute bottom-2 right-2 z-20 w-10 h-10 rounded-full bg-green-600 hover:bg-green-700 flex items-center justify-center cursor-pointer transform scale-90 hover:scale-100 transition-all;
|
||||
&:hover {
|
||||
@apply shadow-lg;
|
||||
}
|
||||
}
|
||||
&-play-btn {
|
||||
@apply flex items-center justify-center;
|
||||
transform: scale(0.8);
|
||||
transition: transform 0.3s ease;
|
||||
|
||||
.user-play-item:hover & {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.mobile {
|
||||
.recommend-singer {
|
||||
&-list {
|
||||
height: 180px;
|
||||
@apply ml-4;
|
||||
}
|
||||
&-item {
|
||||
@apply p-2 rounded-xl;
|
||||
&-bg {
|
||||
@apply rounded-xl;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
199
src/renderer/views/home/components/HomeAlbumSection.vue
Normal file
199
src/renderer/views/home/components/HomeAlbumSection.vue
Normal file
@@ -0,0 +1,199 @@
|
||||
<template>
|
||||
<section class="album-section">
|
||||
<!-- Section Header -->
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<h2 class="text-xl font-bold tracking-tight text-neutral-900 md:text-2xl dark:text-white">
|
||||
{{ title }}
|
||||
</h2>
|
||||
<div class="h-1.5 w-1.5 rounded-full bg-primary" />
|
||||
</div>
|
||||
<button
|
||||
class="group flex items-center gap-1.5 text-sm font-semibold text-neutral-400 transition-colors hover:text-primary dark:text-neutral-500 dark:hover:text-white"
|
||||
@click="$emit('more')"
|
||||
>
|
||||
<span>{{ t('comp.more') }}</span>
|
||||
<i class="ri-arrow-right-s-line text-base transition-transform group-hover:translate-x-1" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading Skeleton -->
|
||||
<div v-if="loading" class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-6">
|
||||
<div v-for="i in displayCount" :key="i" class="space-y-3">
|
||||
<div class="aspect-square animate-pulse rounded-2xl bg-neutral-200 dark:bg-neutral-800" />
|
||||
<div class="h-4 w-3/4 animate-pulse rounded bg-neutral-200 dark:bg-neutral-800" />
|
||||
<div class="h-3 w-1/2 animate-pulse rounded bg-neutral-200 dark:bg-neutral-800" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Album Grid -->
|
||||
<div
|
||||
v-else-if="displayAlbums.length > 0"
|
||||
class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-6"
|
||||
>
|
||||
<home-list-item
|
||||
v-for="(album, index) in displayAlbums"
|
||||
:key="album.id"
|
||||
:cover="album.picUrl"
|
||||
:title="album.name"
|
||||
:subtitle="getArtistNames(album)"
|
||||
:tracks="albumTracksMap[album.id] || []"
|
||||
:animation-delay="calculateAnimationDelay(index, 0.04)"
|
||||
@click="handleAlbumClick(album)"
|
||||
@play="playAlbum(album)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else class="flex flex-col items-center justify-center py-20 text-neutral-400">
|
||||
<i class="ri-album-line mb-4 text-5xl opacity-20" />
|
||||
<p class="text-sm font-medium">{{ t('comp.newAlbum.empty') }}</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { getTopAlbum } from '@/api/home';
|
||||
import { getAlbum } from '@/api/list';
|
||||
import { navigateToMusicList } from '@/components/common/MusicListNavigator';
|
||||
import { usePlayerCoreStore } from '@/store/modules/playerCore';
|
||||
import { usePlaylistStore } from '@/store/modules/playlist';
|
||||
import { calculateAnimationDelay, isElectron } from '@/utils';
|
||||
|
||||
import HomeListItem from './HomeListItem.vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title: string;
|
||||
limit?: number;
|
||||
columns?: number;
|
||||
rows?: number;
|
||||
}>(),
|
||||
{
|
||||
limit: 10,
|
||||
columns: 5,
|
||||
rows: 2
|
||||
}
|
||||
);
|
||||
|
||||
defineEmits<{
|
||||
(e: 'more'): void;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const albums = ref<any[]>([]);
|
||||
const loading = ref(true);
|
||||
const albumTracksMap = reactive<Record<number, any[]>>({});
|
||||
|
||||
// Calculate display count to fill exactly N rows
|
||||
const displayCount = computed(() => props.columns * props.rows);
|
||||
|
||||
const displayAlbums = computed(() => {
|
||||
const count = displayCount.value;
|
||||
return albums.value.slice(0, count);
|
||||
});
|
||||
|
||||
const fetchAlbums = async () => {
|
||||
try {
|
||||
const { data } = await getTopAlbum({ limit: props.limit || displayCount.value + 5 });
|
||||
if (data.code === 200) {
|
||||
albums.value = data.weekData || data.monthData || data.albums || [];
|
||||
// Preload tracks for displayed albums (Electron only)
|
||||
if (isElectron) {
|
||||
preloadAllTracks();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch albums:', error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const preloadAllTracks = async () => {
|
||||
const albumsToLoad = displayAlbums.value;
|
||||
|
||||
// Load tracks in parallel with concurrency limit
|
||||
const batchSize = 4;
|
||||
for (let i = 0; i < albumsToLoad.length; i += batchSize) {
|
||||
const batch = albumsToLoad.slice(i, i + batchSize);
|
||||
await Promise.all(
|
||||
batch.map(async (album) => {
|
||||
if (albumTracksMap[album.id]) return;
|
||||
try {
|
||||
const { data } = await getAlbum(album.id);
|
||||
if (data.code === 200 && data.songs) {
|
||||
albumTracksMap[album.id] = data.songs.slice(0, 3).map((s: any) => ({
|
||||
id: s.id,
|
||||
name: s.name
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.debug('Failed to load tracks for album:', album.id, error);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const getArtistNames = (album: any) => {
|
||||
if (album.artists) {
|
||||
return album.artists.map((ar: any) => ar.name).join(' / ');
|
||||
}
|
||||
if (album.artist) {
|
||||
return album.artist.name;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const handleAlbumClick = async (album: any) => {
|
||||
try {
|
||||
navigateToMusicList(router, {
|
||||
id: album.id,
|
||||
type: 'album',
|
||||
name: album.name,
|
||||
listInfo: {
|
||||
...album,
|
||||
coverImgUrl: album.picUrl
|
||||
},
|
||||
canRemove: false
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to navigate to album:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const playAlbum = async (album: any) => {
|
||||
try {
|
||||
const { data } = await getAlbum(album.id);
|
||||
if (data.code === 200 && data.songs?.length > 0) {
|
||||
const playerCore = usePlayerCoreStore();
|
||||
const playlistStore = usePlaylistStore();
|
||||
|
||||
const playlist = data.songs.map((s: any) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
picUrl: s.al?.picUrl || album.picUrl,
|
||||
source: 'netease',
|
||||
song: s,
|
||||
...s,
|
||||
playLoading: false
|
||||
}));
|
||||
|
||||
playlistStore.setPlayList(playlist, false, false);
|
||||
await playerCore.handlePlayMusic(playlist[0], true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to play album:', error);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchAlbums();
|
||||
});
|
||||
</script>
|
||||
183
src/renderer/views/home/components/HomeArtists.vue
Normal file
183
src/renderer/views/home/components/HomeArtists.vue
Normal file
@@ -0,0 +1,183 @@
|
||||
<template>
|
||||
<section class="artists-section">
|
||||
<!-- Loading Skeleton -->
|
||||
<div v-if="loading" class="artists-scroll flex gap-6 md:gap-8 overflow-x-hidden pb-4">
|
||||
<div v-for="i in 8" :key="i" class="artist-skeleton flex flex-col items-center gap-3">
|
||||
<div
|
||||
class="h-20 w-20 md:h-24 md:w-24 lg:h-28 lg:w-28 animate-pulse rounded-full bg-neutral-200 dark:bg-neutral-800"
|
||||
/>
|
||||
<div class="h-3 w-16 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Artists Horizontal Scroll (Optimized with snap points) -->
|
||||
<div
|
||||
v-else
|
||||
ref="scrollContainer"
|
||||
class="artists-scroll relative -mx-4 sm:-mx-6 lg:-mx-8 px-4 sm:px-6 lg:px-8 overflow-x-auto overflow-y-hidden pt-2"
|
||||
@wheel="handleWheel"
|
||||
>
|
||||
<div class="artists-track flex gap-6 md:gap-8 lg:gap-10">
|
||||
<div
|
||||
v-for="(item, index) in artists"
|
||||
:key="item.id"
|
||||
class="artist-item animate-item group flex flex-shrink-0 snap-start flex-col items-center gap-3 md:gap-4 cursor-pointer"
|
||||
:style="{ animationDelay: calculateAnimationDelay(index, 0.04) }"
|
||||
@click="navigateToArtist(item.id)"
|
||||
>
|
||||
<!-- Artist Avatar -->
|
||||
<div
|
||||
class="artist-avatar relative h-20 w-20 sm:h-24 sm:w-24 md:h-28 md:w-28 lg:h-32 lg:w-32 overflow-hidden rounded-full bg-neutral-100 dark:bg-neutral-800 shadow-lg transition-all duration-500 group-hover:scale-110 group-hover:shadow-2xl group-hover:shadow-primary/20"
|
||||
>
|
||||
<img
|
||||
:src="getImgUrl(item.picUrl, '300y300')"
|
||||
class="h-full w-full object-cover grayscale-[0.15] transition-all duration-700 group-hover:grayscale-0 group-hover:brightness-110"
|
||||
loading="lazy"
|
||||
:alt="item.name"
|
||||
/>
|
||||
<!-- Gradient Overlay on Hover -->
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-tr from-primary/30 via-primary/10 to-transparent opacity-0 transition-opacity duration-500 group-hover:opacity-100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Artist Name -->
|
||||
<span
|
||||
class="artist-name text-xs sm:text-sm md:text-base font-semibold text-neutral-700 dark:text-neutral-300 transition-all duration-300 group-hover:text-primary dark:group-hover:text-white group-hover:scale-105"
|
||||
>
|
||||
{{ item.name }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scroll Indicators (Optional visual feedback) -->
|
||||
<div
|
||||
class="scroll-fade-left pointer-events-none absolute left-0 top-0 bottom-0 w-12 bg-gradient-to-r from-white dark:from-black to-transparent opacity-0 transition-opacity"
|
||||
:class="{ 'opacity-100': showLeftFade }"
|
||||
/>
|
||||
<div
|
||||
class="scroll-fade-right pointer-events-none absolute right-0 top-0 bottom-0 w-12 bg-gradient-to-l from-white dark:from-black to-transparent opacity-0 transition-opacity"
|
||||
:class="{ 'opacity-100': showRightFade }"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { getHotSinger } from '@/api/home';
|
||||
import { useArtist } from '@/hooks/useArtist';
|
||||
import { calculateAnimationDelay, getImgUrl } from '@/utils';
|
||||
|
||||
const props = defineProps<{
|
||||
title: string;
|
||||
limit?: number;
|
||||
}>();
|
||||
|
||||
const { navigateToArtist } = useArtist();
|
||||
const artists = ref<any[]>([]);
|
||||
const loading = ref(true);
|
||||
const scrollContainer = ref<HTMLElement | null>(null);
|
||||
const showLeftFade = ref(false);
|
||||
const showRightFade = ref(false);
|
||||
|
||||
const fetchArtists = async () => {
|
||||
try {
|
||||
const { data } = await getHotSinger({ offset: 0, limit: props.limit || 10 });
|
||||
if (data.code === 200) {
|
||||
// 强制限制数量,确保不超过 limit
|
||||
artists.value = data.artists.slice(0, props.limit || 10);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch hot artists:', error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
// Update scroll indicators after content loads
|
||||
setTimeout(updateScrollIndicators, 100);
|
||||
}
|
||||
};
|
||||
|
||||
// Enhanced horizontal scroll with wheel support
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
if (!scrollContainer.value) return;
|
||||
|
||||
// Prevent default vertical scroll
|
||||
if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) {
|
||||
e.preventDefault();
|
||||
|
||||
// Convert vertical scroll to horizontal
|
||||
scrollContainer.value.scrollBy({
|
||||
left: e.deltaY,
|
||||
behavior: 'auto' // Instant for smooth tracking
|
||||
});
|
||||
}
|
||||
|
||||
updateScrollIndicators();
|
||||
};
|
||||
|
||||
// Update scroll fade indicators
|
||||
const updateScrollIndicators = () => {
|
||||
if (!scrollContainer.value) return;
|
||||
|
||||
const { scrollLeft, scrollWidth, clientWidth } = scrollContainer.value;
|
||||
showLeftFade.value = scrollLeft > 20;
|
||||
showRightFade.value = scrollLeft < scrollWidth - clientWidth - 20;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchArtists();
|
||||
|
||||
// Add scroll listener for fade indicators
|
||||
if (scrollContainer.value) {
|
||||
scrollContainer.value.addEventListener('scroll', updateScrollIndicators);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Typography System */
|
||||
.section-title {
|
||||
@apply text-2xl md:text-3xl lg:text-4xl font-bold tracking-tight;
|
||||
}
|
||||
|
||||
/* Optimized horizontal scroll */
|
||||
.artists-scroll {
|
||||
/* Hide scrollbar while maintaining functionality */
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Smooth scroll behavior */
|
||||
scroll-behavior: smooth;
|
||||
|
||||
/* Enable snap scrolling for better UX */
|
||||
scroll-snap-type: x proximity;
|
||||
|
||||
/* Enable momentum scrolling on iOS */
|
||||
-webkit-overflow-scrolling: touch;
|
||||
|
||||
/* Optimize for touch */
|
||||
touch-action: pan-x;
|
||||
}
|
||||
|
||||
.artists-track {
|
||||
/* Ensure proper width for scrolling */
|
||||
min-width: min-content;
|
||||
}
|
||||
|
||||
.artist-item {
|
||||
/* Snap alignment */
|
||||
scroll-snap-align: start;
|
||||
scroll-snap-stop: normal;
|
||||
}
|
||||
|
||||
/* Scroll fade indicators */
|
||||
.scroll-fade-left,
|
||||
.scroll-fade-right {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
</style>
|
||||
80
src/renderer/views/home/components/HomeCard.vue
Normal file
80
src/renderer/views/home/components/HomeCard.vue
Normal file
@@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<div
|
||||
class="group relative overflow-hidden rounded-2xl bg-white/5 transition-all duration-300 hover:bg-white/10 hover:shadow-2xl hover:shadow-black/20 dark:bg-neutral-900/50"
|
||||
:class="[containerClass]"
|
||||
@click="$emit('click')"
|
||||
>
|
||||
<!-- 图片区域 -->
|
||||
<div class="relative aspect-square overflow-hidden mb-3">
|
||||
<img
|
||||
v-if="image"
|
||||
:src="image"
|
||||
class="h-full w-full object-cover transition-transform duration-500 group-hover:scale-110"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div v-else class="h-full w-full animate-pulse bg-neutral-200 dark:bg-neutral-800" />
|
||||
|
||||
<!-- 播放按钮遮罩 (Apple Music 风格) -->
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-center bg-black/20 opacity-0 transition-opacity duration-300 group-hover:opacity-100"
|
||||
>
|
||||
<div
|
||||
class="flex h-12 w-12 items-center justify-center rounded-full bg-white/90 shadow-lg backdrop-blur-sm transition-transform duration-300 hover:scale-110 active:scale-95"
|
||||
>
|
||||
<slot name="play-icon">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
class="text-black ml-1"
|
||||
>
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右上角额外信息 (例如播放量) -->
|
||||
<div
|
||||
v-if="$slots.extra"
|
||||
class="absolute top-2 right-2 flex items-center gap-1 rounded-full bg-black/40 px-2 py-0.5 text-[10px] text-white backdrop-blur-md"
|
||||
>
|
||||
<slot name="extra" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文字区域 -->
|
||||
<div class="px-3 pb-4">
|
||||
<h3
|
||||
v-if="title"
|
||||
class="truncate text-sm font-semibold text-neutral-800 dark:text-neutral-50 mb-0.5"
|
||||
>
|
||||
{{ title }}
|
||||
</h3>
|
||||
<p v-if="subtitle" class="truncate text-xs text-neutral-500 dark:text-neutral-300">
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
image?: string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
containerClass?: string;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
(e: 'click'): void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 使用 Tailwind CSS,无需额外样式 */
|
||||
</style>
|
||||
217
src/renderer/views/home/components/HomeDailyRecommend.vue
Normal file
217
src/renderer/views/home/components/HomeDailyRecommend.vue
Normal file
@@ -0,0 +1,217 @@
|
||||
<template>
|
||||
<section class="daily-recommend-section">
|
||||
<!-- Section Header -->
|
||||
<div class="section-header mb-6 md:mb-8 flex items-end justify-between">
|
||||
<div>
|
||||
<h2 class="section-title text-neutral-900 dark:text-white">
|
||||
{{ title }}
|
||||
</h2>
|
||||
<div class="mt-1.5 h-1 w-12 rounded-full bg-primary" />
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Play All Button -->
|
||||
<button
|
||||
v-if="!loading && songs.length > 0"
|
||||
class="play-all-btn flex items-center gap-1.5 text-xs md:text-sm font-bold text-primary dark:text-white hover:text-primary/80 dark:hover:text-white/80 transition-colors"
|
||||
@click="playAll"
|
||||
>
|
||||
<i class="iconfont icon-playfill text-sm" />
|
||||
<span>{{ t('musicList.playAll') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading Skeleton -->
|
||||
<div
|
||||
v-if="loading"
|
||||
class="grid grid-cols-2 gap-4 md:gap-5 lg:gap-6 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6"
|
||||
>
|
||||
<div
|
||||
v-for="i in 12"
|
||||
:key="i"
|
||||
class="skeleton-item aspect-square animate-pulse rounded-2xl md:rounded-3xl bg-neutral-100 dark:bg-neutral-800/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Songs Grid -->
|
||||
<div
|
||||
v-else-if="songs.length > 0"
|
||||
class="songs-grid grid grid-cols-2 gap-4 gap-y-8 md:gap-5 md:gap-y-10 lg:gap-6 lg:gap-y-12 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6"
|
||||
>
|
||||
<div
|
||||
v-for="(song, index) in songs.slice(0, limit)"
|
||||
:key="song.id"
|
||||
class="song-item animate-item group relative flex flex-col cursor-pointer"
|
||||
:style="{ animationDelay: calculateAnimationDelay(index, 0.03) }"
|
||||
@click="handleSongClick(song, index)"
|
||||
>
|
||||
<!-- Cover Container -->
|
||||
<div class="cover-wrapper relative aspect-square">
|
||||
<!-- 背景层 -->
|
||||
<div
|
||||
class="cover-bg absolute inset-0 rounded-2xl md:rounded-3xl bg-neutral-100 dark:bg-neutral-800 transition-shadow duration-300 group-hover:shadow-2xl group-hover:shadow-primary/10"
|
||||
/>
|
||||
|
||||
<!-- 图片层 -->
|
||||
<div class="cover-container absolute inset-0 overflow-hidden rounded-2xl md:rounded-3xl">
|
||||
<img
|
||||
:src="getImgUrl(song.album?.picUrl || song.al?.picUrl, '500y500')"
|
||||
class="h-full w-full object-cover transition-transform duration-500 ease-out group-hover:scale-105"
|
||||
loading="lazy"
|
||||
:alt="song.name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Play Overlay -->
|
||||
<div
|
||||
class="play-overlay absolute inset-0 flex items-center justify-center rounded-2xl md:rounded-3xl bg-black/0 opacity-0 backdrop-blur-0 transition-all duration-300 group-hover:bg-black/10 group-hover:opacity-100 group-hover:backdrop-blur-[2px]"
|
||||
>
|
||||
<div
|
||||
class="play-button flex h-12 w-12 md:h-14 md:w-14 items-center justify-center rounded-full bg-white shadow-2xl transition-all duration-300 scale-90 group-hover:scale-100 hover:scale-110 active:scale-95"
|
||||
>
|
||||
<i class="iconfont icon-playfill text-lg md:text-2xl text-neutral-900 ml-0.5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recommended Badge -->
|
||||
<div
|
||||
class="badge absolute top-3 right-3 rounded-full bg-red-500 px-2.5 py-1 text-[10px] font-bold text-white"
|
||||
>
|
||||
{{ t('comp.dailyRecommend.badge') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="song-info mt-3 md:mt-4 px-0.5">
|
||||
<h3
|
||||
class="song-name line-clamp-2 text-sm md:text-base font-semibold leading-tight text-neutral-800 dark:text-neutral-100 transition-colors duration-200 group-hover:text-primary dark:group-hover:text-white"
|
||||
>
|
||||
{{ song.name }}
|
||||
</h3>
|
||||
<p
|
||||
class="artist-name mt-1 md:mt-1.5 line-clamp-1 text-[10px] md:text-[11px] font-medium text-neutral-400 dark:text-neutral-500"
|
||||
>
|
||||
{{ getArtistNames(song) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div
|
||||
v-else
|
||||
class="empty-state flex flex-col items-center justify-center py-20 text-neutral-400"
|
||||
>
|
||||
<i class="iconfont icon-music text-6xl mb-4 opacity-30" />
|
||||
<p>{{ t('comp.dailyRecommend.empty') }}</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onActivated, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { useRecommendStore } from '@/store';
|
||||
import { calculateAnimationDelay, getImgUrl } from '@/utils';
|
||||
|
||||
defineProps<{
|
||||
title: string;
|
||||
limit?: number;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const recommendStore = useRecommendStore();
|
||||
|
||||
const songs = computed(() => recommendStore.dailyRecommendSongs);
|
||||
const loading = computed(() => songs.value.length === 0 && !recommendStore.lastFetchDate);
|
||||
|
||||
onMounted(() => {
|
||||
recommendStore.refreshIfStale();
|
||||
});
|
||||
|
||||
// keep-alive 激活时检查是否跨天需要刷新
|
||||
onActivated(() => {
|
||||
recommendStore.refreshIfStale();
|
||||
});
|
||||
|
||||
const getArtistNames = (song: any) => {
|
||||
if (song.artists) {
|
||||
return song.artists.map((ar: any) => ar.name).join(' / ');
|
||||
}
|
||||
if (song.ar) {
|
||||
return song.ar.map((ar: any) => ar.name).join(' / ');
|
||||
}
|
||||
return 'Unknown Artist';
|
||||
};
|
||||
|
||||
const handleSongClick = async (_song: any, index: number) => {
|
||||
const { usePlayerCoreStore } = await import('@/store/modules/playerCore');
|
||||
const { usePlaylistStore } = await import('@/store/modules/playlist');
|
||||
|
||||
const playerCore = usePlayerCoreStore();
|
||||
const playlistStore = usePlaylistStore();
|
||||
|
||||
const playlist = songs.value.map((s: any) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
picUrl: s.album?.picUrl || s.al?.picUrl,
|
||||
source: 'netease',
|
||||
song: s,
|
||||
...s,
|
||||
playLoading: false
|
||||
}));
|
||||
|
||||
playlistStore.setPlayList(playlist, false, false);
|
||||
await playerCore.handlePlayMusic(playlist[index], true);
|
||||
};
|
||||
|
||||
const playAll = async () => {
|
||||
if (songs.value.length === 0) return;
|
||||
|
||||
const { usePlayerCoreStore } = await import('@/store/modules/playerCore');
|
||||
const { usePlaylistStore } = await import('@/store/modules/playlist');
|
||||
|
||||
const playerCore = usePlayerCoreStore();
|
||||
const playlistStore = usePlaylistStore();
|
||||
|
||||
const playlist = songs.value.map((s: any) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
picUrl: s.album?.picUrl || s.al?.picUrl,
|
||||
source: 'netease',
|
||||
song: s,
|
||||
...s,
|
||||
playLoading: false
|
||||
}));
|
||||
|
||||
playlistStore.setPlayList(playlist, false, false);
|
||||
await playerCore.handlePlayMusic(playlist[0], true);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Typography System */
|
||||
.section-title {
|
||||
@apply text-2xl md:text-3xl lg:text-4xl font-bold tracking-tight;
|
||||
}
|
||||
|
||||
/* Grid */
|
||||
.songs-grid {
|
||||
grid-auto-rows: auto;
|
||||
}
|
||||
|
||||
/* 确保圆角在任何情况下都保持不变 */
|
||||
.cover-wrapper {
|
||||
will-change: transform;
|
||||
transform: translateZ(0);
|
||||
backface-visibility: hidden;
|
||||
-webkit-backface-visibility: hidden;
|
||||
}
|
||||
|
||||
.cover-container {
|
||||
transform: translateZ(0);
|
||||
backface-visibility: hidden;
|
||||
-webkit-backface-visibility: hidden;
|
||||
}
|
||||
</style>
|
||||
535
src/renderer/views/home/components/HomeHero.vue
Normal file
535
src/renderer/views/home/components/HomeHero.vue
Normal file
@@ -0,0 +1,535 @@
|
||||
<template>
|
||||
<div class="hero-section mb-8 md:mb-12">
|
||||
<!-- Skeleton Loading -->
|
||||
<div v-if="loading" class="space-y-5">
|
||||
<div class="flex gap-2 overflow-hidden">
|
||||
<div
|
||||
v-for="i in 6"
|
||||
:key="i"
|
||||
class="skeleton-pill h-10 w-24 flex-shrink-0 animate-pulse rounded-full bg-neutral-200 dark:bg-neutral-800"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div
|
||||
class="skeleton-card aspect-[2.2/1] animate-pulse rounded-2xl bg-neutral-200 dark:bg-neutral-800"
|
||||
/>
|
||||
<div
|
||||
class="skeleton-card aspect-[2.2/1] animate-pulse rounded-2xl bg-neutral-200 dark:bg-neutral-800"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div v-else class="space-y-5">
|
||||
<!-- Quick Navigation -->
|
||||
<nav class="quick-nav flex gap-2 overflow-x-auto pb-1 scrollbar-hide">
|
||||
<button
|
||||
v-for="(item, index) in quickNavItems"
|
||||
:key="item.key"
|
||||
class="quick-nav-item group flex flex-shrink-0 items-center gap-2.5 rounded-full px-4 py-2 transition-all duration-300 hover:-translate-y-0.5 hover:shadow-lg"
|
||||
:class="[
|
||||
item.active
|
||||
? 'bg-primary text-white shadow-lg shadow-primary/25'
|
||||
: 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200 dark:bg-neutral-800/80 dark:text-neutral-200 dark:hover:bg-neutral-700/80'
|
||||
]"
|
||||
:style="{ animationDelay: calculateAnimationDelay(index, 0.05) }"
|
||||
@click="item.action"
|
||||
>
|
||||
<span
|
||||
class="flex h-7 w-7 items-center justify-center rounded-full text-sm transition-transform duration-300 group-hover:scale-110"
|
||||
:class="item.active ? 'bg-white/20' : item.iconBg"
|
||||
>
|
||||
<i :class="[item.icon, item.active ? 'text-white' : item.iconColor]" />
|
||||
</span>
|
||||
<span class="text-sm font-semibold whitespace-nowrap">{{ item.label }}</span>
|
||||
<span
|
||||
v-if="item.badge"
|
||||
class="ml-0.5 rounded-full bg-white/30 px-1.5 py-0.5 text-[10px] font-bold"
|
||||
>
|
||||
{{ item.badge }}
|
||||
</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- Feature Cards -->
|
||||
<div class="cards-grid grid grid-cols-1 gap-4 md:grid-cols-5 lg:gap-5">
|
||||
<!-- Daily Recommend Card -->
|
||||
<div
|
||||
class="daily-card group relative col-span-1 cursor-pointer overflow-hidden rounded-2xl md:col-span-3"
|
||||
:style="{ animationDelay: calculateAnimationDelay(1, 0.08) }"
|
||||
@click="showDayRecommend"
|
||||
>
|
||||
<!-- Background -->
|
||||
<div class="absolute inset-0">
|
||||
<img
|
||||
v-if="dayRecommendCover"
|
||||
:src="getImgUrl(dayRecommendCover, '600y600')"
|
||||
alt=""
|
||||
class="h-full w-full object-cover transition-transform duration-700 group-hover:scale-105"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-br from-black/60 via-black/40 to-black/70"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div
|
||||
class="relative z-10 flex h-full min-h-[160px] flex-col justify-between p-5 md:min-h-[180px] md:p-6"
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="flex items-center gap-1.5 rounded-full bg-white/15 px-3 py-1.5 text-xs font-medium text-white backdrop-blur-sm"
|
||||
>
|
||||
<i class="ri-calendar-check-fill" />
|
||||
{{ t('comp.homeHero.dailyRecommend') }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-xs font-medium text-white/70">
|
||||
{{ dayRecommendSongs.length }} {{ t('comp.homeHero.songs') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-end justify-between gap-4">
|
||||
<!-- Song Preview List -->
|
||||
<div v-if="dayRecommendSongs.length > 0" class="flex-1 space-y-1.5">
|
||||
<div
|
||||
v-for="(song, idx) in dayRecommendSongs.slice(0, 3)"
|
||||
:key="song.id"
|
||||
class="flex items-center gap-2 text-white/90"
|
||||
>
|
||||
<span class="w-4 text-center text-xs font-bold text-white/50">{{ idx + 1 }}</span>
|
||||
<span class="max-w-[140px] truncate text-sm font-medium md:max-w-[200px]">{{
|
||||
song.name
|
||||
}}</span>
|
||||
<span class="max-w-[80px] truncate text-xs text-white/50 md:max-w-[120px]">
|
||||
{{ song.ar?.[0]?.name }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Play Button -->
|
||||
<button
|
||||
class="play-btn flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-white text-neutral-900 shadow-xl transition-all duration-300 hover:scale-110 hover:shadow-2xl active:scale-95"
|
||||
>
|
||||
<i class="ri-play-fill ml-0.5 text-xl" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Personal FM Card -->
|
||||
<div
|
||||
class="fm-card group relative col-span-1 cursor-pointer overflow-hidden rounded-2xl md:col-span-2"
|
||||
:style="{ animationDelay: calculateAnimationDelay(2, 0.08) }"
|
||||
@click="playPersonalFm"
|
||||
>
|
||||
<!-- Gradient Background -->
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-br from-violet-600 via-purple-600 to-fuchsia-600"
|
||||
>
|
||||
<img
|
||||
v-if="personalFmCover"
|
||||
:src="getImgUrl(personalFmCover, '400y400')"
|
||||
alt=""
|
||||
class="h-full w-full object-cover opacity-30 mix-blend-overlay transition-transform duration-700 group-hover:scale-105"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Wave Animation -->
|
||||
<div class="fm-waves absolute right-4 top-4 flex items-end gap-[3px]">
|
||||
<span
|
||||
v-for="i in 4"
|
||||
:key="i"
|
||||
class="fm-wave-bar"
|
||||
:style="{ animationDelay: `${i * 0.15}s` }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div
|
||||
class="relative z-10 flex h-full min-h-[160px] flex-col justify-between p-5 md:min-h-[180px] md:p-6"
|
||||
>
|
||||
<div>
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 rounded-full bg-white/20 px-3 py-1.5 text-xs font-medium text-white backdrop-blur-sm"
|
||||
>
|
||||
<i class="ri-radio-fill" />
|
||||
私人FM
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-end justify-between gap-4">
|
||||
<div v-if="personalFmSong" class="flex-1 min-w-0">
|
||||
<p class="truncate text-base font-bold text-white md:text-lg">
|
||||
{{ personalFmSong.name }}
|
||||
</p>
|
||||
<p class="mt-1 truncate text-sm text-white/70">
|
||||
{{ personalFmSong.artists?.[0]?.name }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-else class="flex-1">
|
||||
<p class="text-base font-bold text-white md:text-lg">发现新音乐</p>
|
||||
<p class="mt-1 text-sm text-white/70">根据你的喜好推荐</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="play-btn flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-white/95 text-purple-600 shadow-xl transition-all duration-300 hover:scale-110 hover:shadow-2xl active:scale-95"
|
||||
>
|
||||
<i class="ri-play-fill ml-0.5 text-xl" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hot Search Bar -->
|
||||
<div
|
||||
v-if="hotSearchList.length > 0"
|
||||
class="hot-search flex items-center gap-3 rounded-xl bg-neutral-100 px-4 py-3 dark:bg-neutral-800/60"
|
||||
:style="{ animationDelay: calculateAnimationDelay(3, 0.08) }"
|
||||
>
|
||||
<div class="flex flex-shrink-0 items-center gap-1.5 text-rose-500">
|
||||
<i class="ri-fire-fill text-base" />
|
||||
<span class="text-xs font-bold">热搜</span>
|
||||
</div>
|
||||
<div class="hot-list flex flex-1 gap-2 overflow-x-auto scrollbar-hide">
|
||||
<button
|
||||
v-for="(item, idx) in hotSearchList.slice(0, 10)"
|
||||
:key="idx"
|
||||
class="hot-item flex flex-shrink-0 items-center gap-1.5 rounded-full bg-white px-3 py-1.5 text-xs font-medium text-neutral-600 transition-all duration-200 hover:bg-neutral-200 hover:text-neutral-900 dark:bg-neutral-700 dark:text-neutral-300 dark:hover:bg-neutral-600 dark:hover:text-white"
|
||||
@click="searchHotKeyword(item.searchWord)"
|
||||
>
|
||||
<span
|
||||
class="font-bold"
|
||||
:class="idx < 3 ? 'text-rose-500' : 'text-neutral-400 dark:text-neutral-500'"
|
||||
>
|
||||
{{ idx + 1 }}
|
||||
</span>
|
||||
<span class="max-w-[80px] truncate">{{ item.searchWord }}</span>
|
||||
<i v-if="item.iconType === 1" class="ri-fire-fill text-[10px] text-rose-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onActivated, onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { getHotSearch, getPersonalFM } from '@/api/home';
|
||||
import { navigateToMusicList } from '@/components/common/MusicListNavigator';
|
||||
import { useIntelligenceModeStore, useRecommendStore } from '@/store';
|
||||
import { calculateAnimationDelay, getImgUrl } from '@/utils';
|
||||
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const recommendStore = useRecommendStore();
|
||||
const intelligenceModeStore = useIntelligenceModeStore();
|
||||
|
||||
const loading = ref(false);
|
||||
const personalFmSong = ref<any>(null);
|
||||
const hotSearchList = ref<any[]>([]);
|
||||
|
||||
const dayRecommendSongs = computed(() => recommendStore.dailyRecommendSongs);
|
||||
const dayRecommendCover = computed(() => dayRecommendSongs.value[0]?.al?.picUrl || '');
|
||||
const personalFmCover = computed(() => personalFmSong.value?.album?.picUrl || '');
|
||||
const isIntelligenceMode = computed(() => intelligenceModeStore.isIntelligenceMode);
|
||||
|
||||
// Quick Nav Configuration
|
||||
const quickNavItems = computed(() => [
|
||||
{
|
||||
key: 'intelligence',
|
||||
label: t('comp.homeHero.intelligenceMode'),
|
||||
icon: 'ri-heart-pulse-fill',
|
||||
iconBg: 'bg-rose-100 dark:bg-rose-900/40',
|
||||
iconColor: 'text-rose-500 dark:text-rose-400',
|
||||
active: isIntelligenceMode.value,
|
||||
badge: isIntelligenceMode.value ? t('comp.homeHero.playing') : null,
|
||||
action: toggleIntelligenceMode
|
||||
},
|
||||
{
|
||||
key: 'toplist',
|
||||
label: t('comp.toplist'),
|
||||
icon: 'ri-trophy-fill',
|
||||
iconBg: 'bg-amber-100 dark:bg-amber-900/40',
|
||||
iconColor: 'text-amber-500 dark:text-amber-400',
|
||||
active: false,
|
||||
badge: null,
|
||||
action: () => router.push('/toplist')
|
||||
},
|
||||
{
|
||||
key: 'mv',
|
||||
label: t('comp.mv'),
|
||||
icon: 'ri-movie-2-fill',
|
||||
iconBg: 'bg-violet-100 dark:bg-violet-900/40',
|
||||
iconColor: 'text-violet-500 dark:text-violet-400',
|
||||
active: false,
|
||||
badge: null,
|
||||
action: () => router.push('/mv')
|
||||
},
|
||||
{
|
||||
key: 'playlist',
|
||||
label: t('comp.list'),
|
||||
icon: 'ri-play-list-2-fill',
|
||||
iconBg: 'bg-sky-100 dark:bg-sky-900/40',
|
||||
iconColor: 'text-sky-500 dark:text-sky-400',
|
||||
active: false,
|
||||
badge: null,
|
||||
action: () => router.push('/list')
|
||||
},
|
||||
{
|
||||
key: 'album',
|
||||
label: t('comp.newAlbum.title'),
|
||||
icon: 'ri-album-fill',
|
||||
iconBg: 'bg-orange-100 dark:bg-orange-900/40',
|
||||
iconColor: 'text-orange-500 dark:text-orange-400',
|
||||
active: false,
|
||||
badge: null,
|
||||
action: () => router.push('/album')
|
||||
},
|
||||
{
|
||||
key: 'history',
|
||||
label: t('comp.history'),
|
||||
icon: 'ri-history-fill',
|
||||
iconBg: 'bg-emerald-100 dark:bg-emerald-900/40',
|
||||
iconColor: 'text-emerald-500 dark:text-emerald-400',
|
||||
active: false,
|
||||
badge: null,
|
||||
action: () => router.push('/history')
|
||||
}
|
||||
]);
|
||||
|
||||
const fetchHeroData = async () => {
|
||||
try {
|
||||
loading.value = true;
|
||||
const promises: Promise<any>[] = [];
|
||||
|
||||
// 使用 refreshIfStale 检查是否需要刷新每日推荐
|
||||
promises.push(recommendStore.refreshIfStale());
|
||||
|
||||
// Fetch Personal FM
|
||||
promises.push(
|
||||
getPersonalFM()
|
||||
.then((res: any) => {
|
||||
if (res.data?.[0]) {
|
||||
personalFmSong.value = res.data[0];
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
);
|
||||
|
||||
// Fetch Hot Search
|
||||
promises.push(
|
||||
getHotSearch()
|
||||
.then((res: any) => {
|
||||
if (res.data) {
|
||||
hotSearchList.value = res.data;
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
);
|
||||
|
||||
await Promise.all(promises);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch hero data:', error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const showDayRecommend = () => {
|
||||
if (dayRecommendSongs.value.length === 0) return;
|
||||
navigateToMusicList(router, {
|
||||
type: 'dailyRecommend',
|
||||
name: t('comp.recommendSinger.songlist'),
|
||||
songList: dayRecommendSongs.value,
|
||||
canRemove: false
|
||||
});
|
||||
};
|
||||
|
||||
const toggleIntelligenceMode = () => {
|
||||
if (isIntelligenceMode.value) {
|
||||
intelligenceModeStore.clearIntelligenceMode();
|
||||
} else {
|
||||
intelligenceModeStore.playIntelligenceMode();
|
||||
}
|
||||
};
|
||||
|
||||
const playPersonalFm = async () => {
|
||||
if (!personalFmSong.value) return;
|
||||
|
||||
try {
|
||||
const { usePlayerCoreStore } = await import('@/store/modules/playerCore');
|
||||
const { usePlaylistStore } = await import('@/store/modules/playlist');
|
||||
|
||||
const playerCore = usePlayerCoreStore();
|
||||
const playlistStore = usePlaylistStore();
|
||||
|
||||
const song = personalFmSong.value;
|
||||
const playlist = [
|
||||
{
|
||||
id: song.id,
|
||||
name: song.name,
|
||||
picUrl: song.al?.picUrl || song.album?.picUrl,
|
||||
source: 'netease',
|
||||
song,
|
||||
...song,
|
||||
playLoading: false
|
||||
}
|
||||
];
|
||||
|
||||
playlistStore.setPlayList(playlist, false, false);
|
||||
await playerCore.handlePlayMusic(playlist[0], true);
|
||||
} catch (error) {
|
||||
console.error('Failed to play Personal FM:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const searchHotKeyword = (keyword: string) => {
|
||||
router.push({ path: '/search-result', query: { keyword } });
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchHeroData();
|
||||
});
|
||||
|
||||
// keep-alive 激活时检查是否跨天需要刷新
|
||||
onActivated(() => {
|
||||
recommendStore.refreshIfStale();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Hide scrollbar */
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Quick Nav Animation */
|
||||
.quick-nav-item {
|
||||
animation: fadeInUp 0.5s ease-out backwards;
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Cards Animation */
|
||||
.daily-card,
|
||||
.fm-card {
|
||||
animation: fadeInScale 0.6s ease-out backwards;
|
||||
}
|
||||
|
||||
@keyframes fadeInScale {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Hot Search Animation */
|
||||
.hot-search {
|
||||
animation: fadeInUp 0.5s ease-out backwards;
|
||||
}
|
||||
|
||||
/* FM Wave Animation */
|
||||
.fm-wave-bar {
|
||||
@apply w-[3px] rounded-full bg-white/60;
|
||||
height: 12px;
|
||||
animation: fmWave 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.fm-wave-bar:nth-child(1) {
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.fm-wave-bar:nth-child(2) {
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.fm-wave-bar:nth-child(3) {
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.fm-wave-bar:nth-child(4) {
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
@keyframes fmWave {
|
||||
0%,
|
||||
100% {
|
||||
transform: scaleY(1);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scaleY(1.6);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Play Button Hover Glow */
|
||||
.play-btn {
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.play-btn:hover {
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
/* Skeleton Shimmer */
|
||||
.skeleton-pill,
|
||||
.skeleton-card {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
theme('colors.neutral.200') 25%,
|
||||
theme('colors.neutral.100') 50%,
|
||||
theme('colors.neutral.200') 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
.dark .skeleton-pill,
|
||||
.dark .skeleton-card {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
theme('colors.neutral.800') 25%,
|
||||
theme('colors.neutral.700') 50%,
|
||||
theme('colors.neutral.800') 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
205
src/renderer/views/home/components/HomeListItem.vue
Normal file
205
src/renderer/views/home/components/HomeListItem.vue
Normal file
@@ -0,0 +1,205 @@
|
||||
<template>
|
||||
<div class="list-item group cursor-pointer" :style="{ animationDelay }" @click="$emit('click')">
|
||||
<!-- Cover -->
|
||||
<div
|
||||
class="relative aspect-square overflow-hidden rounded-2xl bg-neutral-100 shadow-sm transition-all duration-300 ease-out group-hover:shadow-xl dark:bg-neutral-800"
|
||||
>
|
||||
<img
|
||||
ref="coverRef"
|
||||
:src="getImgUrl(cover, '512y512')"
|
||||
class="h-full w-full object-cover transition-transform duration-700 ease-out group-hover:scale-110"
|
||||
loading="lazy"
|
||||
:alt="title"
|
||||
crossorigin="anonymous"
|
||||
@load="extractColor"
|
||||
/>
|
||||
|
||||
<!-- Hover Overlay with Song Preview -->
|
||||
<div
|
||||
class="absolute inset-0 flex items-end opacity-0 transition-all duration-500 ease-out group-hover:opacity-100"
|
||||
:style="overlayStyle"
|
||||
>
|
||||
<!-- Song Preview + Play Button Container -->
|
||||
<div class="flex w-full items-end justify-between gap-3 p-4">
|
||||
<!-- Song List -->
|
||||
<div
|
||||
class="min-w-0 flex-1 translate-y-3 space-y-1.5 transition-all duration-500 ease-out group-hover:translate-y-0"
|
||||
>
|
||||
<div
|
||||
v-for="(track, idx) in displayTracks"
|
||||
:key="idx"
|
||||
class="flex items-center gap-2.5 text-white/95"
|
||||
>
|
||||
<span class="w-5 flex-shrink-0 text-center text-xs font-bold text-white/40">{{
|
||||
idx + 1
|
||||
}}</span>
|
||||
<span class="truncate text-sm font-semibold tracking-wide">{{ track.name }}</span>
|
||||
</div>
|
||||
<div v-if="tracks.length === 0" class="py-4 text-center text-xs text-white/50">
|
||||
{{ t('comp.homeListItem.loading') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Play Button -->
|
||||
<button
|
||||
class="flex h-12 w-12 flex-shrink-0 translate-y-2 items-center justify-center rounded-full bg-white text-neutral-900 shadow-2xl transition-all duration-500 ease-out hover:scale-110 group-hover:translate-y-0 active:scale-95"
|
||||
@click.stop="$emit('play')"
|
||||
>
|
||||
<i class="ri-play-fill ml-0.5 text-lg" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Badge -->
|
||||
<div
|
||||
v-if="badge"
|
||||
class="absolute left-3 top-3 rounded-lg px-2.5 py-1 text-[11px] font-bold text-white shadow-lg backdrop-blur-sm"
|
||||
:class="badgeClass"
|
||||
>
|
||||
{{ badge }}
|
||||
</div>
|
||||
|
||||
<!-- Play Count (for playlists) -->
|
||||
<div
|
||||
v-if="playCount"
|
||||
class="absolute right-3 top-3 flex items-center gap-1.5 rounded-lg bg-black/40 px-2.5 py-1 text-[11px] font-semibold text-white backdrop-blur-md"
|
||||
>
|
||||
<i class="ri-play-fill text-[10px]" />
|
||||
<span>{{ formatNumber(playCount) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="mt-3 px-0.5">
|
||||
<h3
|
||||
class="truncate text-base font-bold tracking-tight text-neutral-900 transition-colors duration-200 group-hover:text-primary dark:text-neutral-50 dark:group-hover:text-white"
|
||||
>
|
||||
{{ title }}
|
||||
</h3>
|
||||
<p
|
||||
v-if="subtitle"
|
||||
class="mt-1.5 truncate text-sm font-medium text-neutral-500 transition-colors duration-200 group-hover:text-neutral-600 dark:text-neutral-400 dark:group-hover:text-neutral-300"
|
||||
>
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { formatNumber, getImgUrl } from '@/utils';
|
||||
import { getImageBackground } from '@/utils/linearColor';
|
||||
|
||||
interface Track {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
cover: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
tracks?: Track[];
|
||||
badge?: string;
|
||||
badgeType?: 'new' | 'hot' | 'recommend';
|
||||
playCount?: number;
|
||||
animationDelay?: string;
|
||||
}>(),
|
||||
{
|
||||
tracks: () => [],
|
||||
animationDelay: '0s'
|
||||
}
|
||||
);
|
||||
|
||||
defineEmits<{
|
||||
(e: 'click'): void;
|
||||
(e: 'play'): void;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const coverRef = ref<HTMLImageElement | null>(null);
|
||||
const backgroundGradient = ref(
|
||||
'linear-gradient(to top, rgba(0,0,0,0.95) 0%, rgba(0,0,0,0.6) 50%, transparent 100%)'
|
||||
);
|
||||
|
||||
const displayTracks = computed(() => props.tracks.slice(0, 3));
|
||||
|
||||
const badgeClass = computed(() => {
|
||||
switch (props.badgeType) {
|
||||
case 'new':
|
||||
return 'bg-gradient-to-r from-orange-500 to-rose-500';
|
||||
case 'hot':
|
||||
return 'bg-gradient-to-r from-rose-500 to-pink-500';
|
||||
case 'recommend':
|
||||
return 'bg-gradient-to-r from-primary to-blue-500';
|
||||
default:
|
||||
return 'bg-gradient-to-r from-primary to-blue-500';
|
||||
}
|
||||
});
|
||||
|
||||
const overlayStyle = computed(() => ({
|
||||
background: backgroundGradient.value
|
||||
}));
|
||||
|
||||
const extractColor = async () => {
|
||||
const img = coverRef.value;
|
||||
if (!img) return;
|
||||
|
||||
try {
|
||||
const { primaryColor } = await getImageBackground(img);
|
||||
if (primaryColor) {
|
||||
// 使用 tinycolor 来创建更自然的渐变效果
|
||||
const tinycolor = (await import('tinycolor2')).default;
|
||||
const baseColor = tinycolor(primaryColor);
|
||||
const hsl = baseColor.toHsl();
|
||||
|
||||
// 创建深色渐变,确保文字可读性
|
||||
const darkColor = tinycolor({
|
||||
h: hsl.h,
|
||||
s: Math.min(hsl.s * 1.3, 1),
|
||||
l: Math.max(hsl.l * 0.15, 0.05)
|
||||
}).setAlpha(0.95);
|
||||
|
||||
const midColor = tinycolor({
|
||||
h: hsl.h,
|
||||
s: Math.min(hsl.s * 1.1, 1),
|
||||
l: Math.max(hsl.l * 0.4, 0.1)
|
||||
}).setAlpha(0.85);
|
||||
|
||||
const topColor = tinycolor({
|
||||
h: hsl.h,
|
||||
s: hsl.s * 0.8,
|
||||
l: Math.min(hsl.l * 0.6, 0.2)
|
||||
}).setAlpha(0.3);
|
||||
|
||||
backgroundGradient.value = `linear-gradient(to top, ${darkColor.toRgbString()} 0%, ${midColor.toRgbString()} 60%, ${topColor.toRgbString()} 100%)`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.debug('Color extraction failed:', error);
|
||||
// 使用深色fallback
|
||||
backgroundGradient.value =
|
||||
'linear-gradient(to top, rgba(0,0,0,0.95) 0%, rgba(0,0,0,0.75) 60%, rgba(0,0,0,0.3) 100%)';
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.list-item {
|
||||
animation: itemFadeIn 0.5s ease-out backwards;
|
||||
}
|
||||
|
||||
@keyframes itemFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
121
src/renderer/views/home/components/HomeNewSongs.vue
Normal file
121
src/renderer/views/home/components/HomeNewSongs.vue
Normal file
@@ -0,0 +1,121 @@
|
||||
<template>
|
||||
<section class="new-songs-section">
|
||||
<!-- Section Header -->
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<h2 class="text-xl font-bold tracking-tight text-neutral-900 md:text-2xl dark:text-white">
|
||||
{{ title }}
|
||||
</h2>
|
||||
<div class="h-1.5 w-1.5 rounded-full bg-primary" />
|
||||
</div>
|
||||
<button
|
||||
class="play-all-btn text-xs md:text-sm font-bold text-primary dark:text-white hover:text-primary/80 dark:hover:text-white/80 transition-colors flex items-center gap-1.5"
|
||||
@click="playAll"
|
||||
>
|
||||
<i class="iconfont icon-playfill text-sm"></i>
|
||||
<span>{{ t('common.playAll') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading Skeleton -->
|
||||
<div
|
||||
v-if="loading"
|
||||
class="songs-grid grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5"
|
||||
>
|
||||
<div
|
||||
v-for="i in 10"
|
||||
:key="i"
|
||||
class="skeleton-item h-20 animate-pulse rounded-xl md:rounded-2xl bg-neutral-100 dark:bg-neutral-800/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Songs Grid (Even columns: 1→2→3→4→5) -->
|
||||
<div
|
||||
v-else
|
||||
class="songs-grid grid grid-cols-1 gap-2 md:gap-3 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5"
|
||||
>
|
||||
<song-item
|
||||
v-for="(song, index) in songs"
|
||||
:key="song.id"
|
||||
:item="song"
|
||||
home
|
||||
:favorite="false"
|
||||
:style="{ animationDelay: calculateAnimationDelay(index % 5, 0.05) }"
|
||||
class="animate-item"
|
||||
@play="playSong(song)"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { getRecommendMusic } from '@/api/home';
|
||||
import SongItem from '@/components/common/SongItem.vue';
|
||||
import { usePlayerStore } from '@/store';
|
||||
import { SongResult } from '@/types/music';
|
||||
import { calculateAnimationDelay } from '@/utils';
|
||||
|
||||
const props = defineProps<{
|
||||
title: string;
|
||||
limit?: number;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const playerStore = usePlayerStore();
|
||||
const songs = ref<SongResult[]>([]);
|
||||
const loading = ref(true);
|
||||
|
||||
const fetchSongs = async () => {
|
||||
try {
|
||||
const { data } = await getRecommendMusic({ limit: props.limit || 12 });
|
||||
if (data.code === 200) {
|
||||
// 转换数据格式为 SongResult
|
||||
songs.value = data.result.slice(0, props.limit || 12).map((item: any) => ({
|
||||
...item,
|
||||
source: 'netease',
|
||||
picUrl: item.picUrl,
|
||||
al: {
|
||||
picUrl: item.picUrl,
|
||||
name: item.name,
|
||||
id: item.id
|
||||
},
|
||||
ar: item.song.artists
|
||||
})) as SongResult[];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch new songs:', error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const playSong = (song: SongResult) => {
|
||||
playerStore.setPlay(song);
|
||||
};
|
||||
|
||||
const playAll = () => {
|
||||
if (songs.value.length > 0) {
|
||||
playerStore.setPlayList(songs.value);
|
||||
playerStore.setPlay(songs.value[0]);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchSongs();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Typography System */
|
||||
.section-title {
|
||||
@apply text-2xl md:text-3xl lg:text-4xl font-bold tracking-tight;
|
||||
}
|
||||
|
||||
/* Optimized grid */
|
||||
.songs-grid {
|
||||
grid-auto-rows: auto;
|
||||
}
|
||||
</style>
|
||||
188
src/renderer/views/home/components/HomePlaylistSection.vue
Normal file
188
src/renderer/views/home/components/HomePlaylistSection.vue
Normal file
@@ -0,0 +1,188 @@
|
||||
<template>
|
||||
<section class="playlist-section">
|
||||
<!-- Section Header -->
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<h2 class="text-xl font-bold tracking-tight text-neutral-900 md:text-2xl dark:text-white">
|
||||
{{ title }}
|
||||
</h2>
|
||||
<div class="h-1.5 w-1.5 rounded-full bg-primary" />
|
||||
</div>
|
||||
<button
|
||||
class="group flex items-center gap-1.5 text-sm font-semibold text-neutral-400 transition-colors hover:text-primary dark:text-neutral-500 dark:hover:text-white"
|
||||
@click="$emit('more')"
|
||||
>
|
||||
<span>{{ t('comp.more') }}</span>
|
||||
<i class="ri-arrow-right-s-line text-base transition-transform group-hover:translate-x-1" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading Skeleton -->
|
||||
<div v-if="loading" class="grid gap-6" :style="gridStyle">
|
||||
<div v-for="i in displayCount" :key="i" class="space-y-3">
|
||||
<div class="aspect-square animate-pulse rounded-2xl bg-neutral-200 dark:bg-neutral-800" />
|
||||
<div class="h-4 w-3/4 animate-pulse rounded bg-neutral-200 dark:bg-neutral-800" />
|
||||
<div class="h-3 w-1/2 animate-pulse rounded bg-neutral-200 dark:bg-neutral-800" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Playlist Grid -->
|
||||
<div v-else-if="displayPlaylists.length > 0" class="grid gap-6" :style="gridStyle">
|
||||
<home-list-item
|
||||
v-for="(item, index) in displayPlaylists"
|
||||
:key="item.id"
|
||||
:cover="item.picUrl"
|
||||
:title="item.name"
|
||||
:subtitle="item.copywriter"
|
||||
:tracks="playlistTracksMap[item.id] || []"
|
||||
:play-count="item.playCount"
|
||||
:animation-delay="calculateAnimationDelay(index, 0.04)"
|
||||
@click="handlePlaylistClick(item)"
|
||||
@play="playPlaylist(item)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else class="flex flex-col items-center justify-center py-20 text-neutral-400">
|
||||
<i class="ri-play-list-2-line mb-4 text-5xl opacity-20" />
|
||||
<p class="text-sm font-medium">{{ t('comp.recommendSonglist.empty') }}</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { getPersonalizedPlaylist } from '@/api/home';
|
||||
import { getListDetail } from '@/api/list';
|
||||
import { navigateToMusicList } from '@/components/common/MusicListNavigator';
|
||||
import { usePlayerCoreStore } from '@/store/modules/playerCore';
|
||||
import { usePlaylistStore } from '@/store/modules/playlist';
|
||||
import { calculateAnimationDelay, isElectron } from '@/utils';
|
||||
|
||||
import HomeListItem from './HomeListItem.vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title: string;
|
||||
limit?: number;
|
||||
columns?: number;
|
||||
rows?: number;
|
||||
}>(),
|
||||
{
|
||||
limit: 15,
|
||||
columns: 5,
|
||||
rows: 3
|
||||
}
|
||||
);
|
||||
|
||||
defineEmits<{
|
||||
(e: 'more'): void;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const playlists = ref<any[]>([]);
|
||||
const loading = ref(true);
|
||||
const playlistTracksMap = reactive<Record<number, any[]>>({});
|
||||
|
||||
// Calculate display count to fill exactly N rows
|
||||
const displayCount = computed(() => props.columns * props.rows);
|
||||
|
||||
const displayPlaylists = computed(() => {
|
||||
const count = displayCount.value;
|
||||
return playlists.value.slice(0, count);
|
||||
});
|
||||
|
||||
const gridStyle = computed(() => ({
|
||||
gridTemplateColumns: `repeat(${props.columns}, minmax(0, 1fr))`
|
||||
}));
|
||||
|
||||
const fetchPlaylists = async () => {
|
||||
try {
|
||||
const { data } = await getPersonalizedPlaylist(props.limit || displayCount.value + 5);
|
||||
if (data.code === 200) {
|
||||
playlists.value = data.result || [];
|
||||
// Preload tracks for displayed playlists (Electron only)
|
||||
if (isElectron) {
|
||||
preloadAllTracks();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch playlists:', error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const preloadAllTracks = async () => {
|
||||
const playlistsToLoad = displayPlaylists.value;
|
||||
|
||||
// Load tracks in parallel with concurrency limit
|
||||
const batchSize = 4;
|
||||
for (let i = 0; i < playlistsToLoad.length; i += batchSize) {
|
||||
const batch = playlistsToLoad.slice(i, i + batchSize);
|
||||
await Promise.all(
|
||||
batch.map(async (item) => {
|
||||
if (playlistTracksMap[item.id]) return;
|
||||
try {
|
||||
const { data } = await getListDetail(item.id);
|
||||
if (data.playlist?.tracks) {
|
||||
playlistTracksMap[item.id] = data.playlist.tracks.slice(0, 3).map((s: any) => ({
|
||||
id: s.id,
|
||||
name: s.name
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.debug('Failed to load tracks for playlist:', item.id, error);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePlaylistClick = async (item: any) => {
|
||||
try {
|
||||
navigateToMusicList(router, {
|
||||
id: item.id,
|
||||
type: 'playlist',
|
||||
name: item.name,
|
||||
listInfo: item,
|
||||
canRemove: false
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to navigate to playlist:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const playPlaylist = async (item: any) => {
|
||||
try {
|
||||
const { data } = await getListDetail(item.id);
|
||||
if (data.playlist?.tracks?.length > 0) {
|
||||
const playerCore = usePlayerCoreStore();
|
||||
const playlistStore = usePlaylistStore();
|
||||
|
||||
const playlist = data.playlist.tracks.map((s: any) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
picUrl: s.al?.picUrl || item.picUrl,
|
||||
source: 'netease',
|
||||
song: s,
|
||||
...s,
|
||||
playLoading: false
|
||||
}));
|
||||
|
||||
playlistStore.setPlayList(playlist, false, false);
|
||||
await playerCore.handlePlayMusic(playlist[0], true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to play playlist:', error);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchPlaylists();
|
||||
});
|
||||
</script>
|
||||
144
src/renderer/views/home/components/HomePrivateContent.vue
Normal file
144
src/renderer/views/home/components/HomePrivateContent.vue
Normal file
@@ -0,0 +1,144 @@
|
||||
<template>
|
||||
<section class="private-content-section">
|
||||
<!-- Section Header -->
|
||||
<div class="section-header mb-6 md:mb-8 flex items-end justify-between">
|
||||
<div>
|
||||
<h2 class="section-title text-neutral-900 dark:text-white">
|
||||
{{ title }}
|
||||
</h2>
|
||||
<div class="mt-1.5 h-1 w-12 rounded-full bg-primary" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading Skeleton -->
|
||||
<div
|
||||
v-if="loading"
|
||||
class="grid grid-cols-1 gap-4 md:gap-5 lg:gap-6 md:grid-cols-2 lg:grid-cols-3"
|
||||
>
|
||||
<div
|
||||
v-for="i in 3"
|
||||
:key="i"
|
||||
class="skeleton-item animate-pulse rounded-2xl md:rounded-3xl bg-neutral-100 dark:bg-neutral-800/50"
|
||||
style="aspect-ratio: 16/9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Private Content Grid -->
|
||||
<div
|
||||
v-else-if="contentList.length > 0"
|
||||
class="private-content-grid grid grid-cols-1 gap-6 md:gap-8 lg:gap-10 md:grid-cols-2 lg:grid-cols-3"
|
||||
>
|
||||
<div
|
||||
v-for="(content, index) in contentList"
|
||||
:key="content.id"
|
||||
class="content-item animate-item group relative flex flex-col cursor-pointer overflow-hidden rounded-2xl md:rounded-3xl bg-neutral-50 dark:bg-neutral-900"
|
||||
:style="{ animationDelay: calculateAnimationDelay(index, 0.1) }"
|
||||
@click="handleContentClick(content)"
|
||||
>
|
||||
<!-- Cover Image (16:9) -->
|
||||
<div class="cover-wrapper relative" style="aspect-ratio: 16/9">
|
||||
<img
|
||||
:src="getImgUrl(content.picUrl, '640y360')"
|
||||
class="h-full w-full object-cover transition-transform duration-500 ease-out group-hover:scale-105"
|
||||
loading="lazy"
|
||||
:alt="content.name"
|
||||
/>
|
||||
|
||||
<!-- Gradient Overlay -->
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-t from-black/60 via-black/20 to-transparent"
|
||||
/>
|
||||
|
||||
<!-- Exclusive Badge -->
|
||||
<div
|
||||
class="exclusive-badge absolute top-4 left-4 flex items-center gap-1.5 rounded-full bg-purple-600 px-3 py-1.5 text-xs font-bold text-white shadow-lg"
|
||||
>
|
||||
<i class="iconfont icon-vip text-sm" />
|
||||
<span>独家</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content Info -->
|
||||
<div class="content-info p-4 md:p-6">
|
||||
<h3
|
||||
class="content-name line-clamp-2 text-base md:text-lg font-bold leading-tight text-neutral-800 dark:text-neutral-100 transition-colors duration-200 group-hover:text-primary dark:group-hover:text-white"
|
||||
>
|
||||
{{ content.name }}
|
||||
</h3>
|
||||
<p
|
||||
v-if="content.copywriter"
|
||||
class="copywriter mt-2 line-clamp-2 text-xs md:text-sm text-neutral-500 dark:text-neutral-400"
|
||||
>
|
||||
{{ content.copywriter }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div
|
||||
v-else
|
||||
class="empty-state flex flex-col items-center justify-center py-20 text-neutral-400"
|
||||
>
|
||||
<i class="iconfont icon-empty text-6xl mb-4 opacity-30" />
|
||||
<p>暂无独家内容</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { getPrivateContent } from '@/api/home';
|
||||
import { calculateAnimationDelay, getImgUrl } from '@/utils';
|
||||
|
||||
defineProps<{
|
||||
title: string;
|
||||
}>();
|
||||
|
||||
const router = useRouter();
|
||||
const contentList = ref<any[]>([]);
|
||||
const loading = ref(true);
|
||||
|
||||
const fetchPrivateContent = async () => {
|
||||
try {
|
||||
const { data } = await getPrivateContent();
|
||||
if (data.code === 200 && data.result) {
|
||||
contentList.value = data.result;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch private content:', error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleContentClick = (content: any) => {
|
||||
// 根据内容类型跳转
|
||||
if (content.type === 'video' && content.id) {
|
||||
router.push(`/mv?id=${content.id}`);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchPrivateContent();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Typography System */
|
||||
.section-title {
|
||||
@apply text-2xl md:text-3xl lg:text-4xl font-bold tracking-tight;
|
||||
}
|
||||
|
||||
/* Grid */
|
||||
.private-content-grid {
|
||||
grid-auto-rows: auto;
|
||||
}
|
||||
|
||||
.cover-wrapper {
|
||||
overflow: hidden;
|
||||
will-change: transform;
|
||||
}
|
||||
</style>
|
||||
262
src/renderer/views/home/components/NavCard.vue
Normal file
262
src/renderer/views/home/components/NavCard.vue
Normal file
@@ -0,0 +1,262 @@
|
||||
<template>
|
||||
<div
|
||||
class="nav-card group relative overflow-hidden rounded-xl md:rounded-2xl cursor-pointer transition-all duration-300"
|
||||
:class="[
|
||||
aspectClass,
|
||||
colorClasses.bg,
|
||||
active ? colorClasses.activeBg : '',
|
||||
'hover:shadow-lg hover:-translate-y-0.5'
|
||||
]"
|
||||
@click="$emit('click')"
|
||||
>
|
||||
<!-- Background Pattern -->
|
||||
<div class="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-500">
|
||||
<div
|
||||
class="absolute inset-0"
|
||||
:class="colorClasses.pattern"
|
||||
style="
|
||||
background-image: radial-gradient(circle at 20% 50%, currentColor 1px, transparent 1px);
|
||||
background-size: 20px 20px;
|
||||
opacity: 0.05;
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Glow Effect on Hover -->
|
||||
<div
|
||||
class="absolute -inset-[1px] rounded-xl md:rounded-2xl opacity-0 group-hover:opacity-100 transition-opacity duration-300 blur-sm"
|
||||
:class="colorClasses.glow"
|
||||
/>
|
||||
|
||||
<!-- Content Container -->
|
||||
<div class="relative h-full flex flex-col justify-between p-4 md:p-5">
|
||||
<!-- Header with Icon and Badge -->
|
||||
<div class="flex items-start justify-between">
|
||||
<!-- Icon -->
|
||||
<div
|
||||
class="icon-wrapper flex items-center justify-center h-10 w-10 md:h-11 md:w-11 rounded-xl md:rounded-2xl transition-all duration-300 group-hover:scale-110 group-hover:rotate-3"
|
||||
:class="[colorClasses.iconBg, active ? colorClasses.activeIconBg : '']"
|
||||
>
|
||||
<i
|
||||
:class="[
|
||||
icon,
|
||||
colorClasses.iconColor,
|
||||
'text-lg md:text-xl transition-all duration-300'
|
||||
]"
|
||||
></i>
|
||||
</div>
|
||||
|
||||
<!-- Badge (optional) -->
|
||||
<div
|
||||
v-if="badge"
|
||||
class="badge px-2 py-0.5 rounded-full text-[10px] md:text-xs font-semibold animate-pulse"
|
||||
:class="colorClasses.badgeBg"
|
||||
>
|
||||
{{ badge }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Text Content -->
|
||||
<div class="space-y-0.5 md:space-y-1">
|
||||
<h3
|
||||
class="text-sm md:text-base font-bold tracking-tight line-clamp-1"
|
||||
:class="colorClasses.title"
|
||||
>
|
||||
{{ title }}
|
||||
</h3>
|
||||
<p class="text-xs md:text-sm line-clamp-1" :class="colorClasses.subtitle">
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Arrow Indicator -->
|
||||
<div
|
||||
class="absolute bottom-3 right-3 md:bottom-4 md:right-4 opacity-0 group-hover:opacity-100 transition-all duration-300 group-hover:translate-x-1"
|
||||
:class="colorClasses.arrow"
|
||||
>
|
||||
<i class="ri-arrow-right-line text-sm md:text-base"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active Indicator -->
|
||||
<div
|
||||
v-if="active"
|
||||
class="absolute top-3 left-3 h-2 w-2 rounded-full animate-pulse"
|
||||
:class="colorClasses.activeDot"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
interface Props {
|
||||
icon: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
color?: 'rose' | 'amber' | 'purple' | 'blue' | 'emerald' | 'cyan';
|
||||
active?: boolean;
|
||||
badge?: string | null;
|
||||
aspect?: 'square' | 'tall' | 'wide';
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
color: 'blue',
|
||||
active: false,
|
||||
badge: null,
|
||||
aspect: 'square'
|
||||
});
|
||||
|
||||
defineEmits<{
|
||||
(e: 'click'): void;
|
||||
}>();
|
||||
|
||||
const aspectClass = computed(() => {
|
||||
switch (props.aspect) {
|
||||
case 'tall':
|
||||
return 'aspect-[4/5]';
|
||||
case 'wide':
|
||||
return 'aspect-[16/9]';
|
||||
default:
|
||||
return 'aspect-square md:aspect-[4/3]';
|
||||
}
|
||||
});
|
||||
|
||||
const colorClasses = computed(() => {
|
||||
const colors = {
|
||||
rose: {
|
||||
bg: 'bg-rose-50 dark:bg-rose-950/30',
|
||||
activeBg: 'dark:bg-rose-950/50',
|
||||
iconBg: 'bg-rose-100 dark:bg-rose-900/40',
|
||||
activeIconBg: 'dark:bg-rose-900/60',
|
||||
iconColor: 'text-rose-600 dark:text-rose-400',
|
||||
title: 'text-rose-900 dark:text-rose-100',
|
||||
subtitle: 'text-rose-700 dark:text-rose-300',
|
||||
arrow: 'text-rose-600 dark:text-rose-400',
|
||||
pattern: 'text-rose-600',
|
||||
glow: 'bg-rose-500/20',
|
||||
badgeBg: 'bg-rose-500 text-white',
|
||||
activeDot: 'bg-rose-500'
|
||||
},
|
||||
amber: {
|
||||
bg: 'bg-amber-50 dark:bg-amber-950/30',
|
||||
activeBg: 'dark:bg-amber-950/50',
|
||||
iconBg: 'bg-amber-100 dark:bg-amber-900/40',
|
||||
activeIconBg: 'dark:bg-amber-900/60',
|
||||
iconColor: 'text-amber-600 dark:text-amber-400',
|
||||
title: 'text-amber-900 dark:text-amber-100',
|
||||
subtitle: 'text-amber-700 dark:text-amber-300',
|
||||
arrow: 'text-amber-600 dark:text-amber-400',
|
||||
pattern: 'text-amber-600',
|
||||
glow: 'bg-amber-500/20',
|
||||
badgeBg: 'bg-amber-500 text-white',
|
||||
activeDot: 'bg-amber-500'
|
||||
},
|
||||
purple: {
|
||||
bg: 'bg-purple-50 dark:bg-purple-950/30',
|
||||
activeBg: 'dark:bg-purple-950/50',
|
||||
iconBg: 'bg-purple-100 dark:bg-purple-900/40',
|
||||
activeIconBg: 'dark:bg-purple-900/60',
|
||||
iconColor: 'text-purple-600 dark:text-purple-400',
|
||||
title: 'text-purple-900 dark:text-purple-100',
|
||||
subtitle: 'text-purple-700 dark:text-purple-300',
|
||||
arrow: 'text-purple-600 dark:text-purple-400',
|
||||
pattern: 'text-purple-600',
|
||||
glow: 'bg-purple-500/20',
|
||||
badgeBg: 'bg-purple-500 text-white',
|
||||
activeDot: 'bg-purple-500'
|
||||
},
|
||||
blue: {
|
||||
bg: 'bg-blue-50 dark:bg-blue-950/30',
|
||||
activeBg: 'dark:bg-blue-950/50',
|
||||
iconBg: 'bg-blue-100 dark:bg-blue-900/40',
|
||||
activeIconBg: 'dark:bg-blue-900/60',
|
||||
iconColor: 'text-blue-600 dark:text-blue-400',
|
||||
title: 'text-blue-900 dark:text-blue-100',
|
||||
subtitle: 'text-blue-700 dark:text-blue-300',
|
||||
arrow: 'text-blue-600 dark:text-blue-400',
|
||||
pattern: 'text-blue-600',
|
||||
glow: 'bg-blue-500/20',
|
||||
badgeBg: 'bg-blue-500 text-white',
|
||||
activeDot: 'bg-blue-500'
|
||||
},
|
||||
emerald: {
|
||||
bg: 'bg-emerald-50 dark:bg-emerald-950/30',
|
||||
activeBg: 'dark:bg-emerald-950/50',
|
||||
iconBg: 'bg-emerald-100 dark:bg-emerald-900/40',
|
||||
activeIconBg: 'dark:bg-emerald-900/60',
|
||||
iconColor: 'text-emerald-600 dark:text-emerald-400',
|
||||
title: 'text-emerald-900 dark:text-emerald-100',
|
||||
subtitle: 'text-emerald-700 dark:text-emerald-300',
|
||||
arrow: 'text-emerald-600 dark:text-emerald-400',
|
||||
pattern: 'text-emerald-600',
|
||||
glow: 'bg-emerald-500/20',
|
||||
badgeBg: 'bg-emerald-500 text-white',
|
||||
activeDot: 'bg-emerald-500'
|
||||
},
|
||||
cyan: {
|
||||
bg: 'bg-cyan-50 dark:bg-cyan-950/30',
|
||||
activeBg: 'dark:bg-cyan-950/50',
|
||||
iconBg: 'bg-cyan-100 dark:bg-cyan-900/40',
|
||||
activeIconBg: 'dark:bg-cyan-900/60',
|
||||
iconColor: 'text-cyan-600 dark:text-cyan-400',
|
||||
title: 'text-cyan-900 dark:text-cyan-100',
|
||||
subtitle: 'text-cyan-700 dark:text-cyan-300',
|
||||
arrow: 'text-cyan-600 dark:text-cyan-400',
|
||||
pattern: 'text-cyan-600',
|
||||
glow: 'bg-cyan-500/20',
|
||||
badgeBg: 'bg-cyan-500 text-white',
|
||||
activeDot: 'bg-cyan-500'
|
||||
}
|
||||
};
|
||||
|
||||
return colors[props.color];
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.nav-card {
|
||||
position: relative;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.nav-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
padding: 1px;
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.2) 0%, transparent 100%);
|
||||
-webkit-mask:
|
||||
linear-gradient(#fff 0 0) content-box,
|
||||
linear-gradient(#fff 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask-composite: exclude;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.nav-card:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.icon-wrapper {
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
@keyframes pulse-subtle {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.badge {
|
||||
animation: pulse-subtle 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
</style>
|
||||
@@ -1,63 +1,81 @@
|
||||
<template>
|
||||
<n-scrollbar :size="100" :x-scrollable="false">
|
||||
<div class="main-page">
|
||||
<!-- 推荐歌手 -->
|
||||
<top-banner />
|
||||
<div class="main-content">
|
||||
<!-- 歌单分类列表 -->
|
||||
<playlist-type v-if="!isMobile" />
|
||||
<!-- 本周最热音乐 -->
|
||||
<recommend-songlist />
|
||||
<!-- 推荐最新专辑 -->
|
||||
<div>
|
||||
<favorite-list is-component />
|
||||
<recommend-album />
|
||||
<div class="home-container h-full w-full bg-white dark:bg-black transition-colors duration-500">
|
||||
<n-scrollbar class="h-full">
|
||||
<div class="home-content w-full pb-32 pt-6 px-4 sm:px-6 lg:px-8 lg:pl-0">
|
||||
<!-- Hero Section -->
|
||||
<home-hero />
|
||||
|
||||
<!-- Main Content Sections -->
|
||||
<div class="content-sections space-y-10 md:space-y-8 lg:space-y-12">
|
||||
<!-- Recommended Playlists (Grid Section) -->
|
||||
<home-playlist-section :title="t('comp.recommendSonglist.title')" :limit="18" />
|
||||
|
||||
<!-- Hot Artists (Horizontal Scroll Section) -->
|
||||
<home-artists :title="t('comp.recommendSinger.title')" :limit="15" />
|
||||
|
||||
<!-- New Albums (NEW - 新碟上架) -->
|
||||
<home-album-section
|
||||
:title="t('comp.newAlbum.title')"
|
||||
:limit="6"
|
||||
:columns="5"
|
||||
:rows="1"
|
||||
@more="router.push('/album')"
|
||||
/>
|
||||
|
||||
<!-- New Songs (Compact Grid Section) -->
|
||||
<home-new-songs :title="t('comp.recommendNewMusic.title')" :limit="20" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</n-scrollbar>
|
||||
</n-scrollbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import PlaylistType from '@/components/home/PlaylistType.vue';
|
||||
import RecommendAlbum from '@/components/home/RecommendAlbum.vue';
|
||||
import RecommendSonglist from '@/components/home/RecommendSonglist.vue';
|
||||
import TopBanner from '@/components/home/TopBanner.vue';
|
||||
import { isMobile } from '@/utils';
|
||||
import FavoriteList from '@/views/favorite/index.vue';
|
||||
import { NScrollbar } from 'naive-ui';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import HomeAlbumSection from './components/HomeAlbumSection.vue';
|
||||
import HomeArtists from './components/HomeArtists.vue';
|
||||
import HomeHero from './components/HomeHero.vue';
|
||||
import HomeNewSongs from './components/HomeNewSongs.vue';
|
||||
import HomePlaylistSection from './components/HomePlaylistSection.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'Home'
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.main-page {
|
||||
@apply h-full w-full overflow-hidden bg-light dark:bg-black;
|
||||
}
|
||||
.main-content {
|
||||
@apply mt-6 flex mb-28;
|
||||
.home-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.mobile {
|
||||
.main-content {
|
||||
@apply flex-col mx-4 mb-40;
|
||||
/* Global animation optimization - use will-change sparingly */
|
||||
:deep(.animate-item) {
|
||||
animation: fadeInUp 0.6s cubic-bezier(0.16, 1, 0.3, 1) backwards;
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(24px);
|
||||
}
|
||||
:deep(.favorite-page) {
|
||||
@apply p-0 mx-4 h-full;
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.favorite-page) {
|
||||
@apply p-0 mx-4 h-[300px];
|
||||
.favorite-header {
|
||||
@apply mb-0 px-0 !important;
|
||||
h2 {
|
||||
@apply text-lg font-bold text-gray-900 dark:text-white;
|
||||
/* Stagger delays for sequential animations */
|
||||
:deep(.animate-item) {
|
||||
@for $i from 1 through 20 {
|
||||
&:nth-child(#{$i}) {
|
||||
animation-delay: #{$i * 0.05}s;
|
||||
}
|
||||
}
|
||||
.favorite-list {
|
||||
@apply px-0 !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user