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'
|
||||
},
|
||||
newAlbum: {
|
||||
title: 'New Albums',
|
||||
title: 'Albums',
|
||||
empty: 'No new albums'
|
||||
},
|
||||
recommendNewMusic: {
|
||||
@@ -153,6 +153,23 @@ export default {
|
||||
toplistDesc: 'Trending now',
|
||||
mvDesc: 'Music videos',
|
||||
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: {
|
||||
myFavorite: 'My Favorites',
|
||||
playHistory: 'History',
|
||||
|
||||
@@ -127,7 +127,7 @@ export default {
|
||||
title: 'おすすめMV'
|
||||
},
|
||||
newAlbum: {
|
||||
title: '新着アルバム',
|
||||
title: 'アルバム',
|
||||
empty: '新しいアルバムがありません'
|
||||
},
|
||||
recommendNewMusic: {
|
||||
@@ -153,6 +153,23 @@ export default {
|
||||
toplistDesc: 'トレンド',
|
||||
mvDesc: 'ミュージックビデオ',
|
||||
playlistDesc: '厳選プレイリスト',
|
||||
personalFm: 'パーソナルFM',
|
||||
discoverMusic: '新しい音楽を発見',
|
||||
personalFmDesc: 'あなたの好みに基づいて',
|
||||
recentPlays: '最近再生した曲',
|
||||
viewAll: 'すべて表示',
|
||||
followedArtists: 'フォロー中',
|
||||
newSongs: '曲の新曲',
|
||||
fromFollowedArtists: 'フォロー中のアーティストから',
|
||||
recommendNewMusic: 'おすすめ新曲',
|
||||
newSongExpress: '新曲速報',
|
||||
discoverNewReleases: '最新リリースを見つけよう',
|
||||
hotPlaylists: '人気プレイリスト',
|
||||
hotArtists: '人気アーティスト',
|
||||
hotArtistsTitle: '人気アーティスト',
|
||||
hotArtistsDesc: '今最も人気のあるアーティスト',
|
||||
fmTrash: '嫌い',
|
||||
fmNext: '次へ',
|
||||
quickNav: {
|
||||
myFavorite: 'お気に入り',
|
||||
playHistory: '再生履歴',
|
||||
|
||||
@@ -126,7 +126,7 @@ export default {
|
||||
title: '추천 MV'
|
||||
},
|
||||
newAlbum: {
|
||||
title: '신곡 앨범',
|
||||
title: '앨범',
|
||||
empty: '새 앨범이 없습니다'
|
||||
},
|
||||
recommendNewMusic: {
|
||||
@@ -152,6 +152,23 @@ export default {
|
||||
toplistDesc: '인기 차트',
|
||||
mvDesc: '뮤직비디오',
|
||||
playlistDesc: '엄선된 플레이리스트',
|
||||
personalFm: '개인 FM',
|
||||
discoverMusic: '새로운 음악 발견',
|
||||
personalFmDesc: '취향에 맞춘 추천',
|
||||
recentPlays: '최근 재생',
|
||||
viewAll: '전체 보기',
|
||||
followedArtists: '팔로우 아티스트',
|
||||
newSongs: '곡의 신곡',
|
||||
fromFollowedArtists: '팔로우한 아티스트의 신곡',
|
||||
recommendNewMusic: '추천 신곡',
|
||||
newSongExpress: '신곡 속보',
|
||||
discoverNewReleases: '최신 발매 곡을 발견하세요',
|
||||
hotPlaylists: '인기 플레이리스트',
|
||||
hotArtists: '인기 아티스트',
|
||||
hotArtistsTitle: '인기 아티스트',
|
||||
hotArtistsDesc: '지금 가장 인기 있는 아티스트',
|
||||
fmTrash: '싫어요',
|
||||
fmNext: '다음',
|
||||
quickNav: {
|
||||
myFavorite: '내 즐겨찾기',
|
||||
playHistory: '재생 기록',
|
||||
|
||||
@@ -120,7 +120,7 @@ export default {
|
||||
title: '推荐MV'
|
||||
},
|
||||
newAlbum: {
|
||||
title: '新碟上架',
|
||||
title: '专辑',
|
||||
empty: '暂无新专辑'
|
||||
},
|
||||
recommendNewMusic: {
|
||||
@@ -146,6 +146,23 @@ export default {
|
||||
toplistDesc: '热门榜单',
|
||||
mvDesc: '音乐视频',
|
||||
playlistDesc: '精选歌单',
|
||||
personalFm: '私人FM',
|
||||
discoverMusic: '发现新音乐',
|
||||
personalFmDesc: '根据你的喜好推荐',
|
||||
recentPlays: '最近播放',
|
||||
viewAll: '查看全部',
|
||||
followedArtists: '关注歌手',
|
||||
newSongs: '首新歌',
|
||||
fromFollowedArtists: '来自你关注的歌手',
|
||||
recommendNewMusic: '推荐新音乐',
|
||||
newSongExpress: '新歌速递',
|
||||
discoverNewReleases: '发现最新发行的好歌',
|
||||
hotPlaylists: '精选歌单',
|
||||
hotArtists: '热门歌手',
|
||||
hotArtistsTitle: '热门艺人',
|
||||
hotArtistsDesc: '当下最受欢迎的歌手',
|
||||
fmTrash: '不喜欢',
|
||||
fmNext: '下一首',
|
||||
quickNav: {
|
||||
myFavorite: '我的收藏',
|
||||
playHistory: '播放历史',
|
||||
|
||||
@@ -120,7 +120,7 @@ export default {
|
||||
title: '推薦MV'
|
||||
},
|
||||
newAlbum: {
|
||||
title: '新碟上架',
|
||||
title: '專輯',
|
||||
empty: '暫無新專輯'
|
||||
},
|
||||
recommendNewMusic: {
|
||||
@@ -146,6 +146,23 @@ export default {
|
||||
toplistDesc: '熱門榜單',
|
||||
mvDesc: '音樂視訊',
|
||||
playlistDesc: '精選播放清單',
|
||||
personalFm: '私人FM',
|
||||
discoverMusic: '發現新音樂',
|
||||
personalFmDesc: '根據你的喜好推薦',
|
||||
recentPlays: '最近播放',
|
||||
viewAll: '查看全部',
|
||||
followedArtists: '關注歌手',
|
||||
newSongs: '首新歌',
|
||||
fromFollowedArtists: '來自你關注的歌手',
|
||||
recommendNewMusic: '推薦新音樂',
|
||||
newSongExpress: '新歌速遞',
|
||||
discoverNewReleases: '發現最新發行的好歌',
|
||||
hotPlaylists: '精選歌單',
|
||||
hotArtists: '熱門歌手',
|
||||
hotArtistsTitle: '熱門藝人',
|
||||
hotArtistsDesc: '當下最受歡迎的歌手',
|
||||
fmTrash: '不喜歡',
|
||||
fmNext: '下一首',
|
||||
quickNav: {
|
||||
myFavorite: '我的收藏',
|
||||
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="[
|
||||
animationClass,
|
||||
index === 0 ? 'ml-0.5' : '',
|
||||
isActive(category) ? 'bg-primary text-white shadow-lg shadow-primary/25 scale-105' : ''
|
||||
]"
|
||||
: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">
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import type { DjProgram, DjRadio } from '@/types/podcast';
|
||||
@@ -8,90 +6,79 @@ import { formatNumber, getImgUrl } from '@/utils';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
radio: DjRadio;
|
||||
radio?: DjRadio;
|
||||
program?: DjProgram;
|
||||
showSubscribeButton?: boolean;
|
||||
isSubscribed?: boolean;
|
||||
animationDelay?: string;
|
||||
}>(),
|
||||
{
|
||||
showSubscribeButton: false,
|
||||
isSubscribed: false
|
||||
}
|
||||
{}
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
subscribe: [radio: DjRadio];
|
||||
}>();
|
||||
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
|
||||
const isSubscribed = computed(() => props.isSubscribed);
|
||||
|
||||
const handleSubscribe = (e: Event) => {
|
||||
e.stopPropagation();
|
||||
emit('subscribe', props.radio);
|
||||
};
|
||||
|
||||
const goToDetail = () => {
|
||||
router.push(`/podcast/radio/${props.radio.id}`);
|
||||
if (props.radio?.id) {
|
||||
router.push(`/podcast/radio/${props.radio.id}`);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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 }"
|
||||
@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
|
||||
:src="getImgUrl(radio.picUrl || program?.coverUrl || '', '200y200')"
|
||||
:alt="radio.name"
|
||||
class="w-full aspect-square object-cover group-hover:scale-105 transition-transform duration-500"
|
||||
:src="getImgUrl(radio?.picUrl || program?.coverUrl || '', '400y400')"
|
||||
:alt="radio?.name || ''"
|
||||
class="h-full w-full object-cover transition-transform duration-700 group-hover:scale-110"
|
||||
loading="lazy"
|
||||
/>
|
||||
<!-- Hover overlay -->
|
||||
<div
|
||||
v-if="showSubscribeButton && radio.subCount !== undefined"
|
||||
class="absolute top-2 right-2 z-10"
|
||||
class="absolute inset-0 bg-transparent group-hover:bg-black/20 transition-colors duration-300 flex items-center justify-center"
|
||||
>
|
||||
<n-button
|
||||
:type="isSubscribed ? 'default' : 'primary'"
|
||||
size="small"
|
||||
round
|
||||
@click="handleSubscribe"
|
||||
<div
|
||||
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"
|
||||
>
|
||||
{{ isSubscribed ? t('podcast.subscribed') : t('podcast.subscribe') }}
|
||||
</n-button>
|
||||
<i class="ri-play-fill text-2xl text-neutral-900 ml-0.5"></i>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Recent played badge -->
|
||||
<div
|
||||
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>
|
||||
|
||||
<h3
|
||||
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"
|
||||
:title="radio.name"
|
||||
>
|
||||
{{ radio.name }}
|
||||
</h3>
|
||||
|
||||
<p
|
||||
v-if="radio.desc"
|
||||
class="mt-1 text-xs md:text-sm text-neutral-500 dark:text-neutral-400 line-clamp-2"
|
||||
>
|
||||
{{ radio.desc }}
|
||||
</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>
|
||||
<!-- 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"
|
||||
:title="radio?.name || ''"
|
||||
>
|
||||
{{ radio?.name || program?.name || '' }}
|
||||
</h3>
|
||||
<p
|
||||
v-if="radio?.subCount !== undefined"
|
||||
class="text-xs text-neutral-500 dark:text-neutral-400"
|
||||
>
|
||||
{{ formatNumber(radio?.subCount || 0) }} subscribers
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -66,8 +66,9 @@
|
||||
trigger="hover"
|
||||
:options="searchTypeOptions"
|
||||
@select="selectSearchType"
|
||||
@mousedown.prevent
|
||||
>
|
||||
<div class="type-chip">
|
||||
<div class="type-chip" @mousedown.prevent>
|
||||
<span>{{
|
||||
searchTypeOptions.find((i) => i.key === searchStore.searchType)?.label
|
||||
}}</span>
|
||||
@@ -238,21 +239,28 @@ const showBackButton = computed(() => {
|
||||
const goBack = () => router.back();
|
||||
|
||||
// ── Tabs ──────────────────────────────────────────────
|
||||
const tabs = computed(() => [
|
||||
{
|
||||
key: 'playlist',
|
||||
label: t('comp.searchBar.tabPlaylist'),
|
||||
path: '/',
|
||||
icon: 'ri-play-list-2-fill'
|
||||
},
|
||||
{ key: 'mv', label: t('comp.searchBar.tabMv'), path: '/mv', icon: 'ri-movie-2-fill' },
|
||||
{
|
||||
key: 'charts',
|
||||
label: t('comp.searchBar.tabCharts'),
|
||||
path: '/toplist',
|
||||
icon: 'ri-bar-chart-grouped-fill'
|
||||
}
|
||||
]);
|
||||
const tabs = computed(() => {
|
||||
const items = [
|
||||
{ key: 'home', label: t('comp.home'), path: '/', icon: 'ri-home-4-fill' },
|
||||
{ key: 'playlist', label: t('comp.list'), path: '/list', icon: 'ri-play-list-2-fill' },
|
||||
{ key: 'album', label: t('comp.newAlbum.title'), path: '/album', icon: 'ri-album-fill' },
|
||||
{
|
||||
key: 'charts',
|
||||
label: t('comp.toplist'),
|
||||
path: '/toplist',
|
||||
icon: 'ri-bar-chart-grouped-fill'
|
||||
},
|
||||
{ key: 'mv', label: t('comp.mv'), path: '/mv', icon: 'ri-movie-2-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;
|
||||
|
||||
// Sliding pill
|
||||
@@ -323,6 +331,7 @@ const selectSearchType = (key: number) => {
|
||||
searchStore.searchType = key;
|
||||
if (searchValue.value)
|
||||
router.push({ path: '/search-result', query: { keyword: searchValue.value, type: key } });
|
||||
nextTick(() => inputRef.value?.focus());
|
||||
};
|
||||
|
||||
const rawSearchTypes = ref(SEARCH_TYPES);
|
||||
|
||||
@@ -62,7 +62,8 @@ const layoutRouter = [
|
||||
icon: 'icon-recordfill',
|
||||
keepAlive: true,
|
||||
isMobile: false,
|
||||
back: true
|
||||
back: true,
|
||||
hideInSidebar: true
|
||||
},
|
||||
component: () => import('@/views/mv/index.vue')
|
||||
},
|
||||
@@ -97,7 +98,8 @@ const layoutRouter = [
|
||||
icon: 'ri-folder-music-fill',
|
||||
keepAlive: true,
|
||||
isMobile: false,
|
||||
electronOnly: true
|
||||
electronOnly: true,
|
||||
hideInSidebar: true
|
||||
},
|
||||
component: () => import('@/views/local-music/index.vue')
|
||||
},
|
||||
|
||||
@@ -14,6 +14,9 @@ export const useMenuStore = defineStore('menu', () => {
|
||||
if (item.meta?.electronOnly && !isElectron) {
|
||||
return false;
|
||||
}
|
||||
if (item.meta?.hideInSidebar) {
|
||||
return false;
|
||||
}
|
||||
if (settingsStore.isMobile) {
|
||||
return item.meta?.isMobile !== false;
|
||||
}
|
||||
|
||||
@@ -1,113 +1,95 @@
|
||||
<template>
|
||||
<div class="list-page h-full w-full bg-white dark:bg-black transition-colors duration-500">
|
||||
<!-- 专辑地区分类 - 保持固定在顶部 -->
|
||||
<category-selector
|
||||
:model-value="currentArea"
|
||||
:categories="areas"
|
||||
label-key="name"
|
||||
value-key="value"
|
||||
@change="handleAreaChange"
|
||||
/>
|
||||
|
||||
<!-- 专辑列表 -->
|
||||
<n-scrollbar
|
||||
ref="contentScrollbarRef"
|
||||
class="h-full"
|
||||
style="height: calc(100% - 73px)"
|
||||
:size="100"
|
||||
@scroll="handleScroll"
|
||||
>
|
||||
<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>
|
||||
<sticky-tab-page
|
||||
ref="pageRef"
|
||||
:title="t('comp.newAlbum.title')"
|
||||
:description="currentAreaName"
|
||||
:model-value="currentArea"
|
||||
:categories="areas"
|
||||
label-key="name"
|
||||
value-key="value"
|
||||
@change="handleAreaChange"
|
||||
@scroll="handleScroll"
|
||||
>
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-6">
|
||||
<!-- Loading State -->
|
||||
<template v-if="loading && page === 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>
|
||||
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-6">
|
||||
<!-- Loading State -->
|
||||
<template v-if="loading && page === 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>
|
||||
<!-- Content State -->
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="(album, index) in albumList"
|
||||
: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
|
||||
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 -->
|
||||
<template v-else>
|
||||
<!-- Play Overlay -->
|
||||
<div
|
||||
v-for="(album, index) in albumList"
|
||||
:key="album.id"
|
||||
class="list-card group cursor-pointer animate-item"
|
||||
:style="{ animationDelay: calculateAnimationDelay(index % TOTAL_ITEMS, 0.05) }"
|
||||
@click.stop="openAlbum(album)"
|
||||
class="absolute inset-0 bg-transparent group-hover:bg-black/20 transition-colors duration-300 flex items-center justify-center"
|
||||
>
|
||||
<!-- Cover Image -->
|
||||
<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
|
||||
: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>
|
||||
<i class="ri-play-fill text-2xl text-neutral-900 ml-1"></i>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 加载更多 -->
|
||||
<div v-if="isLoadingMore" class="flex justify-center items-center py-8">
|
||||
<n-spin size="small" />
|
||||
<span class="ml-2 text-neutral-500">{{ t('comp.homeListItem.loading') }}</span>
|
||||
<!-- 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 v-if="!hasMore && albumList.length > 0" class="text-center py-8 text-neutral-500">
|
||||
{{ t('comp.recommendSonglist.empty') }}
|
||||
</div>
|
||||
</div>
|
||||
</n-scrollbar>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 加载更多 -->
|
||||
<div v-if="isLoadingMore" class="flex justify-center items-center py-8">
|
||||
<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>
|
||||
|
||||
<script lang="ts" setup>
|
||||
@@ -117,7 +99,7 @@ import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import { getNewAlbums } from '@/api/album';
|
||||
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 { usePlayerCoreStore } from '@/store/modules/playerCore';
|
||||
import { usePlaylistStore } from '@/store/modules/playlist';
|
||||
@@ -130,8 +112,9 @@ defineOptions({
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const pageRef = ref();
|
||||
|
||||
const TOTAL_ITEMS = 30; // 每页数量
|
||||
const TOTAL_ITEMS = 30;
|
||||
|
||||
const areas = computed(() => [
|
||||
{ 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')
|
||||
);
|
||||
|
||||
const contentScrollbarRef = ref();
|
||||
|
||||
const handleAreaChange = (value: string) => {
|
||||
router.replace({ query: { area: value } });
|
||||
loadList(value);
|
||||
@@ -169,7 +150,7 @@ const loadList = async (area: string, isLoadMore = false) => {
|
||||
page.value = 0;
|
||||
albumList.value = [];
|
||||
await nextTick();
|
||||
contentScrollbarRef.value?.scrollTo({ top: 0 });
|
||||
pageRef.value?.scrollTo({ top: 0 });
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -179,9 +160,6 @@ const loadList = async (area: string, isLoadMore = false) => {
|
||||
offset: page.value * TOTAL_ITEMS
|
||||
};
|
||||
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 || [];
|
||||
|
||||
if (isLoadMore) {
|
||||
@@ -190,7 +168,6 @@ const loadList = async (area: string, isLoadMore = false) => {
|
||||
albumList.value = albums;
|
||||
}
|
||||
|
||||
// Check if we have more data
|
||||
hasMore.value = albums.length === TOTAL_ITEMS;
|
||||
page.value++;
|
||||
} catch (error) {
|
||||
@@ -289,12 +266,4 @@ watch(
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.list-card {
|
||||
&:hover {
|
||||
.play-icon {
|
||||
@apply opacity-100 scale-100;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -34,11 +34,13 @@
|
||||
:cover="item.picUrl"
|
||||
:title="item.name"
|
||||
:subtitle="item.copywriter"
|
||||
:tracks="playlistTracksMap[item.id] || []"
|
||||
:tracks="isElectron ? playlistTracksMap[item.id] || [] : []"
|
||||
:show-hover-tracks="isElectron"
|
||||
:play-count="item.playCount"
|
||||
:animation-delay="calculateAnimationDelay(index, 0.04)"
|
||||
@click="handlePlaylistClick(item)"
|
||||
@play="playPlaylist(item)"
|
||||
@mouseenter="isElectron && loadTracksOnHover(item.id)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -110,10 +112,6 @@ const fetchPlaylists = async () => {
|
||||
const { data } = await getPersonalizedPlaylist(props.limit || displayCount.value + 5);
|
||||
if (data.code === 200) {
|
||||
playlists.value = data.result || [];
|
||||
// Preload tracks for displayed playlists (Electron only)
|
||||
if (isElectron) {
|
||||
preloadAllTracks();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch playlists:', error);
|
||||
@@ -122,29 +120,19 @@ const fetchPlaylists = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const preloadAllTracks = async () => {
|
||||
const playlistsToLoad = displayPlaylists.value;
|
||||
|
||||
// Load tracks in parallel with concurrency limit
|
||||
const batchSize = 4;
|
||||
for (let i = 0; i < playlistsToLoad.length; i += batchSize) {
|
||||
const batch = playlistsToLoad.slice(i, i + batchSize);
|
||||
await Promise.all(
|
||||
batch.map(async (item) => {
|
||||
if (playlistTracksMap[item.id]) return;
|
||||
try {
|
||||
const { data } = await getListDetail(item.id);
|
||||
if (data.playlist?.tracks) {
|
||||
playlistTracksMap[item.id] = data.playlist.tracks.slice(0, 3).map((s: any) => ({
|
||||
id: s.id,
|
||||
name: s.name
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.debug('Failed to load tracks for playlist:', item.id, error);
|
||||
}
|
||||
})
|
||||
);
|
||||
/** Lazy load tracks for a single playlist on hover */
|
||||
const loadTracksOnHover = async (id: number) => {
|
||||
if (playlistTracksMap[id]) return;
|
||||
try {
|
||||
const { data } = await getListDetail(id);
|
||||
if (data.playlist?.tracks) {
|
||||
playlistTracksMap[id] = data.playlist.tracks.slice(0, 3).map((s: any) => ({
|
||||
id: s.id,
|
||||
name: s.name
|
||||
}));
|
||||
}
|
||||
} catch {
|
||||
// silent — user can retry by hovering again
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,109 +1,91 @@
|
||||
<template>
|
||||
<div class="list-page h-full w-full bg-white dark:bg-black transition-colors duration-500">
|
||||
<!-- 歌单分类 - 保持固定在顶部 -->
|
||||
<category-selector
|
||||
:model-value="currentType"
|
||||
:categories="playlistCategory?.sub || []"
|
||||
label-key="name"
|
||||
value-key="name"
|
||||
@change="handleTypeChange"
|
||||
/>
|
||||
|
||||
<!-- 歌单列表 -->
|
||||
<n-scrollbar
|
||||
ref="contentScrollbarRef"
|
||||
class="h-full"
|
||||
style="height: calc(100% - 73px)"
|
||||
:size="100"
|
||||
@scroll="handleScroll"
|
||||
>
|
||||
<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>
|
||||
<sticky-tab-page
|
||||
ref="pageRef"
|
||||
:title="listTitle"
|
||||
:description="t('comp.pages.list.desc')"
|
||||
:model-value="currentType"
|
||||
:categories="playlistCategory?.sub || []"
|
||||
label-key="name"
|
||||
value-key="name"
|
||||
@change="handleTypeChange"
|
||||
@scroll="handleScroll"
|
||||
>
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-6">
|
||||
<!-- Loading State -->
|
||||
<template v-if="loading && page === 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>
|
||||
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-6">
|
||||
<!-- Loading State -->
|
||||
<template v-if="loading && page === 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>
|
||||
<!-- Content State -->
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="(item, index) in recommendList"
|
||||
: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
|
||||
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 -->
|
||||
<template v-else>
|
||||
<!-- Play Overlay -->
|
||||
<div
|
||||
v-for="(item, index) in recommendList"
|
||||
: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)"
|
||||
class="absolute inset-0 bg-transparent group-hover:bg-black/20 transition-colors duration-300 flex items-center justify-center"
|
||||
>
|
||||
<!-- Cover Image -->
|
||||
<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
|
||||
: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>
|
||||
<i class="ri-play-fill text-2xl text-neutral-900 ml-1"></i>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 加载更多 -->
|
||||
<div v-if="isLoadingMore" class="flex justify-center items-center py-8">
|
||||
<n-spin size="small" />
|
||||
<span class="ml-2 text-neutral-500">{{ t('common.loading') }}</span>
|
||||
<!-- 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 v-if="!hasMore && recommendList.length > 0" class="text-center py-8 text-neutral-500">
|
||||
{{ t('common.noMore') }}
|
||||
</div>
|
||||
</div>
|
||||
</n-scrollbar>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 加载更多 -->
|
||||
<div v-if="isLoadingMore" 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="!hasMore && recommendList.length > 0" class="text-center py-8 text-neutral-500">
|
||||
{{ t('common.noMore') }}
|
||||
</div>
|
||||
</sticky-tab-page>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
@@ -113,7 +95,7 @@ import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import { getPlaylistCategory } from '@/api/home';
|
||||
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 type { IPlayListSort } from '@/types/playlist';
|
||||
import { calculateAnimationDelay, formatNumber, getImgUrl } from '@/utils';
|
||||
@@ -123,13 +105,14 @@ defineOptions({
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const TOTAL_ITEMS = 42; // 每页数量
|
||||
const TOTAL_ITEMS = 42;
|
||||
|
||||
const recommendList = ref<any[]>([]);
|
||||
const page = ref(0);
|
||||
const hasMore = ref(true);
|
||||
const isLoadingMore = ref(false);
|
||||
const animatedIds = reactive(new Set<number>());
|
||||
const pageRef = ref();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
@@ -157,7 +140,7 @@ const loadList = async (type: string, isLoadMore = false) => {
|
||||
page.value = 0;
|
||||
recommendList.value = [];
|
||||
await nextTick();
|
||||
contentScrollbarRef.value?.scrollTo({ top: 0 });
|
||||
pageRef.value?.scrollTo({ top: 0 });
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -182,22 +165,16 @@ const loadList = async (type: string, isLoadMore = false) => {
|
||||
}
|
||||
};
|
||||
|
||||
// 监听滚动事件
|
||||
const handleScroll = (e: any) => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = e.target;
|
||||
// 距离底部100px时加载更多
|
||||
if (scrollTop + clientHeight >= scrollHeight - 100 && !isLoadingMore.value && hasMore.value) {
|
||||
loadList(currentType.value, true);
|
||||
}
|
||||
};
|
||||
|
||||
// 添加歌单分类相关的代码
|
||||
const playlistCategory = ref<IPlayListSort>();
|
||||
const currentType = ref((route.query.type as string) || DEFAULT_CAT);
|
||||
|
||||
const contentScrollbarRef = ref();
|
||||
|
||||
// 加载歌单分类
|
||||
const loadPlaylistCategory = async () => {
|
||||
const { data } = await getPlaylistCategory();
|
||||
playlistCategory.value = {
|
||||
@@ -231,7 +208,6 @@ watch(
|
||||
async (newParams) => {
|
||||
if (route.path !== '/list') return;
|
||||
const newType = (newParams.type as string) || DEFAULT_CAT;
|
||||
// 如果路由参数变化,且与当前类型不同,则重新加载
|
||||
if (newType !== currentType.value) {
|
||||
listTitle.value = newType === DEFAULT_CAT ? t('comp.pages.list.dailyRecommend') : newType;
|
||||
currentType.value = newType;
|
||||
@@ -257,12 +233,4 @@ watch(
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.list-card {
|
||||
&:hover {
|
||||
.play-icon {
|
||||
@apply opacity-100 scale-100;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,115 +1,93 @@
|
||||
<template>
|
||||
<div class="mv-list-page h-full w-full bg-white dark:bg-black transition-colors duration-500">
|
||||
<!-- MV 分类 - 保持固定在顶部 -->
|
||||
<category-selector
|
||||
<div class="h-full w-full">
|
||||
<sticky-tab-page
|
||||
ref="pageRef"
|
||||
title="MV"
|
||||
:description="t('comp.pages.mv.desc')"
|
||||
:model-value="selectedCategory"
|
||||
:categories="categories"
|
||||
@change="handleCategoryChange"
|
||||
/>
|
||||
|
||||
<!-- MV 列表内容 -->
|
||||
<n-scrollbar
|
||||
ref="contentScrollbarRef"
|
||||
class="h-full"
|
||||
style="height: calc(100% - 73px)"
|
||||
:size="100"
|
||||
@scroll="handleScroll"
|
||||
>
|
||||
<div class="mv-content w-full pb-32 pt-6 page-padding">
|
||||
<!-- 页面标题 -->
|
||||
<div class="mb-8">
|
||||
<h1
|
||||
class="text-3xl md:text-4xl font-bold tracking-tight text-neutral-900 dark:text-white mb-2"
|
||||
>
|
||||
MV
|
||||
</h1>
|
||||
<p class="text-neutral-500 dark:text-neutral-400">{{ t('comp.pages.mv.desc') }}</p>
|
||||
<!-- MV Grid -->
|
||||
<div v-if="initLoading" class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
|
||||
<div v-for="i in 12" :key="i" class="space-y-3">
|
||||
<div class="aspect-video 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>
|
||||
|
||||
<!-- MV Grid Container -->
|
||||
<div class="mv-grid-container">
|
||||
<!-- Loading State -->
|
||||
<div v-if="initLoading" class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
|
||||
<div v-for="i in 12" :key="i" class="space-y-3">
|
||||
<div class="aspect-video 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-4 gap-6">
|
||||
<div
|
||||
v-for="(item, index) in mvList"
|
||||
:key="item.id"
|
||||
class="mv-card group cursor-pointer animate-item"
|
||||
:style="{ animationDelay: calculateAnimationDelay(index, 0.05) }"
|
||||
@click="handleShowMv(item, index)"
|
||||
>
|
||||
<!-- Cover Image -->
|
||||
<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 -->
|
||||
<div v-else class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
|
||||
<!-- Play Overlay -->
|
||||
<div
|
||||
v-for="(item, index) in mvList"
|
||||
:key="item.id"
|
||||
class="mv-card group cursor-pointer animate-item"
|
||||
:style="{ animationDelay: calculateAnimationDelay(index, 0.05) }"
|
||||
@click="handleShowMv(item, index)"
|
||||
class="absolute inset-0 bg-transparent group-hover:bg-black/40 transition-colors duration-300 flex items-center justify-center"
|
||||
>
|
||||
<!-- Cover Image -->
|
||||
<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
|
||||
: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>
|
||||
<i class="ri-play-fill text-2xl text-neutral-900 ml-1"></i>
|
||||
</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>
|
||||
<p class="text-xs text-neutral-500 dark:text-neutral-400 line-clamp-1">
|
||||
{{ item.artistName }}
|
||||
</p>
|
||||
</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>
|
||||
|
||||
<!-- 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>
|
||||
<!-- 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>
|
||||
<p class="text-xs text-neutral-500 dark:text-neutral-400 line-clamp-1">
|
||||
{{ item.artistName }}
|
||||
</p>
|
||||
</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
|
||||
v-model:show="showMv"
|
||||
@@ -127,7 +105,7 @@ import { useI18n } from 'vue-i18n';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
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 { audioService } from '@/services/audioService';
|
||||
import { usePlayerStore } from '@/store/modules/player';
|
||||
@@ -146,8 +124,9 @@ const initLoading = ref(false);
|
||||
const loadingMore = ref(false);
|
||||
const currentIndex = ref(0);
|
||||
const offset = ref(0);
|
||||
const limit = ref(40); // 调整为40,方便4列布局 (10行)
|
||||
const limit = ref(40);
|
||||
const hasMore = ref(true);
|
||||
const pageRef = ref();
|
||||
|
||||
const categories = computed(() => [
|
||||
{ label: t('comp.pages.mv.area.all'), value: '全部' },
|
||||
@@ -161,7 +140,6 @@ const selectedCategory = ref('全部');
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const contentScrollbarRef = ref();
|
||||
|
||||
const playerStore = usePlayerStore();
|
||||
|
||||
@@ -170,18 +148,15 @@ const handleCategoryChange = async (value: string) => {
|
||||
offset.value = 0;
|
||||
mvList.value = [];
|
||||
hasMore.value = true;
|
||||
// 更新路由参数
|
||||
router.replace({ query: { ...route.query, area: value } });
|
||||
await loadMvList();
|
||||
};
|
||||
|
||||
// 监听路由变化
|
||||
watch(
|
||||
() => route.query,
|
||||
async (newParams) => {
|
||||
if (route.path !== '/mv') return;
|
||||
const newArea = (newParams.area as string) || '全部';
|
||||
// 如果路由参数变化,且与当前分类不同,则重新加载
|
||||
if (newArea !== selectedCategory.value) {
|
||||
selectedCategory.value = newArea;
|
||||
}
|
||||
@@ -189,7 +164,6 @@ watch(
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
// 从路由获取初始分类
|
||||
selectedCategory.value = (route.query.area as string) || '全部';
|
||||
await loadMvList();
|
||||
});
|
||||
@@ -264,12 +238,9 @@ const loadMvList = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleScroll = (e: Event) => {
|
||||
const target = e.target as Element;
|
||||
const { scrollTop, clientHeight, scrollHeight } = target;
|
||||
const threshold = 150;
|
||||
|
||||
if (scrollHeight - (scrollTop + clientHeight) < threshold) {
|
||||
const handleScroll = (e: any) => {
|
||||
const { scrollTop, clientHeight, scrollHeight } = e.target;
|
||||
if (scrollHeight - (scrollTop + clientHeight) < 150) {
|
||||
loadMvList();
|
||||
}
|
||||
};
|
||||
@@ -292,12 +263,4 @@ const isPrevDisabled = computed(() => currentIndex.value === 0);
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.mv-card {
|
||||
&:hover {
|
||||
.play-icon {
|
||||
@apply opacity-100 scale-100;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,280 +1,144 @@
|
||||
<template>
|
||||
<div
|
||||
class="podcast-container h-full w-full bg-white dark:bg-black transition-colors duration-500 flex flex-col"
|
||||
<sticky-tab-page
|
||||
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 -->
|
||||
<category-selector
|
||||
:model-value="currentCategoryId"
|
||||
:categories="categoryList"
|
||||
label-key="name"
|
||||
value-key="id"
|
||||
@change="handleCategoryChange"
|
||||
/>
|
||||
<!-- Dashboard View -->
|
||||
<div v-if="currentCategoryId === -1" class="space-y-10">
|
||||
<!-- My Subscriptions -->
|
||||
<section v-if="userStore.user && subscribedRadios.length > 0">
|
||||
<div class="mb-6 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 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 -->
|
||||
<n-scrollbar ref="contentScrollbarRef" class="flex-1" :size="100" @scroll="handleScroll">
|
||||
<div class="podcast-content w-full pb-32 pt-6 page-padding">
|
||||
<!-- Dashboard View (Recommend) -->
|
||||
<div v-if="currentCategoryId === -1">
|
||||
<!-- Hero Section -->
|
||||
<div class="mb-8 flex flex-col md:flex-row md:items-end justify-between gap-6">
|
||||
<div>
|
||||
<h1
|
||||
class="text-3xl md:text-4xl font-bold tracking-tight text-neutral-900 dark:text-white mb-2"
|
||||
<!-- Today's Picks -->
|
||||
<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 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') }}
|
||||
</h1>
|
||||
<p class="text-neutral-500 dark:text-neutral-400">
|
||||
{{ t('podcast.discover') }}
|
||||
<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>
|
||||
|
||||
<!-- Main Content Sections -->
|
||||
<div class="content-sections space-y-10 md:space-y-8 lg:space-y-12">
|
||||
<!-- Recently Played Section -->
|
||||
<section v-if="displayRecentPrograms.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.recentPlayed') }}
|
||||
</h2>
|
||||
<div class="h-1.5 w-1.5 rounded-full bg-primary" />
|
||||
</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>
|
||||
<!-- Recommended -->
|
||||
<section>
|
||||
<div class="mb-6 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 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>
|
||||
</div>
|
||||
|
||||
<!-- Category View -->
|
||||
<div v-else>
|
||||
<!-- Hero Section -->
|
||||
<div class="mb-8">
|
||||
<h1
|
||||
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 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"
|
||||
:animation-delay="calculateAnimationDelay(index, 0.04)"
|
||||
/>
|
||||
</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>
|
||||
</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>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -288,42 +152,31 @@ import {
|
||||
getDjRadioHot,
|
||||
getDjRecommend,
|
||||
getDjSublist,
|
||||
getDjTodayPerfered,
|
||||
getRecentDj,
|
||||
subscribeDj
|
||||
getDjTodayPerfered
|
||||
} 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 { usePlayerStore, usePlaylistStore, useUserStore } from '@/store';
|
||||
import { usePlayHistoryStore } from '@/store/modules/playHistory';
|
||||
import type { DjCategory, DjProgram, DjRadio } from '@/types/podcast';
|
||||
import { calculateAnimationDelay, formatNumber, getImgUrl, secondToMinute } from '@/utils';
|
||||
import { mapDjProgramToSongResult } from '@/utils/podcastUtils';
|
||||
|
||||
defineOptions({
|
||||
name: 'Podcast'
|
||||
});
|
||||
defineOptions({ name: 'Podcast' });
|
||||
|
||||
const { t } = useI18n();
|
||||
const { message } = createDiscreteApi(['message']);
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
const playlistStore = usePlaylistStore();
|
||||
const playerStore = usePlayerStore();
|
||||
const userStore = useUserStore();
|
||||
const playHistoryStore = usePlayHistoryStore();
|
||||
|
||||
const contentScrollbarRef = ref();
|
||||
const recommendedSection = ref<HTMLElement | null>(null);
|
||||
const pageRef = ref();
|
||||
|
||||
const currentCategoryId = ref(-1);
|
||||
|
||||
const categories = ref<DjCategory[]>([]);
|
||||
const recommendRadios = ref<DjRadio[]>([]);
|
||||
const todayPerfered = ref<DjProgram[]>([]);
|
||||
const subscribedRadios = ref<DjRadio[]>([]);
|
||||
const recentPrograms = ref<DjProgram[]>([]);
|
||||
const recommendLoading = ref(false);
|
||||
|
||||
const categoryRadios = ref<DjRadio[]>([]);
|
||||
@@ -333,53 +186,26 @@ const categoryPage = ref(0);
|
||||
const categoryLimit = ref(30);
|
||||
const categoryHasMore = ref(true);
|
||||
|
||||
const categoryList = computed(() => {
|
||||
return [{ id: -1, name: t('podcast.discover') }, ...categories.value];
|
||||
});
|
||||
|
||||
const categoryList = computed(() => [{ id: -1, name: t('podcast.discover') }, ...categories.value]);
|
||||
const currentCategoryName = computed(() => {
|
||||
if (currentCategoryId.value === -1) return t('podcast.recommended');
|
||||
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 date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
|
||||
if (diff < 86400000) {
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
if (hours < 1) {
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
return `${minutes}分钟前`;
|
||||
}
|
||||
if (hours < 1) return `${Math.floor(diff / 60000)}分钟前`;
|
||||
return `${hours}小时前`;
|
||||
}
|
||||
|
||||
return `${date.getMonth() + 1}月${date.getDate()}日`;
|
||||
};
|
||||
|
||||
const handleCategoryChange = (id: number) => {
|
||||
router.replace({
|
||||
query: { ...route.query, category: id === -1 ? undefined : String(id) }
|
||||
});
|
||||
};
|
||||
|
||||
const resetCategoryState = () => {
|
||||
categoryRadios.value = [];
|
||||
categoryPage.value = 0;
|
||||
categoryHasMore.value = true;
|
||||
router.replace({ query: { ...route.query, category: id === -1 ? undefined : String(id) } });
|
||||
};
|
||||
|
||||
const loadCategoryRadios = async (id: number, loadMore = false) => {
|
||||
@@ -393,20 +219,14 @@ const loadCategoryRadios = async (id: number, loadMore = false) => {
|
||||
categoryRadios.value = [];
|
||||
categoryHasMore.value = true;
|
||||
await nextTick();
|
||||
contentScrollbarRef.value?.scrollTo({ top: 0 });
|
||||
pageRef.value?.scrollTo({ top: 0 });
|
||||
}
|
||||
|
||||
try {
|
||||
const offset = categoryPage.value * categoryLimit.value;
|
||||
const res = await getDjRadioHot(id, categoryLimit.value, offset);
|
||||
const radios = res.data?.djRadios || [];
|
||||
|
||||
if (loadMore) {
|
||||
categoryRadios.value.push(...radios);
|
||||
} else {
|
||||
categoryRadios.value = radios;
|
||||
}
|
||||
|
||||
if (loadMore) categoryRadios.value.push(...radios);
|
||||
else categoryRadios.value = radios;
|
||||
categoryHasMore.value = radios.length === categoryLimit.value;
|
||||
categoryPage.value++;
|
||||
} 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;
|
||||
const target = e.target as Element;
|
||||
const { scrollTop, clientHeight, scrollHeight } = target;
|
||||
const threshold = 150;
|
||||
|
||||
if (scrollHeight - (scrollTop + clientHeight) < threshold) {
|
||||
const { scrollTop, clientHeight, scrollHeight } = e.target;
|
||||
if (scrollHeight - (scrollTop + clientHeight) < 150) {
|
||||
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 song = mapDjProgramToSongResult(program);
|
||||
playlistStore.setPlayList([song]);
|
||||
@@ -467,11 +254,13 @@ const playProgram = async (program: DjProgram) => {
|
||||
|
||||
const handlePlayTodayPerfered = async () => {
|
||||
if (todayPerfered.value.length === 0) return;
|
||||
const songList = todayPerfered.value.map((program) => mapDjProgramToSongResult(program));
|
||||
const songList = todayPerfered.value.map(mapDjProgramToSongResult);
|
||||
playlistStore.setPlayList(songList);
|
||||
await playerStore.setPlay(songList[0]);
|
||||
};
|
||||
|
||||
// ==================== Data loading ====================
|
||||
|
||||
const loadCategories = async () => {
|
||||
try {
|
||||
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 () => {
|
||||
await Promise.all([loadCategories(), loadRecommendRadios(), loadTodayPerfered()]);
|
||||
await Promise.all([
|
||||
loadCategories(),
|
||||
loadRecommendRadios(),
|
||||
loadTodayPerfered(),
|
||||
loadSubscribedRadios()
|
||||
]);
|
||||
};
|
||||
|
||||
const loadData = async (categoryId: number) => {
|
||||
if (categoryId === -1) {
|
||||
resetCategoryState();
|
||||
categoryRadios.value = [];
|
||||
categoryPage.value = 0;
|
||||
categoryHasMore.value = true;
|
||||
await loadDashboard();
|
||||
} else {
|
||||
await loadCategoryRadios(categoryId);
|
||||
@@ -540,7 +326,6 @@ watch(
|
||||
async (newCategory) => {
|
||||
if (route.path !== '/podcast') return;
|
||||
const newId = newCategory ? Number(newCategory) : -1;
|
||||
|
||||
if (newId !== currentCategoryId.value) {
|
||||
currentCategoryId.value = newId;
|
||||
await loadData(newId);
|
||||
@@ -552,10 +337,9 @@ watch(
|
||||
() => userStore.user,
|
||||
async (user) => {
|
||||
if (user) {
|
||||
await Promise.all([loadSubscribedRadios(), loadRecentPrograms()]);
|
||||
await loadSubscribedRadios();
|
||||
} else {
|
||||
subscribedRadios.value = [];
|
||||
recentPrograms.value = [];
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user