mirror of
https://github.com/algerkong/AlgerMusicPlayer.git
synced 2026-05-17 10:27:30 +08:00
🦄 refactor: 重构整个项目 优化打包 修改后台服务为本地运行 添加更新版本检测功能
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
<template>
|
||||
<div v-if="isComponent ? favoriteSongs.length : true" class="favorite-page">
|
||||
<div class="favorite-header" :class="setAnimationClass('animate__fadeInLeft')">
|
||||
<h2>我的收藏</h2>
|
||||
<div class="favorite-count">共 {{ favoriteList.length }} 首</div>
|
||||
</div>
|
||||
<div class="favorite-main" :class="setAnimationClass('animate__bounceInRight')">
|
||||
<n-scrollbar ref="scrollbarRef" class="favorite-content" @scroll="handleScroll">
|
||||
<div v-if="favoriteList.length === 0" class="empty-tip">
|
||||
<n-empty description="还没有收藏歌曲" />
|
||||
</div>
|
||||
<div v-else class="favorite-list">
|
||||
<song-item
|
||||
v-for="(song, index) in favoriteSongs"
|
||||
:key="song.id"
|
||||
:item="song"
|
||||
:favorite="!isComponent"
|
||||
:class="setAnimationClass('animate__bounceInLeft')"
|
||||
:style="getItemAnimationDelay(index)"
|
||||
@play="handlePlay"
|
||||
/>
|
||||
<div v-if="isComponent" class="favorite-list-more text-center">
|
||||
<n-button text type="primary" @click="handleMore">查看更多</n-button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading-wrapper">
|
||||
<n-spin size="large" />
|
||||
</div>
|
||||
|
||||
<div v-if="noMore" class="no-more-tip">没有更多了</div>
|
||||
</div>
|
||||
</n-scrollbar>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useStore } from 'vuex';
|
||||
|
||||
import { getMusicDetail } from '@/api/music';
|
||||
import SongItem from '@/components/common/SongItem.vue';
|
||||
import type { SongResult } from '@/type/music';
|
||||
import { setAnimationClass, setAnimationDelay } from '@/utils';
|
||||
|
||||
const store = useStore();
|
||||
const favoriteList = computed(() => store.state.favoriteList);
|
||||
const favoriteSongs = ref<SongResult[]>([]);
|
||||
const loading = ref(false);
|
||||
const noMore = ref(false);
|
||||
const scrollbarRef = ref();
|
||||
|
||||
// 无限滚动相关
|
||||
const pageSize = 16;
|
||||
const currentPage = ref(1);
|
||||
|
||||
const props = defineProps({
|
||||
isComponent: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
});
|
||||
|
||||
// 获取当前页的收藏歌曲ID
|
||||
const getCurrentPageIds = () => {
|
||||
const reversedList = [...favoriteList.value];
|
||||
const startIndex = (currentPage.value - 1) * pageSize;
|
||||
const endIndex = startIndex + pageSize;
|
||||
return reversedList.slice(startIndex, endIndex);
|
||||
};
|
||||
|
||||
// 获取收藏歌曲详情
|
||||
const getFavoriteSongs = async () => {
|
||||
if (favoriteList.value.length === 0) {
|
||||
favoriteSongs.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
if (props.isComponent && favoriteSongs.value.length >= 16) {
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
const currentIds = getCurrentPageIds();
|
||||
const res = await getMusicDetail(currentIds);
|
||||
if (res.data.songs) {
|
||||
const newSongs = res.data.songs.map((song: SongResult) => ({
|
||||
...song,
|
||||
picUrl: song.al?.picUrl || ''
|
||||
}));
|
||||
|
||||
// 追加新数据而不是替换
|
||||
if (currentPage.value === 1) {
|
||||
favoriteSongs.value = newSongs;
|
||||
} else {
|
||||
favoriteSongs.value = [...favoriteSongs.value, ...newSongs];
|
||||
}
|
||||
|
||||
// 判断是否还有更多数据
|
||||
noMore.value = favoriteSongs.value.length >= favoriteList.value.length;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取收藏歌曲失败:', error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 处理滚动事件
|
||||
const handleScroll = (e: any) => {
|
||||
const { scrollTop, scrollHeight, offsetHeight } = e.target;
|
||||
const threshold = 100; // 距离底部多少像素时加载更多
|
||||
|
||||
if (!loading.value && !noMore.value && scrollHeight - (scrollTop + offsetHeight) < threshold) {
|
||||
currentPage.value++;
|
||||
getFavoriteSongs();
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
getFavoriteSongs();
|
||||
});
|
||||
|
||||
// 监听收藏列表变化
|
||||
watch(
|
||||
favoriteList,
|
||||
() => {
|
||||
currentPage.value = 1;
|
||||
noMore.value = false;
|
||||
getFavoriteSongs();
|
||||
},
|
||||
{ deep: true, immediate: true }
|
||||
);
|
||||
|
||||
const handlePlay = () => {
|
||||
store.commit('setPlayList', favoriteSongs.value);
|
||||
};
|
||||
|
||||
const getItemAnimationDelay = (index: number) => {
|
||||
return setAnimationDelay(index, 30);
|
||||
};
|
||||
|
||||
const router = useRouter();
|
||||
const handleMore = () => {
|
||||
router.push('/favorite');
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.favorite-page {
|
||||
@apply h-full flex flex-col pt-2;
|
||||
@apply bg-light dark:bg-black;
|
||||
|
||||
.favorite-header {
|
||||
@apply flex items-center justify-between flex-shrink-0 px-4;
|
||||
|
||||
h2 {
|
||||
@apply text-xl font-bold pb-2;
|
||||
@apply text-gray-900 dark:text-white;
|
||||
}
|
||||
|
||||
.favorite-count {
|
||||
@apply text-gray-500 dark:text-gray-400 text-sm;
|
||||
}
|
||||
}
|
||||
|
||||
.favorite-main {
|
||||
@apply flex flex-col flex-grow min-h-0;
|
||||
|
||||
.favorite-content {
|
||||
@apply flex-grow min-h-0;
|
||||
|
||||
.empty-tip {
|
||||
@apply h-full flex items-center justify-center;
|
||||
@apply text-gray-500 dark:text-gray-400;
|
||||
}
|
||||
|
||||
.favorite-list {
|
||||
@apply space-y-2 pb-4 px-4;
|
||||
|
||||
&-more {
|
||||
@apply mt-4;
|
||||
|
||||
.n-button {
|
||||
@apply text-green-500 hover:text-green-600;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.loading-wrapper {
|
||||
@apply flex justify-center items-center py-20;
|
||||
}
|
||||
|
||||
.no-more-tip {
|
||||
@apply text-center py-4 text-sm;
|
||||
@apply text-gray-500 dark:text-gray-400;
|
||||
}
|
||||
|
||||
.mobile {
|
||||
.favorite-page {
|
||||
@apply p-4;
|
||||
|
||||
.favorite-header {
|
||||
@apply mb-4;
|
||||
|
||||
h2 {
|
||||
@apply text-xl;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,165 @@
|
||||
<template>
|
||||
<div class="history-page">
|
||||
<div class="title" :class="setAnimationClass('animate__fadeInRight')">播放历史</div>
|
||||
<n-scrollbar ref="scrollbarRef" :size="100" @scroll="handleScroll">
|
||||
<div class="history-list-content" :class="setAnimationClass('animate__bounceInLeft')">
|
||||
<div
|
||||
v-for="(item, index) in displayList"
|
||||
:key="item.id"
|
||||
class="history-item"
|
||||
:class="setAnimationClass('animate__bounceInRight')"
|
||||
:style="setAnimationDelay(index, 30)"
|
||||
>
|
||||
<song-item class="history-item-content" :item="item" @play="handlePlay" />
|
||||
<div class="history-item-count min-w-[60px]">
|
||||
{{ item.count }}
|
||||
</div>
|
||||
<div class="history-item-delete">
|
||||
<i class="iconfont icon-close" @click="handleDelMusic(item)"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading-wrapper">
|
||||
<n-spin size="large" />
|
||||
</div>
|
||||
|
||||
<div v-if="noMore" class="no-more-tip">没有更多了</div>
|
||||
</div>
|
||||
</n-scrollbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
|
||||
import { getMusicDetail } from '@/api/music';
|
||||
import { useMusicHistory } from '@/hooks/MusicHistoryHook';
|
||||
import type { SongResult } from '@/type/music';
|
||||
import { setAnimationClass, setAnimationDelay } from '@/utils';
|
||||
import SongItem from '@/components/common/SongItem.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'History'
|
||||
});
|
||||
|
||||
const store = useStore();
|
||||
const { delMusic, musicList } = useMusicHistory();
|
||||
const scrollbarRef = ref();
|
||||
const loading = ref(false);
|
||||
const noMore = ref(false);
|
||||
const displayList = ref<SongResult[]>([]);
|
||||
|
||||
// 无限滚动相关配置
|
||||
const pageSize = 20;
|
||||
const currentPage = ref(1);
|
||||
|
||||
// 获取当前页的音乐详情
|
||||
const getHistorySongs = async () => {
|
||||
if (musicList.value.length === 0) {
|
||||
displayList.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
const startIndex = (currentPage.value - 1) * pageSize;
|
||||
const endIndex = startIndex + pageSize;
|
||||
const currentPageItems = musicList.value.slice(startIndex, endIndex);
|
||||
|
||||
const currentIds = currentPageItems.map((item) => item.id);
|
||||
const res = await getMusicDetail(currentIds);
|
||||
|
||||
if (res.data.songs) {
|
||||
const newSongs = res.data.songs.map((song: SongResult) => {
|
||||
const historyItem = currentPageItems.find((item) => item.id === song.id);
|
||||
return {
|
||||
...song,
|
||||
picUrl: song.al?.picUrl || '',
|
||||
count: historyItem?.count || 0
|
||||
};
|
||||
});
|
||||
|
||||
if (currentPage.value === 1) {
|
||||
displayList.value = newSongs;
|
||||
} else {
|
||||
displayList.value = [...displayList.value, ...newSongs];
|
||||
}
|
||||
|
||||
noMore.value = displayList.value.length >= musicList.value.length;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取历史记录失败:', error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 处理滚动事件
|
||||
const handleScroll = (e: any) => {
|
||||
const { scrollTop, scrollHeight, offsetHeight } = e.target;
|
||||
const threshold = 100; // 距离底部多少像素时加载更多
|
||||
|
||||
if (!loading.value && !noMore.value && scrollHeight - (scrollTop + offsetHeight) < threshold) {
|
||||
currentPage.value++;
|
||||
getHistorySongs();
|
||||
}
|
||||
};
|
||||
|
||||
// 播放全部
|
||||
const handlePlay = () => {
|
||||
store.commit('setPlayList', displayList.value);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
getHistorySongs();
|
||||
});
|
||||
|
||||
// 重写删除方法,需要同时更新 displayList
|
||||
const handleDelMusic = async (item: SongResult) => {
|
||||
delMusic(item);
|
||||
musicList.value = musicList.value.filter((music) => music.id !== item.id);
|
||||
displayList.value = displayList.value.filter((music) => music.id !== item.id);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.history-page {
|
||||
@apply h-full w-full pt-2;
|
||||
@apply bg-light dark:bg-black;
|
||||
|
||||
.title {
|
||||
@apply pl-4 text-xl font-bold pb-2 px-4;
|
||||
@apply text-gray-900 dark:text-white;
|
||||
}
|
||||
|
||||
.history-list-content {
|
||||
@apply mt-2 pb-28 px-4;
|
||||
.history-item {
|
||||
@apply flex items-center justify-between;
|
||||
&-content {
|
||||
@apply flex-1;
|
||||
}
|
||||
&-count {
|
||||
@apply px-4 text-lg text-center;
|
||||
@apply text-gray-600 dark:text-gray-400;
|
||||
}
|
||||
&-delete {
|
||||
@apply cursor-pointer rounded-full border-2 w-8 h-8 flex justify-center items-center;
|
||||
@apply border-gray-400 dark:border-gray-600;
|
||||
@apply text-gray-600 dark:text-gray-400;
|
||||
@apply hover:border-red-500 hover:text-red-500;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.loading-wrapper {
|
||||
@apply flex justify-center items-center py-8;
|
||||
}
|
||||
|
||||
.no-more-tip {
|
||||
@apply text-center py-4 text-sm;
|
||||
@apply text-gray-500 dark:text-gray-400;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<div class="flex gap-4 h-full pb-4">
|
||||
<favorite class="flex-item" />
|
||||
<history class="flex-item" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Favorite from '@/views/favorite/index.vue';
|
||||
import History from '@/views/history/index.vue';
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.flex-item {
|
||||
@apply flex-1 bg-light-100 dark:bg-dark-100 rounded-2xl overflow-hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<n-scrollbar :size="100" :x-scrollable="false">
|
||||
<div class="main-page">
|
||||
<!-- 推荐歌手 -->
|
||||
<recommend-singer />
|
||||
<div class="main-content">
|
||||
<!-- 歌单分类列表 -->
|
||||
<playlist-type v-if="!isMobile" />
|
||||
<!-- 本周最热音乐 -->
|
||||
<recommend-songlist />
|
||||
<!-- 推荐最新专辑 -->
|
||||
<div>
|
||||
<favorite-list is-component />
|
||||
<recommend-album />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</n-scrollbar>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import PlaylistType from '@/components/PlaylistType.vue';
|
||||
import RecommendAlbum from '@/components/RecommendAlbum.vue';
|
||||
import RecommendSinger from '@/components/RecommendSinger.vue';
|
||||
import RecommendSonglist from '@/components/RecommendSonglist.vue';
|
||||
import { isMobile } from '@/utils';
|
||||
import FavoriteList from '@/views/favorite/index.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'Home'
|
||||
});
|
||||
</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;
|
||||
}
|
||||
|
||||
.mobile {
|
||||
.main-content {
|
||||
@apply flex-col mx-4;
|
||||
}
|
||||
:deep(.favorite-page) {
|
||||
@apply p-0 mx-4 h-full;
|
||||
}
|
||||
}
|
||||
|
||||
: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;
|
||||
}
|
||||
}
|
||||
.favorite-list {
|
||||
@apply px-0 !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,329 @@
|
||||
<template>
|
||||
<div class="list-page">
|
||||
<!-- 修改歌单分类部分 -->
|
||||
<div class="play-list-type">
|
||||
<n-scrollbar ref="scrollbarRef" x-scrollable>
|
||||
<div class="categories-wrapper" @wheel.prevent="handleWheel">
|
||||
<span
|
||||
v-for="(item, index) in playlistCategory?.sub"
|
||||
:key="item.name"
|
||||
class="play-list-type-item"
|
||||
:class="[setAnimationClass('animate__bounceIn'), { active: currentType === item.name }]"
|
||||
:style="getAnimationDelay(index)"
|
||||
@click="handleClickPlaylistType(item.name)"
|
||||
>
|
||||
{{ item.name }}
|
||||
</span>
|
||||
</div>
|
||||
</n-scrollbar>
|
||||
</div>
|
||||
<!-- 歌单列表 -->
|
||||
<n-scrollbar class="recommend" :size="100" @scroll="handleScroll">
|
||||
<div v-loading="loading" class="recommend-list">
|
||||
<div
|
||||
v-for="(item, index) in recommendList"
|
||||
:key="item.id"
|
||||
class="recommend-item"
|
||||
:class="setAnimationClass('animate__bounceIn')"
|
||||
:style="getItemAnimationDelay(index)"
|
||||
@click.stop="selectRecommendItem(item)"
|
||||
>
|
||||
<div class="recommend-item-img">
|
||||
<n-image
|
||||
class="recommend-item-img-img"
|
||||
:src="getImgUrl(item.picUrl || item.coverImgUrl, '200y200')"
|
||||
width="200"
|
||||
height="200"
|
||||
lazy
|
||||
preview-disabled
|
||||
/>
|
||||
<div class="top">
|
||||
<div class="play-count">{{ formatNumber(item.playCount) }}</div>
|
||||
<i class="iconfont icon-videofill"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="recommend-item-title">{{ item.name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="isLoadingMore" class="loading-more">
|
||||
<n-spin size="small" />
|
||||
<span class="ml-2">加载中...</span>
|
||||
</div>
|
||||
<div v-if="!hasMore && recommendList.length > 0" class="no-more">没有更多了</div>
|
||||
</n-scrollbar>
|
||||
<music-list
|
||||
v-model:show="showMusic"
|
||||
v-model:loading="listLoading"
|
||||
:name="recommendItem?.name || ''"
|
||||
:song-list="listDetail?.playlist.tracks || []"
|
||||
:list-info="listDetail?.playlist"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
import { getPlaylistCategory } from '@/api/home';
|
||||
import { getListByCat, getListDetail } from '@/api/list';
|
||||
import MusicList from '@/components/MusicList.vue';
|
||||
import type { IRecommendItem } from '@/type/list';
|
||||
import type { IListDetail } from '@/type/listDetail';
|
||||
import type { IPlayListSort } from '@/type/playlist';
|
||||
import { formatNumber, getImgUrl, setAnimationClass, setAnimationDelay } from '@/utils';
|
||||
|
||||
defineOptions({
|
||||
name: 'List'
|
||||
});
|
||||
|
||||
const TOTAL_ITEMS = 42; // 每页数量
|
||||
|
||||
const recommendList = ref<any[]>([]);
|
||||
const showMusic = ref(false);
|
||||
const page = ref(0);
|
||||
const hasMore = ref(true);
|
||||
const isLoadingMore = ref(false);
|
||||
|
||||
// 计算每个项目的动画延迟
|
||||
const getItemAnimationDelay = (index: number) => {
|
||||
const currentPageIndex = index % TOTAL_ITEMS;
|
||||
return setAnimationDelay(currentPageIndex, 30);
|
||||
};
|
||||
|
||||
const recommendItem = ref<IRecommendItem | null>();
|
||||
const listDetail = ref<IListDetail | null>();
|
||||
const listLoading = ref(true);
|
||||
|
||||
const selectRecommendItem = async (item: IRecommendItem) => {
|
||||
listLoading.value = true;
|
||||
recommendItem.value = null;
|
||||
listDetail.value = null;
|
||||
showMusic.value = true;
|
||||
recommendItem.value = item;
|
||||
const { data } = await getListDetail(item.id);
|
||||
listDetail.value = data;
|
||||
listLoading.value = false;
|
||||
};
|
||||
|
||||
const route = useRoute();
|
||||
const listTitle = ref(route.query.type || '歌单列表');
|
||||
|
||||
const loading = ref(false);
|
||||
const loadList = async (type: string, isLoadMore = false) => {
|
||||
if (!hasMore.value && isLoadMore) return;
|
||||
if (isLoadMore) {
|
||||
isLoadingMore.value = true;
|
||||
} else {
|
||||
loading.value = true;
|
||||
page.value = 0;
|
||||
recommendList.value = [];
|
||||
}
|
||||
|
||||
try {
|
||||
const params = {
|
||||
cat: type === '每日推荐' ? '' : type,
|
||||
limit: TOTAL_ITEMS,
|
||||
offset: page.value * TOTAL_ITEMS
|
||||
};
|
||||
const { data } = await getListByCat(params);
|
||||
if (isLoadMore) {
|
||||
recommendList.value.push(...data.playlists);
|
||||
} else {
|
||||
recommendList.value = data.playlists;
|
||||
}
|
||||
hasMore.value = data.more;
|
||||
page.value++;
|
||||
} catch (error) {
|
||||
console.error('加载歌单列表失败:', error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
isLoadingMore.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 监听滚动事件
|
||||
const handleScroll = (e: any) => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = e.target;
|
||||
// 距离底部100px时加载更多
|
||||
if (scrollTop + clientHeight >= scrollHeight - 100 && !isLoadingMore.value && hasMore.value) {
|
||||
loadList(route.query.type as string, true);
|
||||
}
|
||||
};
|
||||
|
||||
// 添加歌单分类相关的代码
|
||||
const playlistCategory = ref<IPlayListSort>();
|
||||
const currentType = ref((route.query.type as string) || '每日推荐');
|
||||
|
||||
const getAnimationDelay = computed(() => {
|
||||
return (index: number) => setAnimationDelay(index, 30);
|
||||
});
|
||||
|
||||
// 加载歌单分类
|
||||
const loadPlaylistCategory = async () => {
|
||||
const { data } = await getPlaylistCategory();
|
||||
playlistCategory.value = {
|
||||
...data,
|
||||
sub: [
|
||||
{
|
||||
name: '每日推荐',
|
||||
category: 0
|
||||
},
|
||||
...data.sub
|
||||
]
|
||||
};
|
||||
};
|
||||
|
||||
const handleClickPlaylistType = (type: string) => {
|
||||
currentType.value = type;
|
||||
listTitle.value = type;
|
||||
loading.value = true;
|
||||
loadList(type);
|
||||
};
|
||||
|
||||
const scrollbarRef = ref();
|
||||
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
const scrollbar = scrollbarRef.value;
|
||||
if (scrollbar) {
|
||||
const delta = e.deltaY || e.detail;
|
||||
scrollbar.scrollBy({ left: delta });
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadPlaylistCategory(); // 添加加载歌单分类
|
||||
currentType.value = (route.query.type as string) || currentType.value;
|
||||
loadList(currentType.value);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => route.query,
|
||||
async (newParams) => {
|
||||
if (newParams.type) {
|
||||
recommendList.value = [];
|
||||
listTitle.value = newParams.type || '歌单列表';
|
||||
currentType.value = newParams.type as string;
|
||||
loading.value = true;
|
||||
loadList(newParams.type as string);
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.list-page {
|
||||
@apply relative h-full w-full;
|
||||
@apply bg-light dark:bg-black;
|
||||
}
|
||||
|
||||
.recommend {
|
||||
@apply w-full h-full;
|
||||
|
||||
&-title {
|
||||
@apply text-lg font-bold pb-2;
|
||||
@apply text-gray-900 dark:text-white;
|
||||
}
|
||||
|
||||
&-list {
|
||||
@apply grid gap-x-8 gap-y-6 pb-28 pr-4;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
}
|
||||
|
||||
&-item {
|
||||
@apply flex flex-col;
|
||||
|
||||
&-img {
|
||||
@apply rounded-xl overflow-hidden relative w-full aspect-square;
|
||||
|
||||
&-img {
|
||||
@apply block w-full h-full;
|
||||
}
|
||||
|
||||
img {
|
||||
@apply absolute top-0 left-0 w-full h-full object-cover rounded-xl;
|
||||
}
|
||||
|
||||
&:hover img {
|
||||
@apply hover:scale-110 transition-all duration-300 ease-in-out;
|
||||
}
|
||||
|
||||
.top {
|
||||
@apply absolute w-full h-full top-0 left-0 flex justify-center items-center transition-all duration-300 ease-in-out cursor-pointer;
|
||||
@apply bg-black bg-opacity-50;
|
||||
opacity: 0;
|
||||
|
||||
i {
|
||||
@apply text-5xl text-white transition-all duration-500 ease-in-out opacity-0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
@apply opacity-100;
|
||||
}
|
||||
|
||||
&:hover i {
|
||||
@apply transform scale-150 opacity-100;
|
||||
}
|
||||
|
||||
.play-count {
|
||||
@apply absolute top-2 left-2 text-sm text-white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-title {
|
||||
@apply mt-2 text-sm line-clamp-1;
|
||||
@apply text-gray-900 dark:text-white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.loading-more {
|
||||
@apply flex justify-center items-center py-4;
|
||||
@apply text-gray-500 dark:text-gray-400;
|
||||
}
|
||||
|
||||
.no-more {
|
||||
@apply text-center py-4;
|
||||
@apply text-gray-500 dark:text-gray-400;
|
||||
}
|
||||
|
||||
.mobile {
|
||||
.recommend-title {
|
||||
@apply text-xl font-bold px-4;
|
||||
}
|
||||
|
||||
.recommend-list {
|
||||
@apply px-4 gap-4;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
// 添加歌单分类样式
|
||||
.play-list-type {
|
||||
.categories-wrapper {
|
||||
@apply flex items-center py-2;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&-item {
|
||||
@apply py-2 px-3 mr-3 inline-block rounded-xl cursor-pointer transition-all duration-300;
|
||||
@apply bg-light dark:bg-black text-gray-900 dark:text-white;
|
||||
@apply border border-gray-200 dark:border-gray-700;
|
||||
|
||||
&:hover {
|
||||
@apply bg-green-50 dark:bg-green-900;
|
||||
}
|
||||
|
||||
&.active {
|
||||
@apply bg-green-500 border-green-500 text-white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mobile {
|
||||
.play-list-type {
|
||||
@apply mx-0 w-full;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,208 @@
|
||||
<script lang="ts" setup>
|
||||
import { useMessage } from 'naive-ui';
|
||||
import { onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useStore } from 'vuex';
|
||||
|
||||
import { checkQr, createQr, getQrKey, getUserDetail, loginByCellphone } from '@/api/login';
|
||||
import { setAnimationClass } from '@/utils';
|
||||
|
||||
defineOptions({
|
||||
name: 'Login'
|
||||
});
|
||||
|
||||
const message = useMessage();
|
||||
const store = useStore();
|
||||
const router = useRouter();
|
||||
const isQr = ref(false);
|
||||
|
||||
const qrUrl = ref<string>();
|
||||
onMounted(() => {
|
||||
loadLogin();
|
||||
});
|
||||
|
||||
const timerRef = ref(null);
|
||||
|
||||
const loadLogin = async () => {
|
||||
try {
|
||||
if (timerRef.value) {
|
||||
clearInterval(timerRef.value);
|
||||
timerRef.value = null;
|
||||
}
|
||||
if (!isQr.value) return;
|
||||
const qrKey = await getQrKey();
|
||||
const key = qrKey.data.data.unikey;
|
||||
const { data } = await createQr(key);
|
||||
qrUrl.value = data.data.qrimg;
|
||||
|
||||
const timer = timerIsQr(key);
|
||||
// 添加对定时器的引用,以便在出现错误时可以清除
|
||||
timerRef.value = timer as any;
|
||||
} catch (error) {
|
||||
console.error('加载登录信息时出错:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 使用 ref 来保存定时器,便于在任何地方清除它
|
||||
|
||||
const timerIsQr = (key: string) => {
|
||||
const timer = setInterval(async () => {
|
||||
try {
|
||||
const { data } = await checkQr(key);
|
||||
|
||||
if (data.code === 800) {
|
||||
clearInterval(timer);
|
||||
timerRef.value = null;
|
||||
}
|
||||
if (data.code === 803) {
|
||||
localStorage.setItem('token', data.cookie);
|
||||
const user = await getUserDetail();
|
||||
store.state.user = user.data.profile;
|
||||
localStorage.setItem('user', JSON.stringify(store.state.user));
|
||||
message.success('登录成功');
|
||||
|
||||
clearInterval(timer);
|
||||
timerRef.value = null;
|
||||
router.push('/user');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('检查二维码状态时出错:', error);
|
||||
// 在出现错误时清除定时器
|
||||
clearInterval(timer);
|
||||
timerRef.value = null;
|
||||
}
|
||||
}, 3000);
|
||||
|
||||
return timer;
|
||||
};
|
||||
|
||||
// 离开页面时
|
||||
onBeforeUnmount(() => {
|
||||
if (timerRef.value) {
|
||||
clearInterval(timerRef.value);
|
||||
timerRef.value = null;
|
||||
}
|
||||
});
|
||||
|
||||
// 是否扫码登陆
|
||||
const chooseQr = () => {
|
||||
isQr.value = !isQr.value;
|
||||
loadLogin();
|
||||
};
|
||||
|
||||
// 手机号登录
|
||||
const phone = ref('');
|
||||
const password = ref('');
|
||||
const loginPhone = async () => {
|
||||
const { data } = await loginByCellphone(phone.value, password.value);
|
||||
if (data.code === 200) {
|
||||
message.success('登录成功');
|
||||
store.state.user = data.profile;
|
||||
localStorage.setItem('token', data.cookie);
|
||||
setTimeout(() => {
|
||||
router.push('/user');
|
||||
}, 1000);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<div class="phone-login">
|
||||
<div class="bg"></div>
|
||||
<div class="content">
|
||||
<div v-if="isQr" class="phone" :class="setAnimationClass('animate__fadeInUp')">
|
||||
<div class="login-title">扫码登陆</div>
|
||||
<img class="qr-img" :src="qrUrl" />
|
||||
<div class="text">使用网易云APP扫码登录</div>
|
||||
</div>
|
||||
<div v-else class="phone" :class="setAnimationClass('animate__fadeInUp')">
|
||||
<div class="login-title">手机号登录</div>
|
||||
<div class="phone-page">
|
||||
<input v-model="phone" class="phone-input" type="text" placeholder="手机号" />
|
||||
<input v-model="password" class="phone-input" type="password" placeholder="密码" />
|
||||
</div>
|
||||
<div class="text">使用网易云账号登录</div>
|
||||
<n-button class="btn-login" @click="loginPhone()">登录</n-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bottom">
|
||||
<div class="title" @click="chooseQr()">{{ isQr ? '手机号登录' : '扫码登录' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.login-page {
|
||||
@apply flex flex-col items-center justify-center p-20 pt-20;
|
||||
@apply bg-light dark:bg-black;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
@apply text-2xl font-bold mb-6 text-white;
|
||||
}
|
||||
|
||||
.text {
|
||||
@apply mt-4 text-white text-xs;
|
||||
}
|
||||
|
||||
.phone-login {
|
||||
width: 350px;
|
||||
height: 550px;
|
||||
@apply rounded-2xl rounded-b-none bg-cover bg-no-repeat relative overflow-hidden;
|
||||
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' version='1.1' xmlns:xlink='http://www.w3.org/1999/xlink' xmlns:svgjs='http://svgjs.dev/svgjs' width='400' height='560' preserveAspectRatio='none' viewBox='0 0 400 560'%3e%3cg mask='url(%26quot%3b%23SvgjsMask1066%26quot%3b)' fill='none'%3e%3crect width='400' height='560' x='0' y='0' fill='rgba(24%2c 106%2c 59%2c 1)'%3e%3c/rect%3e%3cpath d='M0%2c234.738C43.535%2c236.921%2c80.103%2c205.252%2c116.272%2c180.923C151.738%2c157.067%2c188.295%2c132.929%2c207.855%2c94.924C227.898%2c55.979%2c233.386%2c10.682%2c226.119%2c-32.511C218.952%2c-75.107%2c199.189%2c-115.793%2c167.469%2c-145.113C137.399%2c-172.909%2c92.499%2c-171.842%2c55.779%2c-189.967C8.719%2c-213.196%2c-28.344%2c-282.721%2c-78.217%2c-266.382C-128.725%2c-249.834%2c-111.35%2c-166.696%2c-143.781%2c-124.587C-173.232%2c-86.348%2c-244.72%2c-83.812%2c-255.129%2c-36.682C-265.368%2c9.678%2c-217.952%2c48.26%2c-190.512%2c87.004C-167.691%2c119.226%2c-140.216%2c145.431%2c-109.013%2c169.627C-74.874%2c196.1%2c-43.147%2c232.575%2c0%2c234.738' fill='%23114b2a'%3e%3c/path%3e%3cpath d='M400 800.9010000000001C443.973 795.023 480.102 765.6 513.011 735.848 541.923 709.71 561.585 676.6320000000001 577.037 640.85 592.211 605.712 606.958 568.912 601.458 531.035 595.962 493.182 568.394 464.36400000000003 546.825 432.775 522.317 396.88300000000004 507.656 347.475 466.528 333.426 425.366 319.366 384.338 352.414 342.111 362.847 297.497 373.869 242.385 362.645 211.294 396.486 180.212 430.318 192.333 483.83299999999997 188.872 529.644 185.656 572.218 178.696 614.453 191.757 655.101 205.885 699.068 227.92 742.4110000000001 265.75 768.898 304.214 795.829 353.459 807.1220000000001 400 800.9010000000001' fill='%231f894c'%3e%3c/path%3e%3c/g%3e%3cdefs%3e%3cmask id='SvgjsMask1066'%3e%3crect width='400' height='560' fill='white'%3e%3c/rect%3e%3c/mask%3e%3c/defs%3e%3c/svg%3e");
|
||||
box-shadow: inset 0px 0px 20px 5px rgba(0, 0, 0, 0.37);
|
||||
|
||||
.bg {
|
||||
@apply absolute w-full h-full bg-light-100 dark:bg-dark-100 opacity-20;
|
||||
}
|
||||
|
||||
.bottom {
|
||||
width: 200%;
|
||||
height: 250px;
|
||||
bottom: -180px;
|
||||
border-radius: 50%;
|
||||
left: 50%;
|
||||
padding: 10px;
|
||||
transform: translateX(-50%);
|
||||
@apply absolute bg-light dark:bg-dark flex justify-center text-lg font-bold cursor-pointer;
|
||||
@apply text-gray-400 hover:text-gray-800 hover:dark:text-white transition-colors;
|
||||
box-shadow: 10px 0px 20px rgba(0, 0, 0, 0.66);
|
||||
}
|
||||
|
||||
.content {
|
||||
@apply absolute w-full h-full p-4 flex flex-col items-center justify-center pb-20 text-center;
|
||||
.qr-img {
|
||||
@apply rounded-2xl cursor-pointer transition-opacity;
|
||||
}
|
||||
|
||||
.phone {
|
||||
animation-duration: 0.5s;
|
||||
&-page {
|
||||
@apply bg-light dark:bg-gray-800 bg-opacity-90 dark:bg-opacity-90;
|
||||
width: 250px;
|
||||
@apply rounded-2xl overflow-hidden;
|
||||
}
|
||||
|
||||
&-input {
|
||||
height: 40px;
|
||||
@apply w-full px-4 outline-none;
|
||||
@apply text-gray-900 dark:text-white bg-transparent;
|
||||
@apply border-b border-gray-200 dark:border-gray-700;
|
||||
@apply placeholder-gray-500 dark:placeholder-gray-400;
|
||||
|
||||
&:focus {
|
||||
@apply border-green-500;
|
||||
}
|
||||
}
|
||||
}
|
||||
.btn-login {
|
||||
width: 250px;
|
||||
height: 40px;
|
||||
@apply mt-10 text-white rounded-xl;
|
||||
@apply bg-green-600 hover:bg-green-700 transition-colors;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,803 @@
|
||||
<template>
|
||||
<div
|
||||
class="lyric-window"
|
||||
:class="[lyricSetting.theme, { lyric_lock: lyricSetting.isLock }]"
|
||||
@mousedown="handleMouseDown"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
>
|
||||
<div class="drag-overlay"></div>
|
||||
<!-- 顶部控制栏 -->
|
||||
<div class="control-bar" :class="{ 'control-bar-show': showControls }">
|
||||
<div class="font-size-controls">
|
||||
<n-button-group>
|
||||
<n-button quaternary size="small" :disabled="fontSize <= 12" @click="decreaseFontSize">
|
||||
<i class="ri-subtract-line"></i>
|
||||
</n-button>
|
||||
<n-button quaternary size="small" :disabled="fontSize >= 48" @click="increaseFontSize">
|
||||
<i class="ri-add-line"></i>
|
||||
</n-button>
|
||||
</n-button-group>
|
||||
<div>{{ staticData.playMusic.name }}</div>
|
||||
</div>
|
||||
<!-- 添加播放控制按钮 -->
|
||||
<div class="play-controls">
|
||||
<div class="control-button" @click="handlePrev">
|
||||
<i class="ri-skip-back-fill"></i>
|
||||
</div>
|
||||
<div class="control-button play-button" @click="handlePlayPause">
|
||||
<i :class="dynamicData.isPlay ? 'ri-pause-fill' : 'ri-play-fill'"></i>
|
||||
</div>
|
||||
<div class="control-button" @click="handleNext">
|
||||
<i class="ri-skip-forward-fill"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="control-buttons">
|
||||
<div class="control-button" @click="checkTheme">
|
||||
<i v-if="lyricSetting.theme === 'light'" class="ri-sun-line"></i>
|
||||
<i v-else class="ri-moon-line"></i>
|
||||
</div>
|
||||
<!-- <div class="control-button" @click="handleTop">
|
||||
<i class="ri-pushpin-line" :class="{ active: lyricSetting.isTop }"></i>
|
||||
</div> -->
|
||||
<div id="lyric-lock" class="control-button" @click="handleLock">
|
||||
<i v-if="lyricSetting.isLock" class="ri-lock-line"></i>
|
||||
<i v-else class="ri-lock-unlock-line"></i>
|
||||
</div>
|
||||
<div class="control-button" @click="handleClose">
|
||||
<i class="ri-close-line"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 歌词显示区域 -->
|
||||
<div ref="containerRef" class="lyric-container">
|
||||
<div class="lyric-scroll">
|
||||
<div class="lyric-wrapper" :style="wrapperStyle">
|
||||
<template v-if="staticData.lrcArray?.length > 0">
|
||||
<div
|
||||
v-for="(line, index) in staticData.lrcArray"
|
||||
:key="index"
|
||||
class="lyric-line"
|
||||
:style="lyricLineStyle"
|
||||
:class="{
|
||||
'lyric-line-current': index === currentIndex,
|
||||
'lyric-line-passed': index < currentIndex,
|
||||
'lyric-line-next': index === currentIndex + 1
|
||||
}"
|
||||
>
|
||||
<div class="lyric-text" :style="{ fontSize: `${fontSize}px` }">
|
||||
<span class="lyric-text-inner" :style="getLyricStyle(index)">
|
||||
{{ line.text || '' }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="line.trText"
|
||||
class="lyric-translation"
|
||||
:style="{ fontSize: `${fontSize * 0.6}px` }"
|
||||
>
|
||||
{{ line.trText }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="lyric-empty">无歌词</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
|
||||
import { SongResult } from '@/type/music';
|
||||
|
||||
defineOptions({
|
||||
name: 'Lyric'
|
||||
});
|
||||
const windowData = window as any;
|
||||
const containerRef = ref<HTMLElement | null>(null);
|
||||
const containerHeight = ref(0);
|
||||
const lineHeight = ref(60);
|
||||
const currentIndex = ref(0);
|
||||
// 字体大小控制
|
||||
const fontSize = ref(24); // 默认字体大小
|
||||
const fontSizeStep = 2; // 每次整的步长
|
||||
const animationFrameId = ref<number | null>(null);
|
||||
const lastUpdateTime = ref(performance.now());
|
||||
|
||||
// 静态数据
|
||||
const staticData = ref<{
|
||||
lrcArray: Array<{ text: string; trText: string }>;
|
||||
lrcTimeArray: number[];
|
||||
allTime: number;
|
||||
playMusic: SongResult;
|
||||
}>({
|
||||
lrcArray: [],
|
||||
lrcTimeArray: [],
|
||||
allTime: 0,
|
||||
playMusic: {} as SongResult
|
||||
});
|
||||
|
||||
// 动态数据
|
||||
const dynamicData = ref({
|
||||
nowTime: 0,
|
||||
startCurrentTime: 0,
|
||||
nextTime: 0,
|
||||
isPlay: true
|
||||
});
|
||||
|
||||
const lyricSetting = ref({
|
||||
...(localStorage.getItem('lyricData')
|
||||
? JSON.parse(localStorage.getItem('lyricData') || '')
|
||||
: {
|
||||
isTop: false,
|
||||
theme: 'dark',
|
||||
isLock: false
|
||||
})
|
||||
});
|
||||
|
||||
let hideControlsTimer: number | null = null;
|
||||
|
||||
const isHovering = ref(false);
|
||||
|
||||
// 计算是否栏
|
||||
const showControls = computed(() => {
|
||||
if (lyricSetting.value.isLock) {
|
||||
return isHovering.value;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// 清除隐藏定时器
|
||||
const clearHideTimer = () => {
|
||||
if (hideControlsTimer) {
|
||||
clearTimeout(hideControlsTimer);
|
||||
hideControlsTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
// 处理鼠标进入窗口
|
||||
const handleMouseEnter = () => {
|
||||
if (lyricSetting.value.isLock) {
|
||||
isHovering.value = true;
|
||||
windowData.electron.ipcRenderer.send('set-ignore-mouse', true);
|
||||
} else {
|
||||
windowData.electron.ipcRenderer.send('set-ignore-mouse', false);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理鼠标离开窗口
|
||||
const handleMouseLeave = () => {
|
||||
if (!lyricSetting.value.isLock) return;
|
||||
isHovering.value = false;
|
||||
windowData.electron.ipcRenderer.send('set-ignore-mouse', false);
|
||||
};
|
||||
|
||||
// 监听锁定状态变化
|
||||
watch(
|
||||
() => lyricSetting.value.isLock,
|
||||
(newLock: boolean) => {
|
||||
if (newLock) {
|
||||
isHovering.value = false;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
// 初始化时,如果是锁定状态,确保控制栏隐藏
|
||||
if (lyricSetting.value.isLock) {
|
||||
isHovering.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
clearHideTimer();
|
||||
});
|
||||
|
||||
// 计算歌词滚动位置
|
||||
const wrapperStyle = computed(() => {
|
||||
if (!containerHeight.value) {
|
||||
return {
|
||||
transform: 'translateY(0)',
|
||||
transition: 'none'
|
||||
};
|
||||
}
|
||||
|
||||
// 计算容器中心点
|
||||
const containerCenter = containerHeight.value / 2;
|
||||
|
||||
// 计算当前行到顶部的距离(包含padding)
|
||||
const currentLineTop =
|
||||
currentIndex.value * lineHeight.value + containerHeight.value * 0.2 + lineHeight.value; // 加上顶部padding
|
||||
|
||||
// 计算偏移量,使当前行居中
|
||||
const targetOffset = containerCenter - currentLineTop;
|
||||
|
||||
// 计算内容总高度(包含padding)
|
||||
const contentHeight =
|
||||
staticData.value.lrcArray.length * lineHeight.value + containerHeight.value * 0.4; // 上下padding各20vh
|
||||
|
||||
// 计算最小和最大偏移量
|
||||
const minOffset = -(contentHeight - containerHeight.value);
|
||||
const maxOffset = 0;
|
||||
|
||||
// 限制偏移量在合理范围内
|
||||
const finalOffset = Math.min(maxOffset, Math.max(minOffset, targetOffset));
|
||||
|
||||
return {
|
||||
transform: `translateY(${finalOffset}px)`,
|
||||
transition: 'transform 0.3s cubic-bezier(0.4, 0, 0.2, 1)'
|
||||
};
|
||||
});
|
||||
|
||||
const lyricLineStyle = computed(() => ({
|
||||
height: `${lineHeight.value}px`
|
||||
}));
|
||||
// 更新容器高度和行高
|
||||
const updateContainerHeight = () => {
|
||||
if (!containerRef.value) return;
|
||||
|
||||
// 更新容器高度
|
||||
containerHeight.value = containerRef.value.clientHeight;
|
||||
|
||||
// 计算基础行高(字体大小的2.5倍)
|
||||
const baseLineHeight = fontSize.value * 2.5;
|
||||
|
||||
// 计算最大允许行高(容器高度的1/4)
|
||||
const maxAllowedHeight = containerHeight.value / 3;
|
||||
|
||||
// 设置行高(不小于40px,不大于最大允许高度)
|
||||
lineHeight.value = Math.min(maxAllowedHeight, Math.max(40, baseLineHeight));
|
||||
};
|
||||
|
||||
// 处理字体大小变化
|
||||
const handleFontSizeChange = async () => {
|
||||
// 先保存字体大小
|
||||
saveFontSize();
|
||||
|
||||
// 更新容器高度和行高
|
||||
updateContainerHeight();
|
||||
};
|
||||
|
||||
// 增加字体大小
|
||||
const increaseFontSize = async () => {
|
||||
if (fontSize.value < 48) {
|
||||
fontSize.value += fontSizeStep;
|
||||
await handleFontSizeChange();
|
||||
}
|
||||
};
|
||||
|
||||
// 减小字体大小
|
||||
const decreaseFontSize = async () => {
|
||||
if (fontSize.value > 12) {
|
||||
fontSize.value -= fontSizeStep;
|
||||
await handleFontSizeChange();
|
||||
}
|
||||
};
|
||||
|
||||
// 保存字体大小到本地存储
|
||||
const saveFontSize = () => {
|
||||
localStorage.setItem('lyricFontSize', fontSize.value.toString());
|
||||
};
|
||||
|
||||
// 监听容器大小变化
|
||||
onMounted(() => {
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
updateContainerHeight();
|
||||
});
|
||||
|
||||
if (containerRef.value) {
|
||||
resizeObserver.observe(containerRef.value);
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
resizeObserver.disconnect();
|
||||
});
|
||||
});
|
||||
// 实际播放时间
|
||||
const actualTime = ref(0);
|
||||
|
||||
// 计算当前行的进度
|
||||
const currentProgress = computed(() => {
|
||||
const { startCurrentTime, nextTime } = dynamicData.value;
|
||||
if (!startCurrentTime || !nextTime) return 0;
|
||||
|
||||
const duration = nextTime - startCurrentTime;
|
||||
const elapsed = actualTime.value - startCurrentTime;
|
||||
return Math.min(Math.max(elapsed / duration, 0), 1);
|
||||
});
|
||||
|
||||
// 获取歌词样式
|
||||
const getLyricStyle = (index: number) => {
|
||||
if (index !== currentIndex.value) return {};
|
||||
|
||||
const progress = currentProgress.value * 100;
|
||||
return {
|
||||
background: `linear-gradient(to right, var(--highlight-color) ${progress}%, var(--text-color) ${progress}%)`,
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
transition: 'all 0.1s linear'
|
||||
};
|
||||
};
|
||||
|
||||
// 时间偏移量(毫秒)
|
||||
const TIME_OFFSET = 400;
|
||||
|
||||
// 更新动画
|
||||
const updateProgress = () => {
|
||||
if (!dynamicData.value.isPlay) {
|
||||
if (animationFrameId.value) {
|
||||
cancelAnimationFrame(animationFrameId.value);
|
||||
animationFrameId.value = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 计算实际时间,添加偏移量
|
||||
const timeDiff = (performance.now() - lastUpdateTime.value) / 1000;
|
||||
actualTime.value = dynamicData.value.nowTime + timeDiff + TIME_OFFSET / 1000;
|
||||
|
||||
// 继续动画
|
||||
animationFrameId.value = requestAnimationFrame(updateProgress);
|
||||
};
|
||||
|
||||
// 记录上次更新时间
|
||||
|
||||
// 监听据更新
|
||||
watch(
|
||||
() => dynamicData.value,
|
||||
(newData: any) => {
|
||||
// 更新最后更新时间
|
||||
lastUpdateTime.value = performance.now();
|
||||
|
||||
// 更新实际时间,包含偏移量
|
||||
actualTime.value = newData.nowTime + TIME_OFFSET / 1000;
|
||||
|
||||
// 如果正在播放且没有动画,启动动画
|
||||
if (newData.isPlay && !animationFrameId.value) {
|
||||
updateProgress();
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
// 监听播放状态变化
|
||||
watch(
|
||||
() => dynamicData.value.isPlay,
|
||||
(isPlaying: boolean) => {
|
||||
if (isPlaying) {
|
||||
lastUpdateTime.value = performance.now();
|
||||
updateProgress();
|
||||
} else if (animationFrameId.value) {
|
||||
cancelAnimationFrame(animationFrameId.value);
|
||||
animationFrameId.value = null;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 修改数据更新处
|
||||
const handleDataUpdate = (parsedData: {
|
||||
nowTime: number;
|
||||
startCurrentTime: number;
|
||||
nextTime: number;
|
||||
isPlay: boolean;
|
||||
nowIndex: number;
|
||||
lrcArray: Array<{ text: string; trText: string }>;
|
||||
lrcTimeArray: number[];
|
||||
allTime: number;
|
||||
playMusic: SongResult;
|
||||
}) => {
|
||||
// 确保数据存在且格式正确
|
||||
if (!parsedData) {
|
||||
console.error('Invalid update data received:', parsedData);
|
||||
return;
|
||||
}
|
||||
// 更新静态数据
|
||||
staticData.value = {
|
||||
lrcArray: parsedData.lrcArray || [],
|
||||
lrcTimeArray: parsedData.lrcTimeArray || [],
|
||||
allTime: parsedData.allTime || 0,
|
||||
playMusic: parsedData.playMusic || {}
|
||||
};
|
||||
|
||||
// 更新动态数据
|
||||
dynamicData.value = {
|
||||
nowTime: parsedData.nowTime || 0,
|
||||
startCurrentTime: parsedData.startCurrentTime || 0,
|
||||
nextTime: parsedData.nextTime || 0,
|
||||
isPlay: parsedData.isPlay
|
||||
};
|
||||
|
||||
// 更新索引
|
||||
if (typeof parsedData.nowIndex === 'number') {
|
||||
currentIndex.value = parsedData.nowIndex;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
// 加载保存的字体大小
|
||||
const savedFontSize = localStorage.getItem('lyricFontSize');
|
||||
if (savedFontSize) {
|
||||
fontSize.value = Number(savedFontSize);
|
||||
lineHeight.value = fontSize.value * 2.5;
|
||||
}
|
||||
|
||||
// 初始化容器高度
|
||||
updateContainerHeight();
|
||||
window.addEventListener('resize', updateContainerHeight);
|
||||
|
||||
// 监听歌词数据
|
||||
windowData.electron.ipcRenderer.on('receive-lyric', (_, data) => {
|
||||
try {
|
||||
const parsedData = JSON.parse(data);
|
||||
handleDataUpdate(parsedData);
|
||||
} catch (error) {
|
||||
console.error('Error parsing lyric data:', error);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', updateContainerHeight);
|
||||
});
|
||||
|
||||
const checkTheme = () => {
|
||||
if (lyricSetting.value.theme === 'light') {
|
||||
lyricSetting.value.theme = 'dark';
|
||||
} else {
|
||||
lyricSetting.value.theme = 'light';
|
||||
}
|
||||
};
|
||||
|
||||
// const handleTop = () => {
|
||||
// lyricSetting.value.isTop = !lyricSetting.value.isTop;
|
||||
// windowData.electron.ipcRenderer.send('top-lyric', lyricSetting.value.isTop);
|
||||
// };
|
||||
|
||||
const handleLock = () => {
|
||||
lyricSetting.value.isLock = !lyricSetting.value.isLock;
|
||||
windowData.electron.ipcRenderer.send('set-ignore-mouse', lyricSetting.value.isLock);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
windowData.electron.ipcRenderer.send('close-lyric');
|
||||
};
|
||||
|
||||
watch(
|
||||
() => lyricSetting.value,
|
||||
(newValue: any) => {
|
||||
localStorage.setItem('lyricData', JSON.stringify(newValue));
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
// 添��拖动相关变量
|
||||
const isDragging = ref(false);
|
||||
const startPosition = ref({ x: 0, y: 0 });
|
||||
|
||||
// 处理鼠标按下事件
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
// 如果点击的是控制按钮区域或窗口被锁定,不处理拖动
|
||||
if (
|
||||
lyricSetting.value.isLock ||
|
||||
(e.target as HTMLElement).closest('.control-buttons') ||
|
||||
(e.target as HTMLElement).closest('.font-size-controls')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 只响应鼠标左键
|
||||
if (e.button !== 0) return;
|
||||
|
||||
isDragging.value = true;
|
||||
startPosition.value = { x: e.screenX, y: e.screenY };
|
||||
|
||||
// 添加全局鼠标事件监听
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!isDragging.value) return;
|
||||
|
||||
const deltaX = e.screenX - startPosition.value.x;
|
||||
const deltaY = e.screenY - startPosition.value.y;
|
||||
|
||||
// 发送移动事件到主进程
|
||||
windowData.electron.ipcRenderer.send('lyric-drag-move', { deltaX, deltaY });
|
||||
startPosition.value = { x: e.screenX, y: e.screenY };
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (!isDragging.value) return;
|
||||
isDragging.value = false;
|
||||
|
||||
// 移除事件监听
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
// 添加全局事件监听
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
// 组件卸载时清理
|
||||
onUnmounted(() => {
|
||||
isDragging.value = false;
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
const lyricLock = document.getElementById('lyric-lock');
|
||||
if (lyricLock) {
|
||||
lyricLock.onmouseenter = () => {
|
||||
if (lyricSetting.value.isLock) {
|
||||
windowData.electron.ipcRenderer.send('set-ignore-mouse', false);
|
||||
}
|
||||
};
|
||||
lyricLock.onmouseleave = () => {
|
||||
if (lyricSetting.value.isLock) {
|
||||
windowData.electron.ipcRenderer.send('set-ignore-mouse', true);
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// 添加播放控制相关的函数
|
||||
const handlePlayPause = () => {
|
||||
windowData.electron.ipcRenderer.send('control-back', 'playpause');
|
||||
};
|
||||
|
||||
const handlePrev = () => {
|
||||
windowData.electron.ipcRenderer.send('control-back', 'prev');
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
windowData.electron.ipcRenderer.send('control-back', 'next');
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
body {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.lyric-window {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
user-select: none;
|
||||
transition: background-color 0.2s ease;
|
||||
cursor: default;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
.control-bar {
|
||||
&-show {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
&.dark {
|
||||
--text-color: #ffffff;
|
||||
--text-secondary: rgba(255, 255, 255, 0.6);
|
||||
--highlight-color: #1db954;
|
||||
--control-bg: rgba(124, 124, 124, 0.3);
|
||||
}
|
||||
|
||||
&.light {
|
||||
--text-color: #333333;
|
||||
--text-secondary: rgba(51, 51, 51, 0.6);
|
||||
--highlight-color: #1db954;
|
||||
--control-bg: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.control-bar {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 80px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: start;
|
||||
padding: 0 20px;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
visibility 0.2s ease;
|
||||
z-index: 100;
|
||||
|
||||
.font-size-controls {
|
||||
-webkit-app-region: no-drag;
|
||||
color: var(--text-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.play-controls {
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
-webkit-app-region: no-drag;
|
||||
|
||||
.play-button {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
i {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.control-buttons {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
}
|
||||
|
||||
.control-buttons {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.control-button {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
color: var(--text-color);
|
||||
transition: all 0.2s ease;
|
||||
&:hover {
|
||||
background: var(--control-bg);
|
||||
}
|
||||
|
||||
i {
|
||||
font-size: 20px;
|
||||
text-shadow: 0 0 10px rgba(0, 0, 0, 0.3);
|
||||
|
||||
&.active {
|
||||
color: var(--highlight-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lyric-container {
|
||||
position: absolute;
|
||||
top: 80px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
overflow: hidden;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.lyric-scroll {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
mask-image: linear-gradient(to bottom, transparent 0%, black 20%, black 80%, transparent 100%);
|
||||
}
|
||||
|
||||
.lyric-wrapper {
|
||||
will-change: transform;
|
||||
padding: 20vh 0;
|
||||
transform-origin: center center;
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.lyric-line {
|
||||
padding: 4px 20px;
|
||||
text-align: center;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
&.lyric-line-current {
|
||||
transform: scale(1.05);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&.lyric-line-passed {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.lyric-text {
|
||||
font-weight: 600;
|
||||
margin-bottom: 2px;
|
||||
color: var(--text-color);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
text-shadow: 0 0 10px rgba(0, 0, 0, 0.3);
|
||||
transition: all 0.2s ease;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.lyric-translation {
|
||||
color: var(--text-secondary);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
text-shadow: 0 0 10px rgba(0, 0, 0, 0.3);
|
||||
transition: font-size 0.2s ease;
|
||||
line-height: 1.4; // 添加行高比例
|
||||
}
|
||||
|
||||
.lyric-empty {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: 16px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: transparent !important;
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell,
|
||||
'Open Sans', 'Helvetica Neue', sans-serif;
|
||||
}
|
||||
|
||||
.lyric-content {
|
||||
transition: font-size 0.2s ease;
|
||||
}
|
||||
|
||||
.lyric-line-current {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.control-bar {
|
||||
.control-buttons {
|
||||
.control-button {
|
||||
&:not(:has(.ri-lock-line)):not(:has(.ri-lock-unlock-line)) {
|
||||
.lyric_lock & {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lyric_lock & .font-size-controls {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.lyric_lock & .play-controls {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.lyric_lock {
|
||||
background: transparent;
|
||||
&:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
#lyric-lock {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 72px;
|
||||
background: var(--control-bg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,295 @@
|
||||
<template>
|
||||
<div class="mv-list">
|
||||
<div class="play-list-type">
|
||||
<n-scrollbar x-scrollable>
|
||||
<div class="categories-wrapper">
|
||||
<span
|
||||
v-for="(category, index) in categories"
|
||||
:key="category.value"
|
||||
class="play-list-type-item"
|
||||
:class="[
|
||||
setAnimationClass('animate__bounceIn'),
|
||||
{ active: selectedCategory === category.value }
|
||||
]"
|
||||
:style="getAnimationDelay(index)"
|
||||
@click="selectedCategory = category.value"
|
||||
>
|
||||
{{ category.label }}
|
||||
</span>
|
||||
</div>
|
||||
</n-scrollbar>
|
||||
</div>
|
||||
<n-scrollbar :size="100" @scroll="handleScroll">
|
||||
<div
|
||||
v-loading="initLoading"
|
||||
class="mv-list-content"
|
||||
:class="setAnimationClass('animate__bounceInLeft')"
|
||||
>
|
||||
<div
|
||||
v-for="(item, index) in mvList"
|
||||
:key="item.id"
|
||||
class="mv-item"
|
||||
:class="setAnimationClass('animate__bounceIn')"
|
||||
:style="getAnimationDelay(index)"
|
||||
>
|
||||
<div class="mv-item-img" @click="handleShowMv(item, index)">
|
||||
<n-image
|
||||
class="mv-item-img-img"
|
||||
:src="getImgUrl(item.cover, '320y180')"
|
||||
lazy
|
||||
preview-disabled
|
||||
/>
|
||||
<div class="top">
|
||||
<div class="play-count">{{ formatNumber(item.playCount) }}</div>
|
||||
<i class="iconfont icon-videofill"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mv-item-title">{{ item.name }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingMore" class="loading-more">加载中...</div>
|
||||
<div v-if="!hasMore && !initLoading" class="no-more">没有更多了</div>
|
||||
</div>
|
||||
</n-scrollbar>
|
||||
|
||||
<mv-player
|
||||
v-model:show="showMv"
|
||||
:current-mv="playMvItem"
|
||||
:is-prev-disabled="isPrevDisabled"
|
||||
@next="playNextMv"
|
||||
@prev="playPrevMv"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
|
||||
import { getAllMv, getTopMv } from '@/api/mv';
|
||||
import MvPlayer from '@/components/MvPlayer.vue';
|
||||
import { audioService } from '@/services/audioService';
|
||||
import { IMvItem } from '@/type/mv';
|
||||
import { formatNumber, getImgUrl, setAnimationClass, setAnimationDelay } from '@/utils';
|
||||
|
||||
defineOptions({
|
||||
name: 'Mv'
|
||||
});
|
||||
|
||||
const showMv = ref(false);
|
||||
const mvList = ref<Array<IMvItem>>([]);
|
||||
const playMvItem = ref<IMvItem>();
|
||||
const store = useStore();
|
||||
const initLoading = ref(false);
|
||||
const loadingMore = ref(false);
|
||||
const currentIndex = ref(0);
|
||||
const offset = ref(0);
|
||||
const limit = ref(42);
|
||||
const hasMore = ref(true);
|
||||
|
||||
const categories = [
|
||||
{ label: '全部', value: '全部' },
|
||||
{ label: '内地', value: '内地' },
|
||||
{ label: '港台', value: '港台' },
|
||||
{ label: '欧美', value: '欧美' },
|
||||
{ label: '日本', value: '日本' },
|
||||
{ label: '韩国', value: '韩国' }
|
||||
];
|
||||
const selectedCategory = ref('全部');
|
||||
|
||||
watch(selectedCategory, async () => {
|
||||
offset.value = 0;
|
||||
mvList.value = [];
|
||||
hasMore.value = true;
|
||||
await loadMvList();
|
||||
});
|
||||
|
||||
const getAnimationDelay = (index: number) => {
|
||||
const currentPageIndex = index % limit.value;
|
||||
return setAnimationDelay(currentPageIndex, 30);
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await loadMvList();
|
||||
});
|
||||
|
||||
const handleShowMv = async (item: IMvItem, index: number) => {
|
||||
store.commit('setIsPlay', false);
|
||||
store.commit('setPlayMusic', false);
|
||||
audioService.getCurrentSound()?.pause();
|
||||
showMv.value = true;
|
||||
currentIndex.value = index;
|
||||
playMvItem.value = item;
|
||||
};
|
||||
|
||||
const playPrevMv = async (setLoading: (value: boolean) => void) => {
|
||||
try {
|
||||
if (currentIndex.value > 0) {
|
||||
const prevItem = mvList.value[currentIndex.value - 1];
|
||||
await handleShowMv(prevItem, currentIndex.value - 1);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const playNextMv = async (setLoading: (value: boolean) => void) => {
|
||||
try {
|
||||
if (currentIndex.value < mvList.value.length - 1) {
|
||||
const nextItem = mvList.value[currentIndex.value + 1];
|
||||
await handleShowMv(nextItem, currentIndex.value + 1);
|
||||
} else if (hasMore.value) {
|
||||
await loadMvList();
|
||||
if (mvList.value.length > currentIndex.value + 1) {
|
||||
const nextItem = mvList.value[currentIndex.value + 1];
|
||||
await handleShowMv(nextItem, currentIndex.value + 1);
|
||||
} else {
|
||||
showMv.value = false;
|
||||
}
|
||||
} else {
|
||||
showMv.value = false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载更多MV失败:', error);
|
||||
showMv.value = false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadMvList = async () => {
|
||||
try {
|
||||
if (!hasMore.value || loadingMore.value) return;
|
||||
if (offset.value === 0) {
|
||||
initLoading.value = true;
|
||||
} else {
|
||||
loadingMore.value = true;
|
||||
}
|
||||
|
||||
const params = {
|
||||
limit: limit.value,
|
||||
offset: offset.value,
|
||||
area: selectedCategory.value === '全部' ? '' : selectedCategory.value
|
||||
};
|
||||
|
||||
const res = selectedCategory.value === '全部' ? await getTopMv(params) : await getAllMv(params);
|
||||
|
||||
const { data } = res.data;
|
||||
mvList.value.push(...data);
|
||||
hasMore.value = data.length === limit.value;
|
||||
offset.value += limit.value;
|
||||
} finally {
|
||||
initLoading.value = false;
|
||||
loadingMore.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleScroll = (e: Event) => {
|
||||
const target = e.target as Element;
|
||||
const { scrollTop, clientHeight, scrollHeight } = target;
|
||||
const threshold = 100;
|
||||
|
||||
if (scrollHeight - (scrollTop + clientHeight) < threshold) {
|
||||
loadMvList();
|
||||
}
|
||||
};
|
||||
|
||||
const isPrevDisabled = computed(() => currentIndex.value === 0);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.mv-list {
|
||||
@apply h-full flex-1 flex flex-col overflow-hidden;
|
||||
|
||||
&-title {
|
||||
@apply text-xl font-bold pb-2;
|
||||
@apply text-gray-900 dark:text-white;
|
||||
}
|
||||
|
||||
// 添加歌单分类样式
|
||||
.play-list-type {
|
||||
.title {
|
||||
@apply text-lg font-bold mb-2;
|
||||
@apply text-gray-900 dark:text-white;
|
||||
}
|
||||
|
||||
.categories-wrapper {
|
||||
@apply flex items-center py-2;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&-item {
|
||||
@apply py-2 px-3 mr-3 inline-block rounded-xl cursor-pointer transition-all duration-300;
|
||||
@apply bg-light dark:bg-black text-gray-900 dark:text-white;
|
||||
@apply border border-gray-200 dark:border-gray-700;
|
||||
|
||||
&:hover {
|
||||
@apply bg-green-50 dark:bg-green-900;
|
||||
}
|
||||
|
||||
&.active {
|
||||
@apply bg-green-500 border-green-500 text-white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-content {
|
||||
@apply grid gap-4 pb-28 mt-2 pr-4;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
}
|
||||
|
||||
.mv-item {
|
||||
@apply p-2 rounded-lg;
|
||||
@apply bg-light dark:bg-black;
|
||||
@apply border border-gray-200 dark:border-gray-700;
|
||||
|
||||
&-img {
|
||||
@apply rounded-lg overflow-hidden relative;
|
||||
aspect-ratio: 16/9;
|
||||
line-height: 0;
|
||||
|
||||
&:hover img {
|
||||
@apply hover:scale-110 transition-all duration-300 ease-in-out object-top;
|
||||
}
|
||||
|
||||
&-img {
|
||||
@apply w-full h-full object-cover rounded-lg overflow-hidden;
|
||||
}
|
||||
|
||||
.top {
|
||||
@apply absolute w-full h-full top-0 left-0 flex justify-center items-center transition-all duration-300 ease-in-out cursor-pointer;
|
||||
@apply bg-black bg-opacity-60;
|
||||
opacity: 0;
|
||||
|
||||
i {
|
||||
@apply text-4xl text-white;
|
||||
}
|
||||
|
||||
.play-count {
|
||||
@apply absolute top-2 right-2 text-sm;
|
||||
@apply text-white text-opacity-90;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-title {
|
||||
@apply mt-2 text-sm line-clamp-1;
|
||||
@apply text-gray-900 dark:text-white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.loading-more {
|
||||
@apply text-center py-4 col-span-full;
|
||||
@apply text-gray-500 dark:text-gray-400;
|
||||
}
|
||||
|
||||
.no-more {
|
||||
@apply text-center py-4 col-span-full;
|
||||
@apply text-gray-500 dark:text-gray-400;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,224 @@
|
||||
<template>
|
||||
<div class="search-page">
|
||||
<n-layout
|
||||
v-if="isMobile ? !searchDetail : true"
|
||||
class="hot-search"
|
||||
:class="setAnimationClass('animate__fadeInDown')"
|
||||
:native-scrollbar="false"
|
||||
>
|
||||
<div class="title">热搜列表</div>
|
||||
<div class="hot-search-list">
|
||||
<template v-for="(item, index) in hotSearchData?.data" :key="index">
|
||||
<div
|
||||
:class="setAnimationClass('animate__bounceInLeft')"
|
||||
:style="setAnimationDelay(index, 10)"
|
||||
class="hot-search-item"
|
||||
@click.stop="loadSearch(item.searchWord, 1)"
|
||||
>
|
||||
<span class="hot-search-item-count" :class="{ 'hot-search-item-count-3': index < 3 }">{{
|
||||
index + 1
|
||||
}}</span>
|
||||
{{ item.searchWord }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</n-layout>
|
||||
<!-- 搜索到的歌曲列表 -->
|
||||
<n-layout
|
||||
v-if="isMobile ? searchDetail : true"
|
||||
class="search-list"
|
||||
:class="setAnimationClass('animate__fadeInUp')"
|
||||
:native-scrollbar="false"
|
||||
>
|
||||
<div class="title">{{ hotKeyword }}</div>
|
||||
<div v-loading="searchDetailLoading" class="search-list-box">
|
||||
<template v-if="searchDetail">
|
||||
<div
|
||||
v-for="(item, index) in searchDetail?.songs"
|
||||
:key="item.id"
|
||||
:class="setAnimationClass('animate__bounceInRight')"
|
||||
:style="setAnimationDelay(index, 50)"
|
||||
>
|
||||
<song-item :item="item" @play="handlePlay" />
|
||||
</div>
|
||||
<template v-for="(list, key) in searchDetail">
|
||||
<template v-if="key.toString() !== 'songs'">
|
||||
<div
|
||||
v-for="(item, index) in list"
|
||||
:key="item.id"
|
||||
:class="setAnimationClass('animate__bounceInRight')"
|
||||
:style="setAnimationDelay(index, 50)"
|
||||
>
|
||||
<SearchItem :item="item" />
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</n-layout>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useDateFormat } from '@vueuse/core';
|
||||
import { onMounted, ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useStore } from 'vuex';
|
||||
|
||||
import { getHotSearch } from '@/api/home';
|
||||
import { getSearch } from '@/api/search';
|
||||
import SongItem from '@/components/common/SongItem.vue';
|
||||
import type { IHotSearch } from '@/type/search';
|
||||
import { isMobile, setAnimationClass, setAnimationDelay } from '@/utils';
|
||||
import SearchItem from '@/components/common/SearchItem.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'Search'
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const store = useStore();
|
||||
|
||||
const searchDetail = ref<any>();
|
||||
const searchType = computed(() => store.state.searchType as number);
|
||||
const searchDetailLoading = ref(false);
|
||||
|
||||
// 热搜列表
|
||||
const hotSearchData = ref<IHotSearch>();
|
||||
const loadHotSearch = async () => {
|
||||
const { data } = await getHotSearch();
|
||||
hotSearchData.value = data;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadHotSearch();
|
||||
loadSearch(route.query.keyword);
|
||||
});
|
||||
|
||||
const hotKeyword = ref(route.query.keyword || '搜索列表');
|
||||
|
||||
watch(
|
||||
() => store.state.searchValue,
|
||||
(value) => {
|
||||
loadSearch(value);
|
||||
}
|
||||
);
|
||||
|
||||
const dateFormat = (time: any) => useDateFormat(time, 'YYYY.MM.DD').value;
|
||||
const loadSearch = async (keywords: any, type: any = null) => {
|
||||
hotKeyword.value = keywords;
|
||||
searchDetail.value = undefined;
|
||||
if (!keywords) return;
|
||||
|
||||
searchDetailLoading.value = true;
|
||||
const { data } = await getSearch({ keywords, type: type || searchType.value });
|
||||
|
||||
const songs = data.result.songs || [];
|
||||
const albums = data.result.albums || [];
|
||||
const mvs = (data.result.mvs || []).map((item: any) => ({
|
||||
...item,
|
||||
picUrl: item.cover,
|
||||
playCount: item.playCount,
|
||||
desc: item.artists.map((artist: any) => artist.name).join('/'),
|
||||
type: 'mv'
|
||||
}));
|
||||
|
||||
const playlists = (data.result.playlists || []).map((item: any) => ({
|
||||
...item,
|
||||
picUrl: item.coverImgUrl,
|
||||
playCount: item.playCount,
|
||||
desc: item.creator.nickname,
|
||||
type: 'playlist'
|
||||
}));
|
||||
|
||||
// songs map 替换属性
|
||||
songs.forEach((item: any) => {
|
||||
item.picUrl = item.al.picUrl;
|
||||
item.artists = item.ar;
|
||||
});
|
||||
albums.forEach((item: any) => {
|
||||
item.desc = `${item.artist.name} ${item.company} ${dateFormat(item.publishTime)}`;
|
||||
});
|
||||
searchDetail.value = {
|
||||
songs,
|
||||
albums,
|
||||
mvs,
|
||||
playlists
|
||||
};
|
||||
|
||||
searchDetailLoading.value = false;
|
||||
};
|
||||
|
||||
watch(
|
||||
() => route.path,
|
||||
async (path) => {
|
||||
if (path === '/search') {
|
||||
store.state.searchValue = route.query.keyword;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const handlePlay = () => {
|
||||
const tracks = searchDetail.value?.songs || [];
|
||||
store.commit('setPlayList', tracks);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.search-page {
|
||||
@apply flex h-full;
|
||||
}
|
||||
|
||||
.hot-search {
|
||||
@apply mr-4 rounded-xl flex-1 overflow-hidden;
|
||||
@apply bg-light-100 dark:bg-dark-100;
|
||||
animation-duration: 0.2s;
|
||||
min-width: 400px;
|
||||
height: 100%;
|
||||
|
||||
&-list {
|
||||
@apply pb-28;
|
||||
}
|
||||
|
||||
&-item {
|
||||
@apply px-4 py-3 text-lg rounded-xl cursor-pointer;
|
||||
@apply text-gray-900 dark:text-white;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
@apply bg-light-100 dark:bg-dark-200;
|
||||
}
|
||||
|
||||
&-count {
|
||||
@apply inline-block ml-3 w-8;
|
||||
@apply text-green-500;
|
||||
|
||||
&-3 {
|
||||
@apply font-bold inline-block ml-3 w-8;
|
||||
@apply text-red-500;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.search-list {
|
||||
@apply flex-1 rounded-xl;
|
||||
@apply bg-light-100 dark:bg-dark-100;
|
||||
height: 100%;
|
||||
|
||||
&-box {
|
||||
@apply pb-28;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
@apply text-xl font-bold my-2 mx-4;
|
||||
@apply text-gray-900 dark:text-white;
|
||||
}
|
||||
|
||||
.mobile {
|
||||
.hot-search {
|
||||
@apply mr-0 w-full;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,192 @@
|
||||
<template>
|
||||
<n-scrollbar>
|
||||
<div class="set-page">
|
||||
<div class="set-item">
|
||||
<div>
|
||||
<div class="set-item-title">主题模式</div>
|
||||
<div class="set-item-content">切换日间/夜间主题</div>
|
||||
</div>
|
||||
<n-switch v-model:value="isDarkTheme">
|
||||
<template #checked>
|
||||
<i class="ri-moon-line"></i>
|
||||
</template>
|
||||
<template #unchecked>
|
||||
<i class="ri-sun-line"></i>
|
||||
</template>
|
||||
</n-switch>
|
||||
</div>
|
||||
<!-- <div v-if="isElectron" class="set-item">
|
||||
<div>
|
||||
<div class="set-item-title">代理</div>
|
||||
<div class="set-item-content">无法听音乐时打开</div>
|
||||
</div>
|
||||
<n-switch v-model:value="setData.isProxy" />
|
||||
</div> -->
|
||||
<div class="set-item" v-if="isElectron">
|
||||
<div>
|
||||
<div class="set-item-title">音乐API端口</div>
|
||||
<div class="set-item-content">
|
||||
修改后需要重启应用
|
||||
</div>
|
||||
</div>
|
||||
<n-input-number v-model:value="setData.musicApiPort" />
|
||||
</div>
|
||||
<div class="set-item">
|
||||
<div>
|
||||
<div class="set-item-title">动画速度</div>
|
||||
<div class="set-item-content">调节动画播放速度</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-gray-400">{{ setData.animationSpeed }}x</span>
|
||||
<div class="w-40">
|
||||
<n-slider
|
||||
v-model:value="setData.animationSpeed"
|
||||
:min="0.1"
|
||||
:max="3"
|
||||
:step="0.1"
|
||||
:marks="{
|
||||
0.1: '极慢',
|
||||
1: '正常',
|
||||
3: '极快'
|
||||
}"
|
||||
:disabled="setData.noAnimate"
|
||||
class="w-40"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="set-item">
|
||||
<div>
|
||||
<div class="set-item-title">版本</div>
|
||||
<div class="set-item-content">
|
||||
{{ updateInfo.currentVersion }}
|
||||
<template v-if="updateInfo.hasUpdate">
|
||||
<n-tag type="success" class="ml-2">发现新版本 {{ updateInfo.latestVersion }}</n-tag>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<n-button
|
||||
:type="updateInfo.hasUpdate ? 'primary' : 'default'"
|
||||
size="small"
|
||||
:loading="checking"
|
||||
@click="checkForUpdates(true)"
|
||||
>
|
||||
{{ checking ? '检查中...' : '检查更新' }}
|
||||
</n-button>
|
||||
<n-button
|
||||
v-if="updateInfo.hasUpdate"
|
||||
type="success"
|
||||
size="small"
|
||||
@click="openReleasePage"
|
||||
>
|
||||
前往更新
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="set-item cursor-pointer hover:text-green-500 hover:bg-green-950 transition-all"
|
||||
@click="openAuthor"
|
||||
>
|
||||
<div>
|
||||
<div class="set-item-title">作者</div>
|
||||
<div class="set-item-content">algerkong github</div>
|
||||
</div>
|
||||
<div>{{ setData.author }}</div>
|
||||
</div>
|
||||
<div class="set-item">
|
||||
<div>
|
||||
<div class="set-item-title">重启</div>
|
||||
<div class="set-item-content">重启应用</div>
|
||||
</div>
|
||||
<n-button type="primary" @click="restartApp">重启</n-button>
|
||||
</div>
|
||||
</div>
|
||||
<PlayBottom/>
|
||||
</n-scrollbar>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { isElectron, checkUpdate } from '@/utils';
|
||||
import config from '../../../../package.json';
|
||||
import PlayBottom from '@/components/common/PlayBottom.vue';
|
||||
|
||||
const store = useStore();
|
||||
const checking = ref(false);
|
||||
const updateInfo = ref({
|
||||
hasUpdate: false,
|
||||
latestVersion: '',
|
||||
currentVersion: config.version,
|
||||
releaseInfo: null
|
||||
});
|
||||
|
||||
const setData = computed(() => store.state.setData);
|
||||
|
||||
watch(() => setData.value, (newVal) => {
|
||||
store.commit('setSetData', newVal)
|
||||
}, { deep: true });
|
||||
|
||||
const isDarkTheme = computed({
|
||||
get: () => store.state.theme === 'dark',
|
||||
set: () => store.commit('toggleTheme')
|
||||
});
|
||||
|
||||
const openAuthor = () => {
|
||||
window.open(setData.value.authorUrl);
|
||||
};
|
||||
|
||||
const restartApp = () => {
|
||||
window.electron.ipcRenderer.send('restart');
|
||||
};
|
||||
const message = useMessage();
|
||||
const checkForUpdates = async (isClick = false) => {
|
||||
checking.value = true;
|
||||
try {
|
||||
const result = await checkUpdate();
|
||||
updateInfo.value = result;
|
||||
if (!result.hasUpdate && isClick) {
|
||||
message.success('当前已是最新版本');
|
||||
}
|
||||
} finally {
|
||||
checking.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const openReleasePage = () => {
|
||||
window.open('https://github.com/algerkong/AlgerMusicPlayer/releases/latest');
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
checkForUpdates();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.set-page {
|
||||
@apply p-4 bg-light dark:bg-dark;
|
||||
}
|
||||
|
||||
.set-item {
|
||||
@apply flex items-center justify-between p-4 rounded-lg mb-4 transition-all;
|
||||
@apply bg-light dark:bg-dark text-gray-900 dark:text-white;
|
||||
@apply border border-gray-200 dark:border-gray-700;
|
||||
|
||||
&-title {
|
||||
@apply text-base font-medium mb-1;
|
||||
}
|
||||
|
||||
&-content {
|
||||
@apply text-sm text-gray-500 dark:text-gray-400;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
@apply bg-gray-50 dark:bg-gray-800;
|
||||
}
|
||||
|
||||
&.cursor-pointer:hover {
|
||||
@apply text-green-500 bg-green-50 dark:bg-green-900;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,277 @@
|
||||
<template>
|
||||
<div class="user-page">
|
||||
<div
|
||||
v-if="userDetail"
|
||||
class="left"
|
||||
:class="setAnimationClass('animate__fadeInLeft')"
|
||||
:style="{ backgroundImage: `url(${getImgUrl(user.backgroundUrl)})` }"
|
||||
>
|
||||
<div class="page">
|
||||
<div class="user-name">{{ user.nickname }}</div>
|
||||
<div class="user-info">
|
||||
<n-avatar round :size="50" :src="getImgUrl(user.avatarUrl, '50y50')" />
|
||||
<div class="user-info-list">
|
||||
<div class="user-info-item">
|
||||
<div class="label">{{ userDetail.profile.followeds }}</div>
|
||||
<div>粉丝</div>
|
||||
</div>
|
||||
<div class="user-info-item">
|
||||
<div class="label">{{ userDetail.profile.follows }}</div>
|
||||
<div>关注</div>
|
||||
</div>
|
||||
<div class="user-info-item">
|
||||
<div class="label">{{ userDetail.level }}</div>
|
||||
<div>等级</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="uesr-signature">{{ userDetail.profile.signature }}</div>
|
||||
|
||||
<div class="play-list" :class="setAnimationClass('animate__fadeInLeft')">
|
||||
<div class="title">创建的歌单</div>
|
||||
<n-scrollbar>
|
||||
<div
|
||||
v-for="(item, index) in playList"
|
||||
:key="index"
|
||||
class="play-list-item"
|
||||
@click="showPlaylist(item.id, item.name)"
|
||||
>
|
||||
<n-image
|
||||
:src="getImgUrl(item.coverImgUrl, '50y50')"
|
||||
class="play-list-item-img"
|
||||
lazy
|
||||
preview-disabled
|
||||
/>
|
||||
<div class="play-list-item-info">
|
||||
<div class="play-list-item-name">{{ item.name }}</div>
|
||||
<div class="play-list-item-count">
|
||||
{{ item.trackCount }}首,播放{{ item.playCount }}次
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<play-bottom />
|
||||
</n-scrollbar>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="!isMobile"
|
||||
v-loading="infoLoading"
|
||||
class="right"
|
||||
:class="setAnimationClass('animate__fadeInRight')"
|
||||
>
|
||||
<div class="title">听歌排行</div>
|
||||
<div class="record-list">
|
||||
<n-scrollbar>
|
||||
<div
|
||||
v-for="(item, index) in recordList"
|
||||
:key="item.id"
|
||||
class="record-item"
|
||||
:class="setAnimationClass('animate__bounceInUp')"
|
||||
:style="setAnimationDelay(index, 25)"
|
||||
>
|
||||
<song-item class="song-item" :item="item" @play="handlePlay" />
|
||||
<div class="play-count">{{ item.playCount }}次</div>
|
||||
</div>
|
||||
<play-bottom />
|
||||
</n-scrollbar>
|
||||
</div>
|
||||
</div>
|
||||
<music-list
|
||||
v-model:show="isShowList"
|
||||
:name="list?.name || ''"
|
||||
:song-list="list?.tracks || []"
|
||||
:list-info="list"
|
||||
:loading="listLoading"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useStore } from 'vuex';
|
||||
|
||||
import { getListDetail } from '@/api/list';
|
||||
import { getUserDetail, getUserPlaylist, getUserRecord } from '@/api/user';
|
||||
import PlayBottom from '@/components/common/PlayBottom.vue';
|
||||
import SongItem from '@/components/common/SongItem.vue';
|
||||
import MusicList from '@/components/MusicList.vue';
|
||||
import type { Playlist } from '@/type/listDetail';
|
||||
import type { IUserDetail } from '@/type/user';
|
||||
import { getImgUrl, isMobile, setAnimationClass, setAnimationDelay } from '@/utils';
|
||||
|
||||
defineOptions({
|
||||
name: 'User'
|
||||
});
|
||||
|
||||
const store = useStore();
|
||||
const router = useRouter();
|
||||
const userDetail = ref<IUserDetail>();
|
||||
const playList = ref<any[]>([]);
|
||||
const recordList = ref();
|
||||
const infoLoading = ref(false);
|
||||
|
||||
const user = computed(() => store.state.user);
|
||||
|
||||
const loadPage = async () => {
|
||||
if (!user.value) {
|
||||
router.push('/login');
|
||||
return;
|
||||
}
|
||||
infoLoading.value = true;
|
||||
|
||||
const { data: userData } = await getUserDetail(user.value.userId);
|
||||
userDetail.value = userData;
|
||||
|
||||
const { data: playlistData } = await getUserPlaylist(user.value.userId);
|
||||
playList.value = playlistData.playlist;
|
||||
|
||||
const { data: recordData } = await getUserRecord(user.value.userId);
|
||||
recordList.value = recordData.allData.map((item: any) => ({
|
||||
...item,
|
||||
...item.song,
|
||||
picUrl: item.song.al.picUrl
|
||||
}));
|
||||
infoLoading.value = false;
|
||||
};
|
||||
|
||||
onActivated(() => {
|
||||
if (!user.value) {
|
||||
router.push('/login');
|
||||
} else {
|
||||
loadPage();
|
||||
}
|
||||
});
|
||||
|
||||
const isShowList = ref(false);
|
||||
const list = ref<Playlist>();
|
||||
const listLoading = ref(false);
|
||||
// 展示歌单
|
||||
const showPlaylist = async (id: number, name: string) => {
|
||||
isShowList.value = true;
|
||||
listLoading.value = true;
|
||||
|
||||
list.value = {
|
||||
name
|
||||
} as Playlist;
|
||||
const { data } = await getListDetail(id);
|
||||
list.value = data.playlist;
|
||||
listLoading.value = false;
|
||||
};
|
||||
|
||||
const handlePlay = () => {
|
||||
const tracks = recordList.value || [];
|
||||
store.commit('setPlayList', tracks);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.user-page {
|
||||
@apply flex h-full;
|
||||
.left {
|
||||
max-width: 600px;
|
||||
@apply flex-1 rounded-2xl overflow-hidden relative bg-no-repeat h-full;
|
||||
@apply bg-gray-900 dark:bg-gray-800;
|
||||
|
||||
.page {
|
||||
@apply p-4 w-full z-10 flex flex-col h-full;
|
||||
@apply bg-black bg-opacity-40;
|
||||
}
|
||||
.title {
|
||||
@apply text-lg font-bold;
|
||||
@apply text-gray-900 dark:text-white;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
@apply text-xl font-bold mb-4;
|
||||
@apply text-white text-opacity-70;
|
||||
}
|
||||
|
||||
.uesr-signature {
|
||||
@apply mt-4;
|
||||
@apply text-white text-opacity-70;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
@apply flex items-center;
|
||||
&-list {
|
||||
@apply flex justify-around w-2/5 text-center;
|
||||
@apply text-white text-opacity-70;
|
||||
|
||||
.label {
|
||||
@apply text-xl font-bold text-white;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.right {
|
||||
@apply flex-1 ml-4 overflow-hidden h-full;
|
||||
|
||||
.record-list {
|
||||
@apply rounded-2xl;
|
||||
@apply bg-light dark:bg-black;
|
||||
height: calc(100% - 3.75rem);
|
||||
|
||||
.record-item {
|
||||
@apply flex items-center px-4;
|
||||
}
|
||||
|
||||
.song-item {
|
||||
@apply flex-1;
|
||||
}
|
||||
|
||||
.play-count {
|
||||
@apply ml-4;
|
||||
@apply text-gray-600 dark:text-gray-400;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
@apply text-xl font-bold m-4;
|
||||
@apply text-gray-900 dark:text-white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.play-list {
|
||||
@apply mt-4 py-4 px-2 rounded-xl flex-1 overflow-hidden;
|
||||
@apply bg-light dark:bg-black;
|
||||
|
||||
&-title {
|
||||
@apply text-lg;
|
||||
@apply text-gray-900 dark:text-white;
|
||||
}
|
||||
|
||||
&-item {
|
||||
@apply flex items-center px-2 py-1 rounded-xl cursor-pointer;
|
||||
@apply transition-all duration-200;
|
||||
@apply hover:bg-light-200 dark:hover:bg-dark-200;
|
||||
|
||||
&-img {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
@apply rounded-xl;
|
||||
}
|
||||
|
||||
&-info {
|
||||
@apply ml-2;
|
||||
}
|
||||
|
||||
&-name {
|
||||
@apply text-gray-900 dark:text-white text-base;
|
||||
}
|
||||
|
||||
&-count {
|
||||
@apply text-gray-500 dark:text-gray-400;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mobile {
|
||||
.user-page {
|
||||
@apply px-4;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user