mirror of
https://github.com/algerkong/AlgerMusicPlayer.git
synced 2026-04-03 14:20:50 +08:00
✨ feat: 重构首页Hero、导航菜单与页面布局统一
HomeHero: - 重建每日推荐(左)+私人FM(右)双栏布局 - FM播放/暂停切换、不喜欢/下一首、背景流动动画、均衡器特效 - 修复FM数据获取(res.data.data双层结构) - 歌单预加载改为hover懒加载避免502 导航优化: - SearchBar顶部菜单: 首页/歌单/专辑/排行榜/MV/本地音乐 - 侧边栏隐藏MV和本地音乐(hideInSidebar) - 修复搜索类型切换时失焦收起(@mousedown.prevent) 页面统一: - 新建StickyTabPage通用布局组件(标题+吸顶tabs+内容slot) - 歌单/专辑/MV/播客页面统一使用StickyTabPage重构 - CategorySelector第一项添加ml-0.5防scale裁切 播客优化: - RadioCard简化去除订阅按钮、容忍radio为undefined - 去除最近播放section、loadDashboard包含loadSubscribedRadios i18n: 新碟上架→专辑(5语言)、新增fmTrash/fmNext(5语言)
This commit is contained in:
@@ -127,7 +127,7 @@ export default {
|
|||||||
title: 'Recommended MVs'
|
title: 'Recommended MVs'
|
||||||
},
|
},
|
||||||
newAlbum: {
|
newAlbum: {
|
||||||
title: 'New Albums',
|
title: 'Albums',
|
||||||
empty: 'No new albums'
|
empty: 'No new albums'
|
||||||
},
|
},
|
||||||
recommendNewMusic: {
|
recommendNewMusic: {
|
||||||
@@ -153,6 +153,23 @@ export default {
|
|||||||
toplistDesc: 'Trending now',
|
toplistDesc: 'Trending now',
|
||||||
mvDesc: 'Music videos',
|
mvDesc: 'Music videos',
|
||||||
playlistDesc: 'Curated playlists',
|
playlistDesc: 'Curated playlists',
|
||||||
|
personalFm: 'Personal FM',
|
||||||
|
discoverMusic: 'Discover Music',
|
||||||
|
personalFmDesc: 'Based on your taste',
|
||||||
|
recentPlays: 'Recent Plays',
|
||||||
|
viewAll: 'View All',
|
||||||
|
followedArtists: 'Followed Artists',
|
||||||
|
newSongs: ' new songs',
|
||||||
|
fromFollowedArtists: 'From artists you follow',
|
||||||
|
recommendNewMusic: 'New Music',
|
||||||
|
newSongExpress: 'New Releases',
|
||||||
|
discoverNewReleases: 'Discover the latest releases',
|
||||||
|
hotPlaylists: 'Hot Playlists',
|
||||||
|
hotArtists: 'Hot Artists',
|
||||||
|
hotArtistsTitle: 'Popular Artists',
|
||||||
|
hotArtistsDesc: 'Most popular artists right now',
|
||||||
|
fmTrash: 'Dislike',
|
||||||
|
fmNext: 'Next',
|
||||||
quickNav: {
|
quickNav: {
|
||||||
myFavorite: 'My Favorites',
|
myFavorite: 'My Favorites',
|
||||||
playHistory: 'History',
|
playHistory: 'History',
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ export default {
|
|||||||
title: 'おすすめMV'
|
title: 'おすすめMV'
|
||||||
},
|
},
|
||||||
newAlbum: {
|
newAlbum: {
|
||||||
title: '新着アルバム',
|
title: 'アルバム',
|
||||||
empty: '新しいアルバムがありません'
|
empty: '新しいアルバムがありません'
|
||||||
},
|
},
|
||||||
recommendNewMusic: {
|
recommendNewMusic: {
|
||||||
@@ -153,6 +153,23 @@ export default {
|
|||||||
toplistDesc: 'トレンド',
|
toplistDesc: 'トレンド',
|
||||||
mvDesc: 'ミュージックビデオ',
|
mvDesc: 'ミュージックビデオ',
|
||||||
playlistDesc: '厳選プレイリスト',
|
playlistDesc: '厳選プレイリスト',
|
||||||
|
personalFm: 'パーソナルFM',
|
||||||
|
discoverMusic: '新しい音楽を発見',
|
||||||
|
personalFmDesc: 'あなたの好みに基づいて',
|
||||||
|
recentPlays: '最近再生した曲',
|
||||||
|
viewAll: 'すべて表示',
|
||||||
|
followedArtists: 'フォロー中',
|
||||||
|
newSongs: '曲の新曲',
|
||||||
|
fromFollowedArtists: 'フォロー中のアーティストから',
|
||||||
|
recommendNewMusic: 'おすすめ新曲',
|
||||||
|
newSongExpress: '新曲速報',
|
||||||
|
discoverNewReleases: '最新リリースを見つけよう',
|
||||||
|
hotPlaylists: '人気プレイリスト',
|
||||||
|
hotArtists: '人気アーティスト',
|
||||||
|
hotArtistsTitle: '人気アーティスト',
|
||||||
|
hotArtistsDesc: '今最も人気のあるアーティスト',
|
||||||
|
fmTrash: '嫌い',
|
||||||
|
fmNext: '次へ',
|
||||||
quickNav: {
|
quickNav: {
|
||||||
myFavorite: 'お気に入り',
|
myFavorite: 'お気に入り',
|
||||||
playHistory: '再生履歴',
|
playHistory: '再生履歴',
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ export default {
|
|||||||
title: '추천 MV'
|
title: '추천 MV'
|
||||||
},
|
},
|
||||||
newAlbum: {
|
newAlbum: {
|
||||||
title: '신곡 앨범',
|
title: '앨범',
|
||||||
empty: '새 앨범이 없습니다'
|
empty: '새 앨범이 없습니다'
|
||||||
},
|
},
|
||||||
recommendNewMusic: {
|
recommendNewMusic: {
|
||||||
@@ -152,6 +152,23 @@ export default {
|
|||||||
toplistDesc: '인기 차트',
|
toplistDesc: '인기 차트',
|
||||||
mvDesc: '뮤직비디오',
|
mvDesc: '뮤직비디오',
|
||||||
playlistDesc: '엄선된 플레이리스트',
|
playlistDesc: '엄선된 플레이리스트',
|
||||||
|
personalFm: '개인 FM',
|
||||||
|
discoverMusic: '새로운 음악 발견',
|
||||||
|
personalFmDesc: '취향에 맞춘 추천',
|
||||||
|
recentPlays: '최근 재생',
|
||||||
|
viewAll: '전체 보기',
|
||||||
|
followedArtists: '팔로우 아티스트',
|
||||||
|
newSongs: '곡의 신곡',
|
||||||
|
fromFollowedArtists: '팔로우한 아티스트의 신곡',
|
||||||
|
recommendNewMusic: '추천 신곡',
|
||||||
|
newSongExpress: '신곡 속보',
|
||||||
|
discoverNewReleases: '최신 발매 곡을 발견하세요',
|
||||||
|
hotPlaylists: '인기 플레이리스트',
|
||||||
|
hotArtists: '인기 아티스트',
|
||||||
|
hotArtistsTitle: '인기 아티스트',
|
||||||
|
hotArtistsDesc: '지금 가장 인기 있는 아티스트',
|
||||||
|
fmTrash: '싫어요',
|
||||||
|
fmNext: '다음',
|
||||||
quickNav: {
|
quickNav: {
|
||||||
myFavorite: '내 즐겨찾기',
|
myFavorite: '내 즐겨찾기',
|
||||||
playHistory: '재생 기록',
|
playHistory: '재생 기록',
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ export default {
|
|||||||
title: '推荐MV'
|
title: '推荐MV'
|
||||||
},
|
},
|
||||||
newAlbum: {
|
newAlbum: {
|
||||||
title: '新碟上架',
|
title: '专辑',
|
||||||
empty: '暂无新专辑'
|
empty: '暂无新专辑'
|
||||||
},
|
},
|
||||||
recommendNewMusic: {
|
recommendNewMusic: {
|
||||||
@@ -146,6 +146,23 @@ export default {
|
|||||||
toplistDesc: '热门榜单',
|
toplistDesc: '热门榜单',
|
||||||
mvDesc: '音乐视频',
|
mvDesc: '音乐视频',
|
||||||
playlistDesc: '精选歌单',
|
playlistDesc: '精选歌单',
|
||||||
|
personalFm: '私人FM',
|
||||||
|
discoverMusic: '发现新音乐',
|
||||||
|
personalFmDesc: '根据你的喜好推荐',
|
||||||
|
recentPlays: '最近播放',
|
||||||
|
viewAll: '查看全部',
|
||||||
|
followedArtists: '关注歌手',
|
||||||
|
newSongs: '首新歌',
|
||||||
|
fromFollowedArtists: '来自你关注的歌手',
|
||||||
|
recommendNewMusic: '推荐新音乐',
|
||||||
|
newSongExpress: '新歌速递',
|
||||||
|
discoverNewReleases: '发现最新发行的好歌',
|
||||||
|
hotPlaylists: '精选歌单',
|
||||||
|
hotArtists: '热门歌手',
|
||||||
|
hotArtistsTitle: '热门艺人',
|
||||||
|
hotArtistsDesc: '当下最受欢迎的歌手',
|
||||||
|
fmTrash: '不喜欢',
|
||||||
|
fmNext: '下一首',
|
||||||
quickNav: {
|
quickNav: {
|
||||||
myFavorite: '我的收藏',
|
myFavorite: '我的收藏',
|
||||||
playHistory: '播放历史',
|
playHistory: '播放历史',
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ export default {
|
|||||||
title: '推薦MV'
|
title: '推薦MV'
|
||||||
},
|
},
|
||||||
newAlbum: {
|
newAlbum: {
|
||||||
title: '新碟上架',
|
title: '專輯',
|
||||||
empty: '暫無新專輯'
|
empty: '暫無新專輯'
|
||||||
},
|
},
|
||||||
recommendNewMusic: {
|
recommendNewMusic: {
|
||||||
@@ -146,6 +146,23 @@ export default {
|
|||||||
toplistDesc: '熱門榜單',
|
toplistDesc: '熱門榜單',
|
||||||
mvDesc: '音樂視訊',
|
mvDesc: '音樂視訊',
|
||||||
playlistDesc: '精選播放清單',
|
playlistDesc: '精選播放清單',
|
||||||
|
personalFm: '私人FM',
|
||||||
|
discoverMusic: '發現新音樂',
|
||||||
|
personalFmDesc: '根據你的喜好推薦',
|
||||||
|
recentPlays: '最近播放',
|
||||||
|
viewAll: '查看全部',
|
||||||
|
followedArtists: '關注歌手',
|
||||||
|
newSongs: '首新歌',
|
||||||
|
fromFollowedArtists: '來自你關注的歌手',
|
||||||
|
recommendNewMusic: '推薦新音樂',
|
||||||
|
newSongExpress: '新歌速遞',
|
||||||
|
discoverNewReleases: '發現最新發行的好歌',
|
||||||
|
hotPlaylists: '精選歌單',
|
||||||
|
hotArtists: '熱門歌手',
|
||||||
|
hotArtistsTitle: '熱門藝人',
|
||||||
|
hotArtistsDesc: '當下最受歡迎的歌手',
|
||||||
|
fmTrash: '不喜歡',
|
||||||
|
fmNext: '下一首',
|
||||||
quickNav: {
|
quickNav: {
|
||||||
myFavorite: '我的收藏',
|
myFavorite: '我的收藏',
|
||||||
playHistory: '播放歷史',
|
playHistory: '播放歷史',
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
class="py-1.5 px-4 mr-3 inline-block rounded-full cursor-pointer transition-all duration-300 text-sm font-medium bg-gray-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 hover:bg-gray-200 dark:hover:bg-neutral-700 hover:text-neutral-900 dark:hover:text-white"
|
class="py-1.5 px-4 mr-3 inline-block rounded-full cursor-pointer transition-all duration-300 text-sm font-medium bg-gray-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 hover:bg-gray-200 dark:hover:bg-neutral-700 hover:text-neutral-900 dark:hover:text-white"
|
||||||
:class="[
|
:class="[
|
||||||
animationClass,
|
animationClass,
|
||||||
|
index === 0 ? 'ml-0.5' : '',
|
||||||
isActive(category) ? 'bg-primary text-white shadow-lg shadow-primary/25 scale-105' : ''
|
isActive(category) ? 'bg-primary text-white shadow-lg shadow-primary/25 scale-105' : ''
|
||||||
]"
|
]"
|
||||||
:style="getAnimationDelay(index)"
|
:style="getAnimationDelay(index)"
|
||||||
|
|||||||
88
src/renderer/components/common/StickyTabPage.vue
Normal file
88
src/renderer/components/common/StickyTabPage.vue
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
<template>
|
||||||
|
<div class="h-full w-full bg-white transition-colors duration-500 dark:bg-black">
|
||||||
|
<n-scrollbar ref="scrollbarRef" class="h-full" :size="100" @scroll="handleScroll">
|
||||||
|
<div class="w-full pb-32">
|
||||||
|
<!-- Page Header (scrolls away) -->
|
||||||
|
<div ref="headerRef" class="page-padding pt-6 pb-2">
|
||||||
|
<h1 class="mb-2 text-2xl font-bold tracking-tight text-neutral-900 md:text-3xl dark:text-white">
|
||||||
|
{{ title }}
|
||||||
|
</h1>
|
||||||
|
<p v-if="description" class="text-neutral-500 dark:text-neutral-400">
|
||||||
|
{{ description }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabs (sticky on scroll) -->
|
||||||
|
<div
|
||||||
|
class="sticky-tabs z-10 transition-shadow duration-200"
|
||||||
|
:class="isSticky ? 'sticky top-0 shadow-sm' : ''"
|
||||||
|
>
|
||||||
|
<category-selector
|
||||||
|
:model-value="modelValue"
|
||||||
|
:categories="categories"
|
||||||
|
:label-key="labelKey"
|
||||||
|
:value-key="valueKey"
|
||||||
|
@change="(val: any) => emit('change', val)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content slot -->
|
||||||
|
<div class="page-padding pt-4">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</n-scrollbar>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
import CategorySelector from '@/components/common/CategorySelector.vue';
|
||||||
|
|
||||||
|
type Category = string | number | { [key: string]: any };
|
||||||
|
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
modelValue: any;
|
||||||
|
categories: Category[];
|
||||||
|
labelKey?: string;
|
||||||
|
valueKey?: string;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
labelKey: 'label',
|
||||||
|
valueKey: 'value'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
change: [value: any];
|
||||||
|
scroll: [e: any];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const scrollbarRef = ref();
|
||||||
|
const headerRef = ref<HTMLElement>();
|
||||||
|
const isSticky = ref(false);
|
||||||
|
|
||||||
|
const handleScroll = (e: any) => {
|
||||||
|
if (headerRef.value) {
|
||||||
|
const headerBottom = headerRef.value.offsetTop + headerRef.value.offsetHeight;
|
||||||
|
isSticky.value = e.target.scrollTop >= headerBottom;
|
||||||
|
}
|
||||||
|
emit('scroll', e);
|
||||||
|
};
|
||||||
|
|
||||||
|
const scrollTo = (options: ScrollToOptions) => {
|
||||||
|
scrollbarRef.value?.scrollTo(options);
|
||||||
|
};
|
||||||
|
|
||||||
|
defineExpose({ scrollbarRef, scrollTo });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sticky-tabs {
|
||||||
|
background: inherit;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
import type { DjProgram, DjRadio } from '@/types/podcast';
|
import type { DjProgram, DjRadio } from '@/types/podcast';
|
||||||
@@ -8,90 +6,79 @@ import { formatNumber, getImgUrl } from '@/utils';
|
|||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
radio: DjRadio;
|
radio?: DjRadio;
|
||||||
program?: DjProgram;
|
program?: DjProgram;
|
||||||
showSubscribeButton?: boolean;
|
|
||||||
isSubscribed?: boolean;
|
|
||||||
animationDelay?: string;
|
animationDelay?: string;
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{}
|
||||||
showSubscribeButton: false,
|
|
||||||
isSubscribed: false
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
subscribe: [radio: DjRadio];
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { t } = useI18n();
|
|
||||||
|
|
||||||
const isSubscribed = computed(() => props.isSubscribed);
|
|
||||||
|
|
||||||
const handleSubscribe = (e: Event) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
emit('subscribe', props.radio);
|
|
||||||
};
|
|
||||||
|
|
||||||
const goToDetail = () => {
|
const goToDetail = () => {
|
||||||
router.push(`/podcast/radio/${props.radio.id}`);
|
if (props.radio?.id) {
|
||||||
|
router.push(`/podcast/radio/${props.radio.id}`);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="radio-card animate-item group flex flex-col rounded-2xl bg-neutral-50 dark:bg-neutral-900/50 p-4 cursor-pointer hover:bg-neutral-100 dark:hover:bg-neutral-800/50 transition-all duration-300"
|
class="group cursor-pointer animate-item"
|
||||||
:style="{ animationDelay }"
|
:style="{ animationDelay }"
|
||||||
@click="goToDetail"
|
@click="goToDetail"
|
||||||
>
|
>
|
||||||
<div class="relative overflow-hidden rounded-xl">
|
<!-- Cover -->
|
||||||
|
<div
|
||||||
|
class="relative aspect-square overflow-hidden rounded-2xl shadow-md group-hover:shadow-xl transition-all duration-500"
|
||||||
|
>
|
||||||
<img
|
<img
|
||||||
:src="getImgUrl(radio.picUrl || program?.coverUrl || '', '200y200')"
|
:src="getImgUrl(radio?.picUrl || program?.coverUrl || '', '400y400')"
|
||||||
:alt="radio.name"
|
:alt="radio?.name || ''"
|
||||||
class="w-full aspect-square object-cover group-hover:scale-105 transition-transform duration-500"
|
class="h-full w-full object-cover transition-transform duration-700 group-hover:scale-110"
|
||||||
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
|
<!-- Hover overlay -->
|
||||||
<div
|
<div
|
||||||
v-if="showSubscribeButton && radio.subCount !== undefined"
|
class="absolute inset-0 bg-transparent group-hover:bg-black/20 transition-colors duration-300 flex items-center justify-center"
|
||||||
class="absolute top-2 right-2 z-10"
|
|
||||||
>
|
>
|
||||||
<n-button
|
<div
|
||||||
:type="isSubscribed ? 'default' : 'primary'"
|
class="w-12 h-12 rounded-full bg-white/90 flex items-center justify-center opacity-0 scale-75 group-hover:opacity-100 group-hover:scale-100 transition-all duration-300 shadow-xl"
|
||||||
size="small"
|
|
||||||
round
|
|
||||||
@click="handleSubscribe"
|
|
||||||
>
|
>
|
||||||
{{ isSubscribed ? t('podcast.subscribed') : t('podcast.subscribe') }}
|
<i class="ri-play-fill text-2xl text-neutral-900 ml-0.5"></i>
|
||||||
</n-button>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Recent played badge -->
|
||||||
<div
|
<div
|
||||||
v-if="program"
|
v-if="program"
|
||||||
class="absolute bottom-0 left-0 right-0 p-2 bg-black/40 backdrop-blur-sm text-white text-[10px] truncate"
|
class="absolute bottom-0 left-0 right-0 px-3 py-2 bg-gradient-to-t from-black/60 to-transparent text-white text-xs truncate"
|
||||||
>
|
>
|
||||||
{{ t('podcast.recentPlayed') }}: {{ program.mainSong?.name || program.name }}
|
{{ program.mainSong?.name || program.name }}
|
||||||
|
</div>
|
||||||
|
<!-- Episode count badge -->
|
||||||
|
<div
|
||||||
|
v-if="radio?.programCount && !program"
|
||||||
|
class="absolute top-3 right-3 px-2 py-1 rounded-lg bg-black/40 backdrop-blur-md text-white text-[10px] font-bold flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity duration-300"
|
||||||
|
>
|
||||||
|
<i class="ri-mic-fill"></i>
|
||||||
|
{{ radio.programCount }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3
|
<!-- Info -->
|
||||||
class="mt-3 text-sm md:text-base font-semibold text-neutral-900 dark:text-white line-clamp-2 group-hover:text-primary transition-colors"
|
<div class="mt-3 space-y-1">
|
||||||
:title="radio.name"
|
<h3
|
||||||
>
|
class="text-sm md:text-base font-bold text-neutral-900 dark:text-white line-clamp-1 group-hover:text-primary transition-colors"
|
||||||
{{ radio.name }}
|
:title="radio?.name || ''"
|
||||||
</h3>
|
>
|
||||||
|
{{ radio?.name || program?.name || '' }}
|
||||||
<p
|
</h3>
|
||||||
v-if="radio.desc"
|
<p
|
||||||
class="mt-1 text-xs md:text-sm text-neutral-500 dark:text-neutral-400 line-clamp-2"
|
v-if="radio?.subCount !== undefined"
|
||||||
>
|
class="text-xs text-neutral-500 dark:text-neutral-400"
|
||||||
{{ radio.desc }}
|
>
|
||||||
</p>
|
{{ formatNumber(radio?.subCount || 0) }} subscribers
|
||||||
|
</p>
|
||||||
<div
|
|
||||||
v-if="radio.subCount !== undefined"
|
|
||||||
class="mt-2 flex items-center justify-between text-xs text-neutral-400"
|
|
||||||
>
|
|
||||||
<span>{{ formatNumber(radio.subCount) }} {{ t('podcast.subscribeCount') }}</span>
|
|
||||||
<span>{{ radio.programCount }} {{ t('podcast.programCount') }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -66,8 +66,9 @@
|
|||||||
trigger="hover"
|
trigger="hover"
|
||||||
:options="searchTypeOptions"
|
:options="searchTypeOptions"
|
||||||
@select="selectSearchType"
|
@select="selectSearchType"
|
||||||
|
@mousedown.prevent
|
||||||
>
|
>
|
||||||
<div class="type-chip">
|
<div class="type-chip" @mousedown.prevent>
|
||||||
<span>{{
|
<span>{{
|
||||||
searchTypeOptions.find((i) => i.key === searchStore.searchType)?.label
|
searchTypeOptions.find((i) => i.key === searchStore.searchType)?.label
|
||||||
}}</span>
|
}}</span>
|
||||||
@@ -238,21 +239,28 @@ const showBackButton = computed(() => {
|
|||||||
const goBack = () => router.back();
|
const goBack = () => router.back();
|
||||||
|
|
||||||
// ── Tabs ──────────────────────────────────────────────
|
// ── Tabs ──────────────────────────────────────────────
|
||||||
const tabs = computed(() => [
|
const tabs = computed(() => {
|
||||||
{
|
const items = [
|
||||||
key: 'playlist',
|
{ key: 'home', label: t('comp.home'), path: '/', icon: 'ri-home-4-fill' },
|
||||||
label: t('comp.searchBar.tabPlaylist'),
|
{ key: 'playlist', label: t('comp.list'), path: '/list', icon: 'ri-play-list-2-fill' },
|
||||||
path: '/',
|
{ key: 'album', label: t('comp.newAlbum.title'), path: '/album', icon: 'ri-album-fill' },
|
||||||
icon: 'ri-play-list-2-fill'
|
{
|
||||||
},
|
key: 'charts',
|
||||||
{ key: 'mv', label: t('comp.searchBar.tabMv'), path: '/mv', icon: 'ri-movie-2-fill' },
|
label: t('comp.toplist'),
|
||||||
{
|
path: '/toplist',
|
||||||
key: 'charts',
|
icon: 'ri-bar-chart-grouped-fill'
|
||||||
label: t('comp.searchBar.tabCharts'),
|
},
|
||||||
path: '/toplist',
|
{ key: 'mv', label: t('comp.mv'), path: '/mv', icon: 'ri-movie-2-fill' },
|
||||||
icon: 'ri-bar-chart-grouped-fill'
|
{
|
||||||
}
|
key: 'localMusic',
|
||||||
]);
|
label: t('comp.localMusic'),
|
||||||
|
path: '/local-music',
|
||||||
|
icon: 'ri-folder-music-fill',
|
||||||
|
electronOnly: true
|
||||||
|
}
|
||||||
|
];
|
||||||
|
return items.filter((tab) => !tab.electronOnly || isElectron);
|
||||||
|
});
|
||||||
const isTabActive = (path: string) => route.path === path;
|
const isTabActive = (path: string) => route.path === path;
|
||||||
|
|
||||||
// Sliding pill
|
// Sliding pill
|
||||||
@@ -323,6 +331,7 @@ const selectSearchType = (key: number) => {
|
|||||||
searchStore.searchType = key;
|
searchStore.searchType = key;
|
||||||
if (searchValue.value)
|
if (searchValue.value)
|
||||||
router.push({ path: '/search-result', query: { keyword: searchValue.value, type: key } });
|
router.push({ path: '/search-result', query: { keyword: searchValue.value, type: key } });
|
||||||
|
nextTick(() => inputRef.value?.focus());
|
||||||
};
|
};
|
||||||
|
|
||||||
const rawSearchTypes = ref(SEARCH_TYPES);
|
const rawSearchTypes = ref(SEARCH_TYPES);
|
||||||
|
|||||||
@@ -62,7 +62,8 @@ const layoutRouter = [
|
|||||||
icon: 'icon-recordfill',
|
icon: 'icon-recordfill',
|
||||||
keepAlive: true,
|
keepAlive: true,
|
||||||
isMobile: false,
|
isMobile: false,
|
||||||
back: true
|
back: true,
|
||||||
|
hideInSidebar: true
|
||||||
},
|
},
|
||||||
component: () => import('@/views/mv/index.vue')
|
component: () => import('@/views/mv/index.vue')
|
||||||
},
|
},
|
||||||
@@ -97,7 +98,8 @@ const layoutRouter = [
|
|||||||
icon: 'ri-folder-music-fill',
|
icon: 'ri-folder-music-fill',
|
||||||
keepAlive: true,
|
keepAlive: true,
|
||||||
isMobile: false,
|
isMobile: false,
|
||||||
electronOnly: true
|
electronOnly: true,
|
||||||
|
hideInSidebar: true
|
||||||
},
|
},
|
||||||
component: () => import('@/views/local-music/index.vue')
|
component: () => import('@/views/local-music/index.vue')
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ export const useMenuStore = defineStore('menu', () => {
|
|||||||
if (item.meta?.electronOnly && !isElectron) {
|
if (item.meta?.electronOnly && !isElectron) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (item.meta?.hideInSidebar) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (settingsStore.isMobile) {
|
if (settingsStore.isMobile) {
|
||||||
return item.meta?.isMobile !== false;
|
return item.meta?.isMobile !== false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,113 +1,95 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="list-page h-full w-full bg-white dark:bg-black transition-colors duration-500">
|
<sticky-tab-page
|
||||||
<!-- 专辑地区分类 - 保持固定在顶部 -->
|
ref="pageRef"
|
||||||
<category-selector
|
:title="t('comp.newAlbum.title')"
|
||||||
:model-value="currentArea"
|
:description="currentAreaName"
|
||||||
:categories="areas"
|
:model-value="currentArea"
|
||||||
label-key="name"
|
:categories="areas"
|
||||||
value-key="value"
|
label-key="name"
|
||||||
@change="handleAreaChange"
|
value-key="value"
|
||||||
/>
|
@change="handleAreaChange"
|
||||||
|
@scroll="handleScroll"
|
||||||
<!-- 专辑列表 -->
|
>
|
||||||
<n-scrollbar
|
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-6">
|
||||||
ref="contentScrollbarRef"
|
<!-- Loading State -->
|
||||||
class="h-full"
|
<template v-if="loading && page === 0">
|
||||||
style="height: calc(100% - 73px)"
|
<div v-for="i in 15" :key="`loading-${i}`" class="space-y-3">
|
||||||
:size="100"
|
<div class="aspect-square skeleton-shimmer rounded-2xl" />
|
||||||
@scroll="handleScroll"
|
<div class="h-4 w-3/4 skeleton-shimmer rounded-lg" />
|
||||||
>
|
<div class="h-3 w-1/2 skeleton-shimmer rounded-lg" />
|
||||||
<div class="list-content w-full pb-32 pt-6 page-padding">
|
|
||||||
<!-- 列表标题 -->
|
|
||||||
<div class="mb-8">
|
|
||||||
<h1 class="text-2xl md:text-3xl font-bold text-neutral-900 dark:text-white mb-2">
|
|
||||||
{{ currentAreaName }}
|
|
||||||
</h1>
|
|
||||||
<p class="text-neutral-500 dark:text-neutral-400">{{ t('comp.newAlbum.title') }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-6">
|
<!-- Content State -->
|
||||||
<!-- Loading State -->
|
<template v-else>
|
||||||
<template v-if="loading && page === 0">
|
<div
|
||||||
<div v-for="i in 15" :key="`loading-${i}`" class="space-y-3">
|
v-for="(album, index) in albumList"
|
||||||
<div class="aspect-square skeleton-shimmer rounded-2xl" />
|
:key="album.id"
|
||||||
<div class="h-4 w-3/4 skeleton-shimmer rounded-lg" />
|
class="list-card group cursor-pointer animate-item"
|
||||||
<div class="h-3 w-1/2 skeleton-shimmer rounded-lg" />
|
:style="{ animationDelay: calculateAnimationDelay(index % TOTAL_ITEMS, 0.05) }"
|
||||||
</div>
|
@click.stop="openAlbum(album)"
|
||||||
</template>
|
>
|
||||||
|
<!-- Cover Image -->
|
||||||
|
<div
|
||||||
|
class="relative aspect-square overflow-hidden rounded-2xl shadow-md group-hover:shadow-xl transition-all duration-500"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="getImgUrl(album.picUrl, '400y400')"
|
||||||
|
:alt="album.name"
|
||||||
|
class="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
|
||||||
|
loading="lazy"
|
||||||
|
crossorigin="anonymous"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Content State -->
|
<!-- Play Overlay -->
|
||||||
<template v-else>
|
|
||||||
<div
|
<div
|
||||||
v-for="(album, index) in albumList"
|
class="absolute inset-0 bg-transparent group-hover:bg-black/20 transition-colors duration-300 flex items-center justify-center"
|
||||||
:key="album.id"
|
|
||||||
class="list-card group cursor-pointer animate-item"
|
|
||||||
:style="{ animationDelay: calculateAnimationDelay(index % TOTAL_ITEMS, 0.05) }"
|
|
||||||
@click.stop="openAlbum(album)"
|
|
||||||
>
|
>
|
||||||
<!-- Cover Image -->
|
|
||||||
<div
|
<div
|
||||||
class="relative aspect-square overflow-hidden rounded-2xl shadow-md group-hover:shadow-xl transition-all duration-500"
|
class="play-icon w-12 h-12 rounded-full bg-white/90 flex items-center justify-center opacity-0 scale-75 group-hover:opacity-100 group-hover:scale-100 transition-all duration-300 shadow-xl"
|
||||||
|
@click.stop="playAlbum(album)"
|
||||||
>
|
>
|
||||||
<img
|
<i class="ri-play-fill text-2xl text-neutral-900 ml-1"></i>
|
||||||
:src="getImgUrl(album.picUrl, '400y400')"
|
|
||||||
:alt="album.name"
|
|
||||||
class="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
|
|
||||||
loading="lazy"
|
|
||||||
crossorigin="anonymous"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Play Overlay -->
|
|
||||||
<div
|
|
||||||
class="absolute inset-0 bg-transparent group-hover:bg-black/20 transition-colors duration-300 flex items-center justify-center"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="play-icon w-12 h-12 rounded-full bg-white/90 flex items-center justify-center opacity-0 scale-75 group-hover:opacity-100 group-hover:scale-100 transition-all duration-300 shadow-xl"
|
|
||||||
@click.stop="playAlbum(album)"
|
|
||||||
>
|
|
||||||
<i class="ri-play-fill text-2xl text-neutral-900 ml-1"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Album Size Badge -->
|
|
||||||
<div
|
|
||||||
v-if="album.size"
|
|
||||||
class="absolute top-3 left-3 px-2 py-1 rounded-lg bg-black/40 backdrop-blur-md text-white text-[10px] font-bold flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity duration-300"
|
|
||||||
>
|
|
||||||
<i class="ri-music-2-fill"></i>
|
|
||||||
{{ album.size }} {{ t('comp.playlistDrawer.count') }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Info -->
|
|
||||||
<div class="mt-3 space-y-1">
|
|
||||||
<h3
|
|
||||||
class="text-sm md:text-base font-bold text-neutral-900 dark:text-white line-clamp-1 group-hover:text-primary transition-colors"
|
|
||||||
>
|
|
||||||
{{ album.name }}
|
|
||||||
</h3>
|
|
||||||
<p
|
|
||||||
v-if="getArtistNames(album)"
|
|
||||||
class="text-xs text-neutral-500 dark:text-neutral-400 line-clamp-1"
|
|
||||||
>
|
|
||||||
{{ getArtistNames(album) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 加载更多 -->
|
<!-- Album Size Badge -->
|
||||||
<div v-if="isLoadingMore" class="flex justify-center items-center py-8">
|
<div
|
||||||
<n-spin size="small" />
|
v-if="album.size"
|
||||||
<span class="ml-2 text-neutral-500">{{ t('comp.homeListItem.loading') }}</span>
|
class="absolute top-3 left-3 px-2 py-1 rounded-lg bg-black/40 backdrop-blur-md text-white text-[10px] font-bold flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity duration-300"
|
||||||
|
>
|
||||||
|
<i class="ri-music-2-fill"></i>
|
||||||
|
{{ album.size }} {{ t('comp.playlistDrawer.count') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Info -->
|
||||||
|
<div class="mt-3 space-y-1">
|
||||||
|
<h3
|
||||||
|
class="text-sm md:text-base font-bold text-neutral-900 dark:text-white line-clamp-1 group-hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
{{ album.name }}
|
||||||
|
</h3>
|
||||||
|
<p
|
||||||
|
v-if="getArtistNames(album)"
|
||||||
|
class="text-xs text-neutral-500 dark:text-neutral-400 line-clamp-1"
|
||||||
|
>
|
||||||
|
{{ getArtistNames(album) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!hasMore && albumList.length > 0" class="text-center py-8 text-neutral-500">
|
</template>
|
||||||
{{ t('comp.recommendSonglist.empty') }}
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
<!-- 加载更多 -->
|
||||||
</n-scrollbar>
|
<div v-if="isLoadingMore" class="flex justify-center items-center py-8">
|
||||||
</div>
|
<n-spin size="small" />
|
||||||
|
<span class="ml-2 text-neutral-500">{{ t('comp.homeListItem.loading') }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="!hasMore && albumList.length > 0" class="text-center py-8 text-neutral-500">
|
||||||
|
{{ t('comp.recommendSonglist.empty') }}
|
||||||
|
</div>
|
||||||
|
</sticky-tab-page>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
@@ -117,7 +99,7 @@ import { useRoute, useRouter } from 'vue-router';
|
|||||||
|
|
||||||
import { getNewAlbums } from '@/api/album';
|
import { getNewAlbums } from '@/api/album';
|
||||||
import { getAlbum } from '@/api/list';
|
import { getAlbum } from '@/api/list';
|
||||||
import CategorySelector from '@/components/common/CategorySelector.vue';
|
import StickyTabPage from '@/components/common/StickyTabPage.vue';
|
||||||
import { navigateToMusicList } from '@/components/common/MusicListNavigator';
|
import { navigateToMusicList } from '@/components/common/MusicListNavigator';
|
||||||
import { usePlayerCoreStore } from '@/store/modules/playerCore';
|
import { usePlayerCoreStore } from '@/store/modules/playerCore';
|
||||||
import { usePlaylistStore } from '@/store/modules/playlist';
|
import { usePlaylistStore } from '@/store/modules/playlist';
|
||||||
@@ -130,8 +112,9 @@ defineOptions({
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
const pageRef = ref();
|
||||||
|
|
||||||
const TOTAL_ITEMS = 30; // 每页数量
|
const TOTAL_ITEMS = 30;
|
||||||
|
|
||||||
const areas = computed(() => [
|
const areas = computed(() => [
|
||||||
{ name: t('comp.pages.album.area.all'), value: 'ALL' },
|
{ name: t('comp.pages.album.area.all'), value: 'ALL' },
|
||||||
@@ -153,8 +136,6 @@ const currentAreaName = computed(
|
|||||||
areas.value.find((a) => a.value === currentArea.value)?.name || t('comp.pages.album.area.all')
|
areas.value.find((a) => a.value === currentArea.value)?.name || t('comp.pages.album.area.all')
|
||||||
);
|
);
|
||||||
|
|
||||||
const contentScrollbarRef = ref();
|
|
||||||
|
|
||||||
const handleAreaChange = (value: string) => {
|
const handleAreaChange = (value: string) => {
|
||||||
router.replace({ query: { area: value } });
|
router.replace({ query: { area: value } });
|
||||||
loadList(value);
|
loadList(value);
|
||||||
@@ -169,7 +150,7 @@ const loadList = async (area: string, isLoadMore = false) => {
|
|||||||
page.value = 0;
|
page.value = 0;
|
||||||
albumList.value = [];
|
albumList.value = [];
|
||||||
await nextTick();
|
await nextTick();
|
||||||
contentScrollbarRef.value?.scrollTo({ top: 0 });
|
pageRef.value?.scrollTo({ top: 0 });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -179,9 +160,6 @@ const loadList = async (area: string, isLoadMore = false) => {
|
|||||||
offset: page.value * TOTAL_ITEMS
|
offset: page.value * TOTAL_ITEMS
|
||||||
};
|
};
|
||||||
const { data } = await getNewAlbums(params);
|
const { data } = await getNewAlbums(params);
|
||||||
|
|
||||||
// API returns { albums: [], code: 200 } or { monthData: [] } depending on endpoint
|
|
||||||
// /album/new returns { albums: [], total, code }
|
|
||||||
const albums = data.albums || [];
|
const albums = data.albums || [];
|
||||||
|
|
||||||
if (isLoadMore) {
|
if (isLoadMore) {
|
||||||
@@ -190,7 +168,6 @@ const loadList = async (area: string, isLoadMore = false) => {
|
|||||||
albumList.value = albums;
|
albumList.value = albums;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we have more data
|
|
||||||
hasMore.value = albums.length === TOTAL_ITEMS;
|
hasMore.value = albums.length === TOTAL_ITEMS;
|
||||||
page.value++;
|
page.value++;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -289,12 +266,4 @@ watch(
|
|||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-card {
|
|
||||||
&:hover {
|
|
||||||
.play-icon {
|
|
||||||
@apply opacity-100 scale-100;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -34,11 +34,13 @@
|
|||||||
:cover="item.picUrl"
|
:cover="item.picUrl"
|
||||||
:title="item.name"
|
:title="item.name"
|
||||||
:subtitle="item.copywriter"
|
:subtitle="item.copywriter"
|
||||||
:tracks="playlistTracksMap[item.id] || []"
|
:tracks="isElectron ? playlistTracksMap[item.id] || [] : []"
|
||||||
|
:show-hover-tracks="isElectron"
|
||||||
:play-count="item.playCount"
|
:play-count="item.playCount"
|
||||||
:animation-delay="calculateAnimationDelay(index, 0.04)"
|
:animation-delay="calculateAnimationDelay(index, 0.04)"
|
||||||
@click="handlePlaylistClick(item)"
|
@click="handlePlaylistClick(item)"
|
||||||
@play="playPlaylist(item)"
|
@play="playPlaylist(item)"
|
||||||
|
@mouseenter="isElectron && loadTracksOnHover(item.id)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -110,10 +112,6 @@ const fetchPlaylists = async () => {
|
|||||||
const { data } = await getPersonalizedPlaylist(props.limit || displayCount.value + 5);
|
const { data } = await getPersonalizedPlaylist(props.limit || displayCount.value + 5);
|
||||||
if (data.code === 200) {
|
if (data.code === 200) {
|
||||||
playlists.value = data.result || [];
|
playlists.value = data.result || [];
|
||||||
// Preload tracks for displayed playlists (Electron only)
|
|
||||||
if (isElectron) {
|
|
||||||
preloadAllTracks();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch playlists:', error);
|
console.error('Failed to fetch playlists:', error);
|
||||||
@@ -122,29 +120,19 @@ const fetchPlaylists = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const preloadAllTracks = async () => {
|
/** Lazy load tracks for a single playlist on hover */
|
||||||
const playlistsToLoad = displayPlaylists.value;
|
const loadTracksOnHover = async (id: number) => {
|
||||||
|
if (playlistTracksMap[id]) return;
|
||||||
// Load tracks in parallel with concurrency limit
|
try {
|
||||||
const batchSize = 4;
|
const { data } = await getListDetail(id);
|
||||||
for (let i = 0; i < playlistsToLoad.length; i += batchSize) {
|
if (data.playlist?.tracks) {
|
||||||
const batch = playlistsToLoad.slice(i, i + batchSize);
|
playlistTracksMap[id] = data.playlist.tracks.slice(0, 3).map((s: any) => ({
|
||||||
await Promise.all(
|
id: s.id,
|
||||||
batch.map(async (item) => {
|
name: s.name
|
||||||
if (playlistTracksMap[item.id]) return;
|
}));
|
||||||
try {
|
}
|
||||||
const { data } = await getListDetail(item.id);
|
} catch {
|
||||||
if (data.playlist?.tracks) {
|
// silent — user can retry by hovering again
|
||||||
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);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,109 +1,91 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="list-page h-full w-full bg-white dark:bg-black transition-colors duration-500">
|
<sticky-tab-page
|
||||||
<!-- 歌单分类 - 保持固定在顶部 -->
|
ref="pageRef"
|
||||||
<category-selector
|
:title="listTitle"
|
||||||
:model-value="currentType"
|
:description="t('comp.pages.list.desc')"
|
||||||
:categories="playlistCategory?.sub || []"
|
:model-value="currentType"
|
||||||
label-key="name"
|
:categories="playlistCategory?.sub || []"
|
||||||
value-key="name"
|
label-key="name"
|
||||||
@change="handleTypeChange"
|
value-key="name"
|
||||||
/>
|
@change="handleTypeChange"
|
||||||
|
@scroll="handleScroll"
|
||||||
<!-- 歌单列表 -->
|
>
|
||||||
<n-scrollbar
|
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-6">
|
||||||
ref="contentScrollbarRef"
|
<!-- Loading State -->
|
||||||
class="h-full"
|
<template v-if="loading && page === 0">
|
||||||
style="height: calc(100% - 73px)"
|
<div v-for="i in 15" :key="`loading-${i}`" class="space-y-3">
|
||||||
:size="100"
|
<div class="aspect-square skeleton-shimmer rounded-2xl" />
|
||||||
@scroll="handleScroll"
|
<div class="h-4 w-3/4 skeleton-shimmer rounded-lg" />
|
||||||
>
|
|
||||||
<div class="list-content w-full pb-32 pt-6 page-padding">
|
|
||||||
<!-- 列表标题 -->
|
|
||||||
<div class="mb-8">
|
|
||||||
<h1 class="text-2xl md:text-3xl font-bold text-neutral-900 dark:text-white mb-2">
|
|
||||||
{{ listTitle }}
|
|
||||||
</h1>
|
|
||||||
<p class="text-neutral-500 dark:text-neutral-400">{{ t('comp.pages.list.desc') }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-6">
|
<!-- Content State -->
|
||||||
<!-- Loading State -->
|
<template v-else>
|
||||||
<template v-if="loading && page === 0">
|
<div
|
||||||
<div v-for="i in 15" :key="`loading-${i}`" class="space-y-3">
|
v-for="(item, index) in recommendList"
|
||||||
<div class="aspect-square skeleton-shimmer rounded-2xl" />
|
:key="item.id"
|
||||||
<div class="h-4 w-3/4 skeleton-shimmer rounded-lg" />
|
class="list-card group cursor-pointer"
|
||||||
</div>
|
:class="{ 'animate-item': !animatedIds.has(item.id) }"
|
||||||
</template>
|
:style="{
|
||||||
|
animationDelay: !animatedIds.has(item.id)
|
||||||
|
? calculateAnimationDelay(index % TOTAL_ITEMS, 0.05)
|
||||||
|
: '0s'
|
||||||
|
}"
|
||||||
|
@click.stop="openPlaylist(item)"
|
||||||
|
@animationend="animatedIds.add(item.id)"
|
||||||
|
>
|
||||||
|
<!-- Cover Image -->
|
||||||
|
<div
|
||||||
|
class="relative aspect-square overflow-hidden rounded-2xl shadow-md group-hover:shadow-xl transition-all duration-500"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="getImgUrl(item.picUrl || item.coverImgUrl, '400y400')"
|
||||||
|
:alt="item.name"
|
||||||
|
class="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Content State -->
|
<!-- Play Overlay -->
|
||||||
<template v-else>
|
|
||||||
<div
|
<div
|
||||||
v-for="(item, index) in recommendList"
|
class="absolute inset-0 bg-transparent group-hover:bg-black/20 transition-colors duration-300 flex items-center justify-center"
|
||||||
:key="item.id"
|
|
||||||
class="list-card group cursor-pointer"
|
|
||||||
:class="{ 'animate-item': !animatedIds.has(item.id) }"
|
|
||||||
:style="{
|
|
||||||
animationDelay: !animatedIds.has(item.id)
|
|
||||||
? calculateAnimationDelay(index % TOTAL_ITEMS, 0.05)
|
|
||||||
: '0s'
|
|
||||||
}"
|
|
||||||
@click.stop="openPlaylist(item)"
|
|
||||||
@animationend="animatedIds.add(item.id)"
|
|
||||||
>
|
>
|
||||||
<!-- Cover Image -->
|
|
||||||
<div
|
<div
|
||||||
class="relative aspect-square overflow-hidden rounded-2xl shadow-md group-hover:shadow-xl transition-all duration-500"
|
class="play-icon w-12 h-12 rounded-full bg-white/90 flex items-center justify-center opacity-0 scale-75 group-hover:opacity-100 group-hover:scale-100 transition-all duration-300 shadow-xl"
|
||||||
>
|
>
|
||||||
<img
|
<i class="ri-play-fill text-2xl text-neutral-900 ml-1"></i>
|
||||||
:src="getImgUrl(item.picUrl || item.coverImgUrl, '400y400')"
|
|
||||||
:alt="item.name"
|
|
||||||
class="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Play Overlay -->
|
|
||||||
<div
|
|
||||||
class="absolute inset-0 bg-transparent group-hover:bg-black/20 transition-colors duration-300 flex items-center justify-center"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="play-icon w-12 h-12 rounded-full bg-white/90 flex items-center justify-center opacity-0 scale-75 group-hover:opacity-100 group-hover:scale-100 transition-all duration-300 shadow-xl"
|
|
||||||
>
|
|
||||||
<i class="ri-play-fill text-2xl text-neutral-900 ml-1"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Play Count Badge -->
|
|
||||||
<div
|
|
||||||
class="absolute top-3 right-3 px-2 py-1 rounded-lg bg-black/40 backdrop-blur-md text-white text-[10px] font-bold flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity duration-300"
|
|
||||||
>
|
|
||||||
<i class="ri-play-fill"></i>
|
|
||||||
{{ formatNumber(item.playCount) }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Info -->
|
|
||||||
<div class="mt-3 space-y-1">
|
|
||||||
<h3
|
|
||||||
class="text-sm md:text-base font-bold text-neutral-900 dark:text-white line-clamp-1 group-hover:text-primary transition-colors"
|
|
||||||
>
|
|
||||||
{{ item.name }}
|
|
||||||
</h3>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 加载更多 -->
|
<!-- Play Count Badge -->
|
||||||
<div v-if="isLoadingMore" class="flex justify-center items-center py-8">
|
<div
|
||||||
<n-spin size="small" />
|
class="absolute top-3 right-3 px-2 py-1 rounded-lg bg-black/40 backdrop-blur-md text-white text-[10px] font-bold flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity duration-300"
|
||||||
<span class="ml-2 text-neutral-500">{{ t('common.loading') }}</span>
|
>
|
||||||
|
<i class="ri-play-fill"></i>
|
||||||
|
{{ formatNumber(item.playCount) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Info -->
|
||||||
|
<div class="mt-3 space-y-1">
|
||||||
|
<h3
|
||||||
|
class="text-sm md:text-base font-bold text-neutral-900 dark:text-white line-clamp-1 group-hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
{{ item.name }}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!hasMore && recommendList.length > 0" class="text-center py-8 text-neutral-500">
|
</template>
|
||||||
{{ t('common.noMore') }}
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
<!-- 加载更多 -->
|
||||||
</n-scrollbar>
|
<div v-if="isLoadingMore" class="flex justify-center items-center py-8">
|
||||||
</div>
|
<n-spin size="small" />
|
||||||
|
<span class="ml-2 text-neutral-500">{{ t('common.loading') }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="!hasMore && recommendList.length > 0" class="text-center py-8 text-neutral-500">
|
||||||
|
{{ t('common.noMore') }}
|
||||||
|
</div>
|
||||||
|
</sticky-tab-page>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
@@ -113,7 +95,7 @@ import { useRoute, useRouter } from 'vue-router';
|
|||||||
|
|
||||||
import { getPlaylistCategory } from '@/api/home';
|
import { getPlaylistCategory } from '@/api/home';
|
||||||
import { getListByCat } from '@/api/list';
|
import { getListByCat } from '@/api/list';
|
||||||
import CategorySelector from '@/components/common/CategorySelector.vue';
|
import StickyTabPage from '@/components/common/StickyTabPage.vue';
|
||||||
import { navigateToMusicList } from '@/components/common/MusicListNavigator';
|
import { navigateToMusicList } from '@/components/common/MusicListNavigator';
|
||||||
import type { IPlayListSort } from '@/types/playlist';
|
import type { IPlayListSort } from '@/types/playlist';
|
||||||
import { calculateAnimationDelay, formatNumber, getImgUrl } from '@/utils';
|
import { calculateAnimationDelay, formatNumber, getImgUrl } from '@/utils';
|
||||||
@@ -123,13 +105,14 @@ defineOptions({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const TOTAL_ITEMS = 42; // 每页数量
|
const TOTAL_ITEMS = 42;
|
||||||
|
|
||||||
const recommendList = ref<any[]>([]);
|
const recommendList = ref<any[]>([]);
|
||||||
const page = ref(0);
|
const page = ref(0);
|
||||||
const hasMore = ref(true);
|
const hasMore = ref(true);
|
||||||
const isLoadingMore = ref(false);
|
const isLoadingMore = ref(false);
|
||||||
const animatedIds = reactive(new Set<number>());
|
const animatedIds = reactive(new Set<number>());
|
||||||
|
const pageRef = ref();
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@@ -157,7 +140,7 @@ const loadList = async (type: string, isLoadMore = false) => {
|
|||||||
page.value = 0;
|
page.value = 0;
|
||||||
recommendList.value = [];
|
recommendList.value = [];
|
||||||
await nextTick();
|
await nextTick();
|
||||||
contentScrollbarRef.value?.scrollTo({ top: 0 });
|
pageRef.value?.scrollTo({ top: 0 });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -182,22 +165,16 @@ const loadList = async (type: string, isLoadMore = false) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 监听滚动事件
|
|
||||||
const handleScroll = (e: any) => {
|
const handleScroll = (e: any) => {
|
||||||
const { scrollTop, scrollHeight, clientHeight } = e.target;
|
const { scrollTop, scrollHeight, clientHeight } = e.target;
|
||||||
// 距离底部100px时加载更多
|
|
||||||
if (scrollTop + clientHeight >= scrollHeight - 100 && !isLoadingMore.value && hasMore.value) {
|
if (scrollTop + clientHeight >= scrollHeight - 100 && !isLoadingMore.value && hasMore.value) {
|
||||||
loadList(currentType.value, true);
|
loadList(currentType.value, true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 添加歌单分类相关的代码
|
|
||||||
const playlistCategory = ref<IPlayListSort>();
|
const playlistCategory = ref<IPlayListSort>();
|
||||||
const currentType = ref((route.query.type as string) || DEFAULT_CAT);
|
const currentType = ref((route.query.type as string) || DEFAULT_CAT);
|
||||||
|
|
||||||
const contentScrollbarRef = ref();
|
|
||||||
|
|
||||||
// 加载歌单分类
|
|
||||||
const loadPlaylistCategory = async () => {
|
const loadPlaylistCategory = async () => {
|
||||||
const { data } = await getPlaylistCategory();
|
const { data } = await getPlaylistCategory();
|
||||||
playlistCategory.value = {
|
playlistCategory.value = {
|
||||||
@@ -231,7 +208,6 @@ watch(
|
|||||||
async (newParams) => {
|
async (newParams) => {
|
||||||
if (route.path !== '/list') return;
|
if (route.path !== '/list') return;
|
||||||
const newType = (newParams.type as string) || DEFAULT_CAT;
|
const newType = (newParams.type as string) || DEFAULT_CAT;
|
||||||
// 如果路由参数变化,且与当前类型不同,则重新加载
|
|
||||||
if (newType !== currentType.value) {
|
if (newType !== currentType.value) {
|
||||||
listTitle.value = newType === DEFAULT_CAT ? t('comp.pages.list.dailyRecommend') : newType;
|
listTitle.value = newType === DEFAULT_CAT ? t('comp.pages.list.dailyRecommend') : newType;
|
||||||
currentType.value = newType;
|
currentType.value = newType;
|
||||||
@@ -257,12 +233,4 @@ watch(
|
|||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-card {
|
|
||||||
&:hover {
|
|
||||||
.play-icon {
|
|
||||||
@apply opacity-100 scale-100;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,115 +1,93 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="mv-list-page h-full w-full bg-white dark:bg-black transition-colors duration-500">
|
<div class="h-full w-full">
|
||||||
<!-- MV 分类 - 保持固定在顶部 -->
|
<sticky-tab-page
|
||||||
<category-selector
|
ref="pageRef"
|
||||||
|
title="MV"
|
||||||
|
:description="t('comp.pages.mv.desc')"
|
||||||
:model-value="selectedCategory"
|
:model-value="selectedCategory"
|
||||||
:categories="categories"
|
:categories="categories"
|
||||||
@change="handleCategoryChange"
|
@change="handleCategoryChange"
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- MV 列表内容 -->
|
|
||||||
<n-scrollbar
|
|
||||||
ref="contentScrollbarRef"
|
|
||||||
class="h-full"
|
|
||||||
style="height: calc(100% - 73px)"
|
|
||||||
:size="100"
|
|
||||||
@scroll="handleScroll"
|
@scroll="handleScroll"
|
||||||
>
|
>
|
||||||
<div class="mv-content w-full pb-32 pt-6 page-padding">
|
<!-- MV Grid -->
|
||||||
<!-- 页面标题 -->
|
<div v-if="initLoading" class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
|
||||||
<div class="mb-8">
|
<div v-for="i in 12" :key="i" class="space-y-3">
|
||||||
<h1
|
<div class="aspect-video skeleton-shimmer rounded-2xl" />
|
||||||
class="text-3xl md:text-4xl font-bold tracking-tight text-neutral-900 dark:text-white mb-2"
|
<div class="h-4 w-3/4 skeleton-shimmer rounded-lg" />
|
||||||
>
|
<div class="h-3 w-1/2 skeleton-shimmer rounded-lg" />
|
||||||
MV
|
|
||||||
</h1>
|
|
||||||
<p class="text-neutral-500 dark:text-neutral-400">{{ t('comp.pages.mv.desc') }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- MV Grid Container -->
|
<div v-else class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
|
||||||
<div class="mv-grid-container">
|
<div
|
||||||
<!-- Loading State -->
|
v-for="(item, index) in mvList"
|
||||||
<div v-if="initLoading" class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
|
:key="item.id"
|
||||||
<div v-for="i in 12" :key="i" class="space-y-3">
|
class="mv-card group cursor-pointer animate-item"
|
||||||
<div class="aspect-video skeleton-shimmer rounded-2xl" />
|
:style="{ animationDelay: calculateAnimationDelay(index, 0.05) }"
|
||||||
<div class="h-4 w-3/4 skeleton-shimmer rounded-lg" />
|
@click="handleShowMv(item, index)"
|
||||||
<div class="h-3 w-1/2 skeleton-shimmer rounded-lg" />
|
>
|
||||||
</div>
|
<!-- Cover Image -->
|
||||||
</div>
|
<div
|
||||||
|
class="relative aspect-video overflow-hidden rounded-2xl shadow-md group-hover:shadow-xl transition-all duration-500"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="getImgUrl(item.cover, '400y225')"
|
||||||
|
:alt="item.name"
|
||||||
|
class="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Content State -->
|
<!-- Play Overlay -->
|
||||||
<div v-else class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
|
|
||||||
<div
|
<div
|
||||||
v-for="(item, index) in mvList"
|
class="absolute inset-0 bg-transparent group-hover:bg-black/40 transition-colors duration-300 flex items-center justify-center"
|
||||||
:key="item.id"
|
|
||||||
class="mv-card group cursor-pointer animate-item"
|
|
||||||
:style="{ animationDelay: calculateAnimationDelay(index, 0.05) }"
|
|
||||||
@click="handleShowMv(item, index)"
|
|
||||||
>
|
>
|
||||||
<!-- Cover Image -->
|
|
||||||
<div
|
<div
|
||||||
class="relative aspect-video overflow-hidden rounded-2xl shadow-md group-hover:shadow-xl transition-all duration-500"
|
class="play-icon w-12 h-12 rounded-full bg-white/90 flex items-center justify-center opacity-0 scale-75 group-hover:opacity-100 group-hover:scale-100 transition-all duration-300 shadow-xl"
|
||||||
>
|
>
|
||||||
<img
|
<i class="ri-play-fill text-2xl text-neutral-900 ml-1"></i>
|
||||||
:src="getImgUrl(item.cover, '400y225')"
|
|
||||||
:alt="item.name"
|
|
||||||
class="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Play Overlay -->
|
|
||||||
<div
|
|
||||||
class="absolute inset-0 bg-transparent group-hover:bg-black/40 transition-colors duration-300 flex items-center justify-center"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="play-icon w-12 h-12 rounded-full bg-white/90 flex items-center justify-center opacity-0 scale-75 group-hover:opacity-100 group-hover:scale-100 transition-all duration-300 shadow-xl"
|
|
||||||
>
|
|
||||||
<i class="ri-play-fill text-2xl text-neutral-900 ml-1"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Play Count Badge -->
|
|
||||||
<div
|
|
||||||
class="absolute top-3 right-3 px-2 py-1 rounded-lg bg-black/40 backdrop-blur-md text-white text-[10px] font-bold flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity duration-300"
|
|
||||||
>
|
|
||||||
<i class="ri-play-fill"></i>
|
|
||||||
{{ formatNumber(item.playCount) }}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Info -->
|
<!-- Play Count Badge -->
|
||||||
<div class="mt-3 space-y-1">
|
<div
|
||||||
<h3
|
class="absolute top-3 right-3 px-2 py-1 rounded-lg bg-black/40 backdrop-blur-md text-white text-[10px] font-bold flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity duration-300"
|
||||||
class="text-sm md:text-base font-bold text-neutral-900 dark:text-white line-clamp-1 group-hover:text-primary transition-colors"
|
>
|
||||||
>
|
<i class="ri-play-fill"></i>
|
||||||
{{ item.name }}
|
{{ formatNumber(item.playCount) }}
|
||||||
</h3>
|
|
||||||
<p class="text-xs text-neutral-500 dark:text-neutral-400 line-clamp-1">
|
|
||||||
{{ item.artistName }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Loading More / No More -->
|
<!-- Info -->
|
||||||
<div class="mt-12 py-8 border-t border-neutral-100 dark:border-neutral-800">
|
<div class="mt-3 space-y-1">
|
||||||
<div v-if="loadingMore" class="flex flex-col items-center gap-4">
|
<h3
|
||||||
<n-spin size="small" />
|
class="text-sm md:text-base font-bold text-neutral-900 dark:text-white line-clamp-1 group-hover:text-primary transition-colors"
|
||||||
<span class="text-xs text-neutral-400 font-medium tracking-widest uppercase">
|
>
|
||||||
{{ t('comp.pages.mv.loadingMore') }}
|
{{ item.name }}
|
||||||
</span>
|
</h3>
|
||||||
</div>
|
<p class="text-xs text-neutral-500 dark:text-neutral-400 line-clamp-1">
|
||||||
<div v-if="!hasMore && !initLoading" class="text-center">
|
{{ item.artistName }}
|
||||||
<span
|
</p>
|
||||||
class="text-xs text-neutral-400 font-medium tracking-widest uppercase opacity-50"
|
|
||||||
>
|
|
||||||
{{ t('comp.pages.mv.noMore') }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</n-scrollbar>
|
|
||||||
|
<!-- Loading More / No More -->
|
||||||
|
<div class="mt-12 py-8 border-t border-neutral-100 dark:border-neutral-800">
|
||||||
|
<div v-if="loadingMore" class="flex flex-col items-center gap-4">
|
||||||
|
<n-spin size="small" />
|
||||||
|
<span class="text-xs text-neutral-400 font-medium tracking-widest uppercase">
|
||||||
|
{{ t('comp.pages.mv.loadingMore') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="!hasMore && !initLoading" class="text-center">
|
||||||
|
<span
|
||||||
|
class="text-xs text-neutral-400 font-medium tracking-widest uppercase opacity-50"
|
||||||
|
>
|
||||||
|
{{ t('comp.pages.mv.noMore') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sticky-tab-page>
|
||||||
|
|
||||||
<mv-player
|
<mv-player
|
||||||
v-model:show="showMv"
|
v-model:show="showMv"
|
||||||
@@ -127,7 +105,7 @@ import { useI18n } from 'vue-i18n';
|
|||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
|
||||||
import { getAllMv, getTopMv } from '@/api/mv';
|
import { getAllMv, getTopMv } from '@/api/mv';
|
||||||
import CategorySelector from '@/components/common/CategorySelector.vue';
|
import StickyTabPage from '@/components/common/StickyTabPage.vue';
|
||||||
import MvPlayer from '@/components/MvPlayer.vue';
|
import MvPlayer from '@/components/MvPlayer.vue';
|
||||||
import { audioService } from '@/services/audioService';
|
import { audioService } from '@/services/audioService';
|
||||||
import { usePlayerStore } from '@/store/modules/player';
|
import { usePlayerStore } from '@/store/modules/player';
|
||||||
@@ -146,8 +124,9 @@ const initLoading = ref(false);
|
|||||||
const loadingMore = ref(false);
|
const loadingMore = ref(false);
|
||||||
const currentIndex = ref(0);
|
const currentIndex = ref(0);
|
||||||
const offset = ref(0);
|
const offset = ref(0);
|
||||||
const limit = ref(40); // 调整为40,方便4列布局 (10行)
|
const limit = ref(40);
|
||||||
const hasMore = ref(true);
|
const hasMore = ref(true);
|
||||||
|
const pageRef = ref();
|
||||||
|
|
||||||
const categories = computed(() => [
|
const categories = computed(() => [
|
||||||
{ label: t('comp.pages.mv.area.all'), value: '全部' },
|
{ label: t('comp.pages.mv.area.all'), value: '全部' },
|
||||||
@@ -161,7 +140,6 @@ const selectedCategory = ref('全部');
|
|||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const contentScrollbarRef = ref();
|
|
||||||
|
|
||||||
const playerStore = usePlayerStore();
|
const playerStore = usePlayerStore();
|
||||||
|
|
||||||
@@ -170,18 +148,15 @@ const handleCategoryChange = async (value: string) => {
|
|||||||
offset.value = 0;
|
offset.value = 0;
|
||||||
mvList.value = [];
|
mvList.value = [];
|
||||||
hasMore.value = true;
|
hasMore.value = true;
|
||||||
// 更新路由参数
|
|
||||||
router.replace({ query: { ...route.query, area: value } });
|
router.replace({ query: { ...route.query, area: value } });
|
||||||
await loadMvList();
|
await loadMvList();
|
||||||
};
|
};
|
||||||
|
|
||||||
// 监听路由变化
|
|
||||||
watch(
|
watch(
|
||||||
() => route.query,
|
() => route.query,
|
||||||
async (newParams) => {
|
async (newParams) => {
|
||||||
if (route.path !== '/mv') return;
|
if (route.path !== '/mv') return;
|
||||||
const newArea = (newParams.area as string) || '全部';
|
const newArea = (newParams.area as string) || '全部';
|
||||||
// 如果路由参数变化,且与当前分类不同,则重新加载
|
|
||||||
if (newArea !== selectedCategory.value) {
|
if (newArea !== selectedCategory.value) {
|
||||||
selectedCategory.value = newArea;
|
selectedCategory.value = newArea;
|
||||||
}
|
}
|
||||||
@@ -189,7 +164,6 @@ watch(
|
|||||||
);
|
);
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
// 从路由获取初始分类
|
|
||||||
selectedCategory.value = (route.query.area as string) || '全部';
|
selectedCategory.value = (route.query.area as string) || '全部';
|
||||||
await loadMvList();
|
await loadMvList();
|
||||||
});
|
});
|
||||||
@@ -264,12 +238,9 @@ const loadMvList = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleScroll = (e: Event) => {
|
const handleScroll = (e: any) => {
|
||||||
const target = e.target as Element;
|
const { scrollTop, clientHeight, scrollHeight } = e.target;
|
||||||
const { scrollTop, clientHeight, scrollHeight } = target;
|
if (scrollHeight - (scrollTop + clientHeight) < 150) {
|
||||||
const threshold = 150;
|
|
||||||
|
|
||||||
if (scrollHeight - (scrollTop + clientHeight) < threshold) {
|
|
||||||
loadMvList();
|
loadMvList();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -292,12 +263,4 @@ const isPrevDisabled = computed(() => currentIndex.value === 0);
|
|||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.mv-card {
|
|
||||||
&:hover {
|
|
||||||
.play-icon {
|
|
||||||
@apply opacity-100 scale-100;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,280 +1,144 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<sticky-tab-page
|
||||||
class="podcast-container h-full w-full bg-white dark:bg-black transition-colors duration-500 flex flex-col"
|
ref="pageRef"
|
||||||
|
:title="currentCategoryId === -1 ? t('podcast.podcast') : currentCategoryName"
|
||||||
|
:description="currentCategoryId === -1 ? t('podcast.discover') : t('podcast.exploreCategoryRadios')"
|
||||||
|
:model-value="currentCategoryId"
|
||||||
|
:categories="categoryList"
|
||||||
|
label-key="name"
|
||||||
|
value-key="id"
|
||||||
|
@change="handleCategoryChange"
|
||||||
|
@scroll="handleScroll"
|
||||||
>
|
>
|
||||||
<!-- Top Categories Bar -->
|
<!-- Dashboard View -->
|
||||||
<category-selector
|
<div v-if="currentCategoryId === -1" class="space-y-10">
|
||||||
:model-value="currentCategoryId"
|
<!-- My Subscriptions -->
|
||||||
:categories="categoryList"
|
<section v-if="userStore.user && subscribedRadios.length > 0">
|
||||||
label-key="name"
|
<div class="mb-6 flex items-center gap-3">
|
||||||
value-key="id"
|
<h2 class="text-xl font-bold tracking-tight text-neutral-900 md:text-2xl dark:text-white">
|
||||||
@change="handleCategoryChange"
|
{{ t('podcast.mySubscriptions') }}
|
||||||
/>
|
</h2>
|
||||||
|
<div class="h-1.5 w-1.5 rounded-full bg-primary" />
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-6">
|
||||||
|
<radio-card
|
||||||
|
v-for="(radio, index) in subscribedRadios.slice(0, 10)"
|
||||||
|
:key="`sub-${radio.id}`"
|
||||||
|
:radio="radio"
|
||||||
|
:animation-delay="calculateAnimationDelay(index, 0.04)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Main Content Scrollbar -->
|
<!-- Today's Picks -->
|
||||||
<n-scrollbar ref="contentScrollbarRef" class="flex-1" :size="100" @scroll="handleScroll">
|
<section v-if="todayPerfered.length > 0">
|
||||||
<div class="podcast-content w-full pb-32 pt-6 page-padding">
|
<div class="mb-6 flex items-center justify-between">
|
||||||
<!-- Dashboard View (Recommend) -->
|
<div class="flex items-center gap-3">
|
||||||
<div v-if="currentCategoryId === -1">
|
<h2 class="text-xl font-bold tracking-tight text-neutral-900 md:text-2xl dark:text-white">
|
||||||
<!-- Hero Section -->
|
{{ t('podcast.todayPerfered') }}
|
||||||
<div class="mb-8 flex flex-col md:flex-row md:items-end justify-between gap-6">
|
</h2>
|
||||||
<div>
|
<div class="h-1.5 w-1.5 rounded-full bg-primary" />
|
||||||
<h1
|
</div>
|
||||||
class="text-3xl md:text-4xl font-bold tracking-tight text-neutral-900 dark:text-white mb-2"
|
<n-button type="primary" secondary round size="small" @click="handlePlayTodayPerfered">
|
||||||
|
<template #icon><i class="ri-play-circle-line"></i></template>
|
||||||
|
{{ t('search.button.playAll') }}
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div
|
||||||
|
v-for="(program, index) in todayPerfered.slice(0, 5)"
|
||||||
|
:key="`today-${program.id}`"
|
||||||
|
class="flex items-center gap-4 p-4 rounded-xl bg-neutral-50 dark:bg-neutral-900/50 hover:bg-neutral-100 dark:hover:bg-neutral-800/50 cursor-pointer group transition-all duration-300 animate-item"
|
||||||
|
:style="{ animationDelay: calculateAnimationDelay(index, 0.04) }"
|
||||||
|
@click="playProgram(program)"
|
||||||
|
>
|
||||||
|
<div class="relative flex-shrink-0 w-16 h-16 md:w-20 md:h-20">
|
||||||
|
<img
|
||||||
|
:src="getImgUrl(program.coverUrl, '100y100')"
|
||||||
|
:alt="program.mainSong?.name || program.name"
|
||||||
|
class="w-full h-full rounded-lg object-cover"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 bg-black/40 rounded-lg opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
|
||||||
>
|
>
|
||||||
{{ t('podcast.podcast') }}
|
<i class="ri-play-fill text-white text-2xl"></i>
|
||||||
</h1>
|
</div>
|
||||||
<p class="text-neutral-500 dark:text-neutral-400">
|
</div>
|
||||||
{{ t('podcast.discover') }}
|
<div class="flex-1 min-w-0">
|
||||||
|
<h4 class="text-sm md:text-base font-semibold text-neutral-900 dark:text-white truncate">
|
||||||
|
{{ program.mainSong?.name || program.name }}
|
||||||
|
</h4>
|
||||||
|
<p class="text-xs md:text-sm text-neutral-500 dark:text-neutral-400 truncate mt-1">
|
||||||
|
{{ program.description }}
|
||||||
</p>
|
</p>
|
||||||
|
<div class="flex items-center gap-3 text-xs text-neutral-400 mt-2">
|
||||||
|
<span>{{ formatDate(program.createTime) }}</span>
|
||||||
|
<span>{{ secondToMinute((program.mainSong?.duration || 0) / 1000) }}</span>
|
||||||
|
<span>{{ formatNumber(program.listenerCount) }} {{ t('podcast.listeners') }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Main Content Sections -->
|
<!-- Recommended -->
|
||||||
<div class="content-sections space-y-10 md:space-y-8 lg:space-y-12">
|
<section>
|
||||||
<!-- Recently Played Section -->
|
<div class="mb-6 flex items-center gap-3">
|
||||||
<section v-if="displayRecentPrograms.length > 0">
|
<h2 class="text-xl font-bold tracking-tight text-neutral-900 md:text-2xl dark:text-white">
|
||||||
<div class="mb-6 flex items-center justify-between">
|
{{ t('podcast.recommended') }}
|
||||||
<div class="flex items-center gap-3">
|
</h2>
|
||||||
<h2
|
<div class="h-1.5 w-1.5 rounded-full bg-primary" />
|
||||||
class="text-xl font-bold tracking-tight text-neutral-900 md:text-2xl dark:text-white"
|
</div>
|
||||||
>
|
<div v-if="recommendLoading" class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-6">
|
||||||
{{ t('podcast.recentPlayed') }}
|
<div v-for="i in 10" :key="`skeleton-${i}`" class="space-y-3">
|
||||||
</h2>
|
<div class="aspect-square skeleton-shimmer rounded-2xl" />
|
||||||
<div class="h-1.5 w-1.5 rounded-full bg-primary" />
|
<div class="h-4 w-3/4 skeleton-shimmer rounded-lg" />
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<n-button
|
|
||||||
v-if="!userStore.user"
|
|
||||||
text
|
|
||||||
class="text-xs text-neutral-400"
|
|
||||||
@click="clearLocalHistory"
|
|
||||||
>
|
|
||||||
{{ t('common.clear') }}
|
|
||||||
</n-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-6">
|
|
||||||
<radio-card
|
|
||||||
v-for="(program, index) in displayRecentPrograms"
|
|
||||||
:key="`recent-${program.id}`"
|
|
||||||
:radio="program.radio as any"
|
|
||||||
:program="program"
|
|
||||||
:animation-delay="calculateAnimationDelay(index, 0.04)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<!-- Today's Perfered Section -->
|
|
||||||
<section v-if="todayPerfered.length > 0">
|
|
||||||
<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"
|
|
||||||
>
|
|
||||||
{{ t('podcast.todayPerfered') }}
|
|
||||||
</h2>
|
|
||||||
<div class="h-1.5 w-1.5 rounded-full bg-primary" />
|
|
||||||
</div>
|
|
||||||
<n-button
|
|
||||||
v-if="todayPerfered.length > 0"
|
|
||||||
type="primary"
|
|
||||||
secondary
|
|
||||||
round
|
|
||||||
size="small"
|
|
||||||
@click="handlePlayTodayPerfered"
|
|
||||||
>
|
|
||||||
<template #icon>
|
|
||||||
<i class="ri-play-circle-line"></i>
|
|
||||||
</template>
|
|
||||||
{{ t('search.button.playAll') }}
|
|
||||||
</n-button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-3">
|
|
||||||
<div
|
|
||||||
v-for="(program, index) in todayPerfered.slice(0, 5)"
|
|
||||||
:key="`today-${program.id}`"
|
|
||||||
class="program-card flex items-center gap-4 p-4 rounded-xl bg-neutral-50 dark:bg-neutral-900/50 hover:bg-neutral-100 dark:hover:bg-neutral-800/50 cursor-pointer group transition-all duration-300 animate-item"
|
|
||||||
:style="{ animationDelay: calculateAnimationDelay(index, 0.04) }"
|
|
||||||
@click="playProgram(program)"
|
|
||||||
>
|
|
||||||
<div class="relative flex-shrink-0 w-16 h-16 md:w-20 md:h-20">
|
|
||||||
<img
|
|
||||||
:src="getImgUrl(program.coverUrl, '100y100')"
|
|
||||||
:alt="program.mainSong?.name || program.name"
|
|
||||||
class="w-full h-full rounded-lg object-cover"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
class="absolute inset-0 bg-black/40 rounded-lg opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
|
|
||||||
>
|
|
||||||
<i class="ri-play-fill text-white text-2xl"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<h4
|
|
||||||
class="text-sm md:text-base font-semibold text-neutral-900 dark:text-white truncate"
|
|
||||||
>
|
|
||||||
{{ program.mainSong?.name || program.name }}
|
|
||||||
</h4>
|
|
||||||
<p
|
|
||||||
class="text-xs md:text-sm text-neutral-500 dark:text-neutral-400 truncate mt-1"
|
|
||||||
>
|
|
||||||
{{ program.description }}
|
|
||||||
</p>
|
|
||||||
<div class="flex items-center gap-3 text-xs text-neutral-400 mt-2">
|
|
||||||
<span>{{ formatDate(program.createTime) }}</span>
|
|
||||||
<span>{{ secondToMinute((program.mainSong?.duration || 0) / 1000) }}</span>
|
|
||||||
<span
|
|
||||||
>{{ formatNumber(program.listenerCount) }}
|
|
||||||
{{ t('podcast.listeners') }}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- My Subscriptions Section -->
|
|
||||||
<section v-if="userStore.user">
|
|
||||||
<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"
|
|
||||||
>
|
|
||||||
{{ t('podcast.mySubscriptions') }}
|
|
||||||
</h2>
|
|
||||||
<div class="h-1.5 w-1.5 rounded-full bg-primary" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Empty State -->
|
|
||||||
<div
|
|
||||||
v-if="subscribedRadios.length === 0"
|
|
||||||
class="flex flex-col items-center justify-center py-20 text-neutral-400"
|
|
||||||
>
|
|
||||||
<i class="ri-radio-line mb-4 text-5xl opacity-20" />
|
|
||||||
<p class="text-sm font-medium mb-4">{{ t('podcast.noSubscriptions') }}</p>
|
|
||||||
<n-button type="primary" @click="scrollToRecommended">
|
|
||||||
{{ t('podcast.goDiscover') }}
|
|
||||||
</n-button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Subscribed Radios Grid -->
|
|
||||||
<div v-else class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-6">
|
|
||||||
<radio-card
|
|
||||||
v-for="(radio, index) in subscribedRadios.slice(0, 10)"
|
|
||||||
:key="`sub-${radio.id}`"
|
|
||||||
:radio="radio"
|
|
||||||
:show-subscribe-button="true"
|
|
||||||
:is-subscribed="isRadioSubscribed(radio.id)"
|
|
||||||
:animation-delay="calculateAnimationDelay(index, 0.04)"
|
|
||||||
@subscribe="handleSubscribe"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Recommended Radios Section -->
|
|
||||||
<section ref="recommendedSection">
|
|
||||||
<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"
|
|
||||||
>
|
|
||||||
{{ t('podcast.recommended') }}
|
|
||||||
</h2>
|
|
||||||
<div class="h-1.5 w-1.5 rounded-full bg-primary" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="recommendLoading"
|
|
||||||
class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-6"
|
|
||||||
>
|
|
||||||
<div v-for="i in 10" :key="`skeleton-${i}`" class="space-y-3">
|
|
||||||
<div class="aspect-square skeleton-shimmer rounded-2xl" />
|
|
||||||
<div class="h-4 w-3/4 skeleton-shimmer rounded-lg" />
|
|
||||||
<div class="h-3 w-1/2 skeleton-shimmer rounded-lg" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-6">
|
|
||||||
<radio-card
|
|
||||||
v-for="(radio, index) in recommendRadios.slice(0, 10)"
|
|
||||||
:key="`recommend-${radio.id}`"
|
|
||||||
:radio="radio"
|
|
||||||
:show-subscribe-button="true"
|
|
||||||
:is-subscribed="isRadioSubscribed(radio.id)"
|
|
||||||
:animation-delay="calculateAnimationDelay(index, 0.04)"
|
|
||||||
@subscribe="handleSubscribe"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-6">
|
||||||
<!-- Category View -->
|
<radio-card
|
||||||
<div v-else>
|
v-for="(radio, index) in recommendRadios.slice(0, 10)"
|
||||||
<!-- Hero Section -->
|
:key="`recommend-${radio.id}`"
|
||||||
<div class="mb-8">
|
:radio="radio"
|
||||||
<h1
|
:animation-delay="calculateAnimationDelay(index, 0.04)"
|
||||||
class="text-3xl md:text-4xl font-bold tracking-tight text-neutral-900 dark:text-white mb-2"
|
/>
|
||||||
>
|
|
||||||
{{ currentCategoryName }}
|
|
||||||
</h1>
|
|
||||||
<p class="text-neutral-500 dark:text-neutral-400">
|
|
||||||
{{ t('podcast.exploreCategoryRadios') }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Radios Grid -->
|
|
||||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-6">
|
|
||||||
<template v-if="categoryLoading && categoryPage === 0">
|
|
||||||
<div v-for="i in 15" :key="`loading-${i}`" class="space-y-3">
|
|
||||||
<div class="aspect-square skeleton-shimmer rounded-2xl" />
|
|
||||||
<div class="h-4 w-3/4 skeleton-shimmer rounded-lg" />
|
|
||||||
<div class="h-3 w-1/2 skeleton-shimmer rounded-lg" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<radio-card
|
|
||||||
v-for="(radio, index) in categoryRadios"
|
|
||||||
:key="`cat-${radio.id}`"
|
|
||||||
:radio="radio"
|
|
||||||
:show-subscribe-button="true"
|
|
||||||
:is-subscribed="isRadioSubscribed(radio.id)"
|
|
||||||
:animation-delay="calculateAnimationDelay(index % 30, 0.04)"
|
|
||||||
@subscribe="handleSubscribe"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Empty State -->
|
|
||||||
<div
|
|
||||||
v-if="!categoryLoading && categoryRadios.length === 0"
|
|
||||||
class="flex flex-col items-center justify-center py-20 text-neutral-400"
|
|
||||||
>
|
|
||||||
<i class="ri-radio-line mb-4 text-5xl opacity-20" />
|
|
||||||
<p class="text-sm font-medium">{{ t('podcast.noCategoryRadios') }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Load More Spinner -->
|
|
||||||
<div v-if="categoryLoadingMore" class="flex justify-center items-center py-8">
|
|
||||||
<n-spin size="small" />
|
|
||||||
<span class="ml-2 text-neutral-500">{{ t('common.loading') }}</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="!categoryHasMore && categoryRadios.length > 0"
|
|
||||||
class="text-center py-8 text-neutral-500"
|
|
||||||
>
|
|
||||||
{{ t('common.noMore') }}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Category View -->
|
||||||
|
<div v-else>
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-6">
|
||||||
|
<template v-if="categoryLoading && categoryPage === 0">
|
||||||
|
<div v-for="i in 15" :key="`loading-${i}`" class="space-y-3">
|
||||||
|
<div class="aspect-square skeleton-shimmer rounded-2xl" />
|
||||||
|
<div class="h-4 w-3/4 skeleton-shimmer rounded-lg" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<radio-card
|
||||||
|
v-for="(radio, index) in categoryRadios"
|
||||||
|
:key="`cat-${radio.id}`"
|
||||||
|
:radio="radio"
|
||||||
|
:animation-delay="calculateAnimationDelay(index % 30, 0.04)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</n-scrollbar>
|
|
||||||
</div>
|
<div v-if="!categoryLoading && categoryRadios.length === 0" class="flex flex-col items-center justify-center py-20 text-neutral-400">
|
||||||
|
<i class="ri-radio-line mb-4 text-5xl opacity-20" />
|
||||||
|
<p class="text-sm font-medium">{{ t('podcast.noCategoryRadios') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="categoryLoadingMore" class="flex justify-center items-center py-8">
|
||||||
|
<n-spin size="small" />
|
||||||
|
<span class="ml-2 text-neutral-500">{{ t('common.loading') }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="!categoryHasMore && categoryRadios.length > 0" class="text-center py-8 text-neutral-500">
|
||||||
|
{{ t('common.noMore') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sticky-tab-page>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -288,42 +152,31 @@ import {
|
|||||||
getDjRadioHot,
|
getDjRadioHot,
|
||||||
getDjRecommend,
|
getDjRecommend,
|
||||||
getDjSublist,
|
getDjSublist,
|
||||||
getDjTodayPerfered,
|
getDjTodayPerfered
|
||||||
getRecentDj,
|
|
||||||
subscribeDj
|
|
||||||
} from '@/api/podcast';
|
} from '@/api/podcast';
|
||||||
import CategorySelector from '@/components/common/CategorySelector.vue';
|
import StickyTabPage from '@/components/common/StickyTabPage.vue';
|
||||||
import RadioCard from '@/components/podcast/RadioCard.vue';
|
import RadioCard from '@/components/podcast/RadioCard.vue';
|
||||||
import { usePlayerStore, usePlaylistStore, useUserStore } from '@/store';
|
import { usePlayerStore, usePlaylistStore, useUserStore } from '@/store';
|
||||||
import { usePlayHistoryStore } from '@/store/modules/playHistory';
|
|
||||||
import type { DjCategory, DjProgram, DjRadio } from '@/types/podcast';
|
import type { DjCategory, DjProgram, DjRadio } from '@/types/podcast';
|
||||||
import { calculateAnimationDelay, formatNumber, getImgUrl, secondToMinute } from '@/utils';
|
import { calculateAnimationDelay, formatNumber, getImgUrl, secondToMinute } from '@/utils';
|
||||||
import { mapDjProgramToSongResult } from '@/utils/podcastUtils';
|
import { mapDjProgramToSongResult } from '@/utils/podcastUtils';
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({ name: 'Podcast' });
|
||||||
name: 'Podcast'
|
|
||||||
});
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { message } = createDiscreteApi(['message']);
|
const { message } = createDiscreteApi(['message']);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|
||||||
const playlistStore = usePlaylistStore();
|
const playlistStore = usePlaylistStore();
|
||||||
const playerStore = usePlayerStore();
|
const playerStore = usePlayerStore();
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
const playHistoryStore = usePlayHistoryStore();
|
const pageRef = ref();
|
||||||
|
|
||||||
const contentScrollbarRef = ref();
|
|
||||||
const recommendedSection = ref<HTMLElement | null>(null);
|
|
||||||
|
|
||||||
const currentCategoryId = ref(-1);
|
const currentCategoryId = ref(-1);
|
||||||
|
|
||||||
const categories = ref<DjCategory[]>([]);
|
const categories = ref<DjCategory[]>([]);
|
||||||
const recommendRadios = ref<DjRadio[]>([]);
|
const recommendRadios = ref<DjRadio[]>([]);
|
||||||
const todayPerfered = ref<DjProgram[]>([]);
|
const todayPerfered = ref<DjProgram[]>([]);
|
||||||
const subscribedRadios = ref<DjRadio[]>([]);
|
const subscribedRadios = ref<DjRadio[]>([]);
|
||||||
const recentPrograms = ref<DjProgram[]>([]);
|
|
||||||
const recommendLoading = ref(false);
|
const recommendLoading = ref(false);
|
||||||
|
|
||||||
const categoryRadios = ref<DjRadio[]>([]);
|
const categoryRadios = ref<DjRadio[]>([]);
|
||||||
@@ -333,53 +186,26 @@ const categoryPage = ref(0);
|
|||||||
const categoryLimit = ref(30);
|
const categoryLimit = ref(30);
|
||||||
const categoryHasMore = ref(true);
|
const categoryHasMore = ref(true);
|
||||||
|
|
||||||
const categoryList = computed(() => {
|
const categoryList = computed(() => [{ id: -1, name: t('podcast.discover') }, ...categories.value]);
|
||||||
return [{ id: -1, name: t('podcast.discover') }, ...categories.value];
|
|
||||||
});
|
|
||||||
|
|
||||||
const currentCategoryName = computed(() => {
|
const currentCategoryName = computed(() => {
|
||||||
if (currentCategoryId.value === -1) return t('podcast.recommended');
|
if (currentCategoryId.value === -1) return t('podcast.recommended');
|
||||||
return categories.value.find((c) => c.id === currentCategoryId.value)?.name || '';
|
return categories.value.find((c) => c.id === currentCategoryId.value)?.name || '';
|
||||||
});
|
});
|
||||||
|
|
||||||
const displayRecentPrograms = computed(() => {
|
|
||||||
if (userStore.user) {
|
|
||||||
return recentPrograms.value.slice(0, 5);
|
|
||||||
}
|
|
||||||
return playHistoryStore.podcastHistory.slice(0, 5);
|
|
||||||
});
|
|
||||||
|
|
||||||
const subscribedIdSet = computed(() => new Set(subscribedRadios.value.map((radio) => radio.id)));
|
|
||||||
|
|
||||||
const isRadioSubscribed = (id: number) => subscribedIdSet.value.has(id);
|
|
||||||
|
|
||||||
const formatDate = (timestamp: number): string => {
|
const formatDate = (timestamp: number): string => {
|
||||||
const date = new Date(timestamp);
|
const date = new Date(timestamp);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const diff = now.getTime() - date.getTime();
|
const diff = now.getTime() - date.getTime();
|
||||||
|
|
||||||
if (diff < 86400000) {
|
if (diff < 86400000) {
|
||||||
const hours = Math.floor(diff / 3600000);
|
const hours = Math.floor(diff / 3600000);
|
||||||
if (hours < 1) {
|
if (hours < 1) return `${Math.floor(diff / 60000)}分钟前`;
|
||||||
const minutes = Math.floor(diff / 60000);
|
|
||||||
return `${minutes}分钟前`;
|
|
||||||
}
|
|
||||||
return `${hours}小时前`;
|
return `${hours}小时前`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${date.getMonth() + 1}月${date.getDate()}日`;
|
return `${date.getMonth() + 1}月${date.getDate()}日`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCategoryChange = (id: number) => {
|
const handleCategoryChange = (id: number) => {
|
||||||
router.replace({
|
router.replace({ query: { ...route.query, category: id === -1 ? undefined : String(id) } });
|
||||||
query: { ...route.query, category: id === -1 ? undefined : String(id) }
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetCategoryState = () => {
|
|
||||||
categoryRadios.value = [];
|
|
||||||
categoryPage.value = 0;
|
|
||||||
categoryHasMore.value = true;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadCategoryRadios = async (id: number, loadMore = false) => {
|
const loadCategoryRadios = async (id: number, loadMore = false) => {
|
||||||
@@ -393,20 +219,14 @@ const loadCategoryRadios = async (id: number, loadMore = false) => {
|
|||||||
categoryRadios.value = [];
|
categoryRadios.value = [];
|
||||||
categoryHasMore.value = true;
|
categoryHasMore.value = true;
|
||||||
await nextTick();
|
await nextTick();
|
||||||
contentScrollbarRef.value?.scrollTo({ top: 0 });
|
pageRef.value?.scrollTo({ top: 0 });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const offset = categoryPage.value * categoryLimit.value;
|
const offset = categoryPage.value * categoryLimit.value;
|
||||||
const res = await getDjRadioHot(id, categoryLimit.value, offset);
|
const res = await getDjRadioHot(id, categoryLimit.value, offset);
|
||||||
const radios = res.data?.djRadios || [];
|
const radios = res.data?.djRadios || [];
|
||||||
|
if (loadMore) categoryRadios.value.push(...radios);
|
||||||
if (loadMore) {
|
else categoryRadios.value = radios;
|
||||||
categoryRadios.value.push(...radios);
|
|
||||||
} else {
|
|
||||||
categoryRadios.value = radios;
|
|
||||||
}
|
|
||||||
|
|
||||||
categoryHasMore.value = radios.length === categoryLimit.value;
|
categoryHasMore.value = radios.length === categoryLimit.value;
|
||||||
categoryPage.value++;
|
categoryPage.value++;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -418,47 +238,14 @@ const loadCategoryRadios = async (id: number, loadMore = false) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleScroll = (e: Event) => {
|
const handleScroll = (e: any) => {
|
||||||
if (currentCategoryId.value === -1) return;
|
if (currentCategoryId.value === -1) return;
|
||||||
const target = e.target as Element;
|
const { scrollTop, clientHeight, scrollHeight } = e.target;
|
||||||
const { scrollTop, clientHeight, scrollHeight } = target;
|
if (scrollHeight - (scrollTop + clientHeight) < 150) {
|
||||||
const threshold = 150;
|
|
||||||
|
|
||||||
if (scrollHeight - (scrollTop + clientHeight) < threshold) {
|
|
||||||
loadCategoryRadios(currentCategoryId.value, true);
|
loadCategoryRadios(currentCategoryId.value, true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const scrollToRecommended = async () => {
|
|
||||||
await nextTick();
|
|
||||||
recommendedSection.value?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearLocalHistory = () => {
|
|
||||||
playHistoryStore.clearPodcastHistory();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubscribe = async (radio: DjRadio) => {
|
|
||||||
if (!userStore.user) {
|
|
||||||
message.warning(t('history.needLogin'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isSubed = isRadioSubscribed(radio.id);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await subscribeDj(radio.id, isSubed ? 0 : 1);
|
|
||||||
if (radio.subCount !== undefined) {
|
|
||||||
radio.subCount = Math.max(0, radio.subCount + (isSubed ? -1 : 1));
|
|
||||||
}
|
|
||||||
await loadSubscribedRadios();
|
|
||||||
message.success(isSubed ? '已取消订阅' : '订阅成功');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('订阅操作失败:', error);
|
|
||||||
message.error(isSubed ? '取消订阅失败' : '订阅失败');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const playProgram = async (program: DjProgram) => {
|
const playProgram = async (program: DjProgram) => {
|
||||||
const song = mapDjProgramToSongResult(program);
|
const song = mapDjProgramToSongResult(program);
|
||||||
playlistStore.setPlayList([song]);
|
playlistStore.setPlayList([song]);
|
||||||
@@ -467,11 +254,13 @@ const playProgram = async (program: DjProgram) => {
|
|||||||
|
|
||||||
const handlePlayTodayPerfered = async () => {
|
const handlePlayTodayPerfered = async () => {
|
||||||
if (todayPerfered.value.length === 0) return;
|
if (todayPerfered.value.length === 0) return;
|
||||||
const songList = todayPerfered.value.map((program) => mapDjProgramToSongResult(program));
|
const songList = todayPerfered.value.map(mapDjProgramToSongResult);
|
||||||
playlistStore.setPlayList(songList);
|
playlistStore.setPlayList(songList);
|
||||||
await playerStore.setPlay(songList[0]);
|
await playerStore.setPlay(songList[0]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ==================== Data loading ====================
|
||||||
|
|
||||||
const loadCategories = async () => {
|
const loadCategories = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await getDjCategoryList();
|
const res = await getDjCategoryList();
|
||||||
@@ -512,23 +301,20 @@ const loadSubscribedRadios = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadRecentPrograms = async () => {
|
|
||||||
if (!userStore.user) return;
|
|
||||||
try {
|
|
||||||
const res = await getRecentDj();
|
|
||||||
recentPrograms.value = res.data?.data?.list || [];
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取最近播放失败:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadDashboard = async () => {
|
const loadDashboard = async () => {
|
||||||
await Promise.all([loadCategories(), loadRecommendRadios(), loadTodayPerfered()]);
|
await Promise.all([
|
||||||
|
loadCategories(),
|
||||||
|
loadRecommendRadios(),
|
||||||
|
loadTodayPerfered(),
|
||||||
|
loadSubscribedRadios()
|
||||||
|
]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadData = async (categoryId: number) => {
|
const loadData = async (categoryId: number) => {
|
||||||
if (categoryId === -1) {
|
if (categoryId === -1) {
|
||||||
resetCategoryState();
|
categoryRadios.value = [];
|
||||||
|
categoryPage.value = 0;
|
||||||
|
categoryHasMore.value = true;
|
||||||
await loadDashboard();
|
await loadDashboard();
|
||||||
} else {
|
} else {
|
||||||
await loadCategoryRadios(categoryId);
|
await loadCategoryRadios(categoryId);
|
||||||
@@ -540,7 +326,6 @@ watch(
|
|||||||
async (newCategory) => {
|
async (newCategory) => {
|
||||||
if (route.path !== '/podcast') return;
|
if (route.path !== '/podcast') return;
|
||||||
const newId = newCategory ? Number(newCategory) : -1;
|
const newId = newCategory ? Number(newCategory) : -1;
|
||||||
|
|
||||||
if (newId !== currentCategoryId.value) {
|
if (newId !== currentCategoryId.value) {
|
||||||
currentCategoryId.value = newId;
|
currentCategoryId.value = newId;
|
||||||
await loadData(newId);
|
await loadData(newId);
|
||||||
@@ -552,10 +337,9 @@ watch(
|
|||||||
() => userStore.user,
|
() => userStore.user,
|
||||||
async (user) => {
|
async (user) => {
|
||||||
if (user) {
|
if (user) {
|
||||||
await Promise.all([loadSubscribedRadios(), loadRecentPrograms()]);
|
await loadSubscribedRadios();
|
||||||
} else {
|
} else {
|
||||||
subscribedRadios.value = [];
|
subscribedRadios.value = [];
|
||||||
recentPrograms.value = [];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user