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:
alger
2026-03-16 23:22:35 +08:00
parent 68b3700f3f
commit a3f91c45f0
17 changed files with 1184 additions and 1130 deletions

View File

@@ -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',

View File

@@ -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: '再生履歴',

View File

@@ -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: '재생 기록',

View File

@@ -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: '播放历史',

View File

@@ -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: '播放歷史',

View File

@@ -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)"

View 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>

View File

@@ -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>

View File

@@ -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);

View File

@@ -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')
}, },

View File

@@ -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;
} }

View File

@@ -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

View File

@@ -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);
}
})
);
} }
}; };

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 = [];
} }
} }
); );