feat: 优化 UI 逻辑适配移动端

This commit is contained in:
alger
2026-02-06 12:50:58 +08:00
parent fab29e5c79
commit 292751643f
17 changed files with 1003 additions and 986 deletions

View File

@@ -0,0 +1,121 @@
<template>
<div
class="category-selector border-b border-gray-100 dark:border-gray-800 bg-white dark:bg-black z-10"
>
<n-scrollbar ref="scrollbarRef" x-scrollable>
<div
class="categories-wrapper py-4 px-4 sm:px-6 lg:px-8 lg:pl-0"
@wheel.prevent="handleWheel"
>
<span
v-for="(category, index) in categories"
:key="getItemKey(category, index)"
class="category-item"
:class="[animationClass, { active: isActive(category) }]"
:style="getAnimationDelay(index)"
@click="handleClickCategory(category)"
>
{{ getItemLabel(category) }}
</span>
</div>
</n-scrollbar>
</div>
</template>
<script setup lang="ts">
import { NScrollbar } from 'naive-ui';
import { computed, ref } from 'vue';
import { setAnimationDelay } from '@/utils';
type Category = string | number | { [key: string]: any };
type CategorySelectorProps = {
categories: Category[];
modelValue: any;
labelKey?: string;
valueKey?: string;
animationClass?: string;
};
const props = withDefaults(defineProps<CategorySelectorProps>(), {
labelKey: 'label',
valueKey: 'value',
animationClass: 'animate__bounceIn'
});
const emit = defineEmits<{
'update:modelValue': [value: any];
change: [value: any];
}>();
const scrollbarRef = ref();
const getItemKey = (item: Category, index: number): string | number => {
if (typeof item === 'object' && item !== null) {
return item[props.valueKey] ?? item[props.labelKey] ?? index;
}
return item;
};
const getItemLabel = (item: Category): string => {
if (typeof item === 'object' && item !== null) {
return item[props.labelKey] ?? String(item);
}
return String(item);
};
const getItemValue = (item: Category): any => {
if (typeof item === 'object' && item !== null) {
return item[props.valueKey] ?? item;
}
return item;
};
const isActive = (item: Category): boolean => {
const itemValue = getItemValue(item);
return itemValue === props.modelValue;
};
const getAnimationDelay = computed(() => {
return (index: number) => setAnimationDelay(index, 30);
});
const handleClickCategory = (item: Category) => {
const value = getItemValue(item);
if (value === props.modelValue) return;
emit('change', value);
};
const handleWheel = (e: WheelEvent) => {
const scrollbar = scrollbarRef.value;
if (scrollbar) {
const delta = e.deltaY || e.detail;
scrollbar.scrollBy({ left: delta });
}
};
defineExpose({
scrollbarRef
});
</script>
<style lang="scss" scoped>
.category-selector {
.categories-wrapper {
@apply flex items-center;
white-space: nowrap;
}
.category-item {
@apply py-1.5 px-4 mr-3 inline-block rounded-full cursor-pointer transition-all duration-300;
@apply text-sm font-medium;
@apply bg-gray-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400;
@apply hover:bg-gray-200 dark:hover:bg-neutral-700 hover:text-neutral-900 dark:hover:text-white;
&.active {
@apply bg-primary text-white shadow-lg shadow-primary/25 scale-105;
}
}
}
</style>

View File

@@ -1,35 +1,37 @@
<script setup lang="ts">
import { useMessage } from 'naive-ui';
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { usePodcastStore, useUserStore } from '@/store';
import type { DjProgram, DjRadio } from '@/types/podcast';
import { formatNumber, getImgUrl } from '@/utils';
const props = defineProps<{
radio: DjRadio;
program?: DjProgram;
showSubscribeButton?: boolean;
animationDelay?: string;
const props = withDefaults(
defineProps<{
radio: DjRadio;
program?: DjProgram;
showSubscribeButton?: boolean;
isSubscribed?: boolean;
animationDelay?: string;
}>(),
{
showSubscribeButton: false,
isSubscribed: false
}
);
const emit = defineEmits<{
subscribe: [radio: DjRadio];
}>();
const podcastStore = usePodcastStore();
const userStore = useUserStore();
const router = useRouter();
const message = useMessage();
const { t } = useI18n();
const isSubscribed = computed(() => podcastStore.isRadioSubscribed(props.radio.id));
const isSubscribed = computed(() => props.isSubscribed);
const handleSubscribe = async (e: Event) => {
const handleSubscribe = (e: Event) => {
e.stopPropagation();
if (!userStore.user) {
message.warning(t('history.needLogin'));
return;
}
await podcastStore.toggleSubscribe(props.radio);
emit('subscribe', props.radio);
};
const goToDetail = () => {

View File

@@ -212,7 +212,11 @@ const { zoomFactor, initZoomFactor, increaseZoom, decreaseZoom, resetZoom, isZoo
// 显示返回按钮
const showBackButton = computed(() => {
return router.currentRoute.value.meta.back === true;
const meta = router.currentRoute.value.meta;
if (!settingsStore.isMobile && meta.isMobile === false) {
return false;
}
return meta.back === true;
});
// 返回上一页

View File

@@ -61,10 +61,23 @@ const layoutRouter = [
title: 'comp.mv',
icon: 'icon-recordfill',
keepAlive: true,
isMobile: false
isMobile: false,
back: true
},
component: () => import('@/views/mv/index.vue')
},
{
path: '/podcast',
name: 'podcast',
meta: {
title: 'podcast.podcast',
icon: 'ri-radio-fill',
keepAlive: true,
isMobile: false,
back: true
},
component: () => import('@/views/podcast/index.vue')
},
{
path: '/history',
name: 'history',
@@ -88,17 +101,6 @@ const layoutRouter = [
},
component: () => import('@/views/user/index.vue')
},
{
path: '/podcast',
name: 'podcast',
meta: {
title: 'podcast.podcast',
icon: 'ri-radio-fill',
keepAlive: true,
isMobile: true
},
component: () => import('@/views/podcast/index.vue')
},
{
path: '/set',
name: 'set',

View File

@@ -133,16 +133,15 @@ const otherRouter = [
component: () => import('@/views/podcast/radio.vue')
},
{
path: '/podcast/category/:id',
name: 'podcastCategory',
path: '/favorite',
name: 'favorite',
meta: {
title: 'podcast.category',
keepAlive: false,
showInMenu: false,
back: true,
isMobile: true
title: 'comp.homeHero.quickNav.myFavorite',
icon: 'ri-heart-fill',
keepAlive: true,
back: true
},
component: () => import('@/views/podcast/category.vue')
component: () => import('@/views/favorite/index.vue')
},
{
path: '/search-result',

View File

@@ -22,7 +22,6 @@ export * from './modules/music';
export * from './modules/player';
export * from './modules/playerCore';
export * from './modules/playlist';
export * from './modules/podcast';
export * from './modules/recommend';
export * from './modules/search';
export * from './modules/settings';

View File

@@ -1,13 +1,24 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import { computed, ref } from 'vue';
import homeRouter from '@/router/home';
import { useSettingsStore } from '@/store/modules/settings';
export const useMenuStore = defineStore('menu', () => {
const menus = ref(homeRouter);
const allMenus = ref(homeRouter);
const settingsStore = useSettingsStore();
const menus = computed(() => {
return allMenus.value.filter((item) => {
if (settingsStore.isMobile) {
return item.meta?.isMobile !== false;
}
return true;
});
});
const setMenus = (newMenus: any[]) => {
menus.value = newMenus;
allMenus.value = newMenus;
};
return {

View File

@@ -1,166 +0,0 @@
import { createDiscreteApi } from 'naive-ui';
import { defineStore } from 'pinia';
import { computed, ref, shallowRef } from 'vue';
import * as podcastApi from '@/api/podcast';
import type { DjCategory, DjProgram, DjRadio } from '@/types/podcast';
const { message } = createDiscreteApi(['message']);
export const usePodcastStore = defineStore(
'podcast',
() => {
const subscribedRadios = shallowRef<DjRadio[]>([]);
const categories = shallowRef<DjCategory[]>([]);
const currentRadio = shallowRef<DjRadio | null>(null);
const currentPrograms = shallowRef<DjProgram[]>([]);
const recommendRadios = shallowRef<DjRadio[]>([]);
const todayPerfered = shallowRef<DjProgram[]>([]);
const recentPrograms = shallowRef<DjProgram[]>([]);
const isLoading = ref(false);
const subscribedCount = computed(() => subscribedRadios.value.length);
const isRadioSubscribed = computed(() => {
return (rid: number) => subscribedRadios.value.some((r) => r.id === rid);
});
const fetchSubscribedRadios = async () => {
try {
isLoading.value = true;
const res = await podcastApi.getDjSublist();
subscribedRadios.value = res.data?.djRadios || [];
} catch (error) {
console.error('获取订阅列表失败:', error);
message.error('获取订阅列表失败');
} finally {
isLoading.value = false;
}
};
const toggleSubscribe = async (radio: DjRadio) => {
const isSubed = isRadioSubscribed.value(radio.id);
try {
await podcastApi.subscribeDj(radio.id, isSubed ? 0 : 1);
if (isSubed) {
message.success('已取消订阅');
} else {
message.success('订阅成功');
}
await fetchSubscribedRadios();
if (currentRadio.value?.id === radio.id) {
currentRadio.value = { ...currentRadio.value, subed: !isSubed };
}
} catch (error) {
console.error('订阅操作失败:', error);
message.error(isSubed ? '取消订阅失败' : '订阅失败');
}
};
const fetchRadioDetail = async (rid: number) => {
try {
isLoading.value = true;
const res = await podcastApi.getDjDetail(rid);
currentRadio.value = res.data?.data;
if (currentRadio.value) {
currentRadio.value.subed = isRadioSubscribed.value(rid);
}
} catch (error) {
console.error('获取电台详情失败:', error);
message.error('获取电台详情失败');
} finally {
isLoading.value = false;
}
};
const fetchRadioPrograms = async (rid: number, offset = 0) => {
try {
isLoading.value = true;
const res = await podcastApi.getDjProgram(rid, 30, offset);
if (offset === 0) {
currentPrograms.value = res.data?.programs || [];
} else {
currentPrograms.value.push(...(res.data?.programs || []));
}
} catch (error) {
console.error('获取节目列表失败:', error);
message.error('获取节目列表失败');
} finally {
isLoading.value = false;
}
};
const fetchCategories = async () => {
try {
const res = await podcastApi.getDjCategoryList();
categories.value = res.data?.categories || [];
} catch (error) {
console.error('获取分类列表失败:', error);
}
};
const fetchRecommendRadios = async () => {
try {
const res = await podcastApi.getDjRecommend();
recommendRadios.value = res.data?.djRadios || [];
} catch (error) {
console.error('获取推荐电台失败:', error);
}
};
const fetchTodayPerfered = async () => {
try {
const res = await podcastApi.getDjTodayPerfered();
todayPerfered.value = res.data?.data || [];
} catch (error) {
console.error('获取今日优选失败:', error);
}
};
const fetchRecentPrograms = async () => {
try {
const res = await podcastApi.getRecentDj();
recentPrograms.value = res.data?.data?.list || [];
} catch (error) {
console.error('获取最近播放失败:', error);
}
};
const clearCurrentRadio = () => {
currentRadio.value = null;
currentPrograms.value = [];
};
return {
subscribedRadios,
categories,
currentRadio,
currentPrograms,
recommendRadios,
todayPerfered,
recentPrograms,
isLoading,
subscribedCount,
isRadioSubscribed,
fetchSubscribedRadios,
toggleSubscribe,
fetchRadioDetail,
fetchRadioPrograms,
fetchCategories,
fetchRecommendRadios,
fetchTodayPerfered,
fetchRecentPrograms,
clearCurrentRadio
};
},
{
persist: {
key: 'podcast-store',
storage: localStorage,
pick: ['subscribedRadios', 'categories']
}
}
);

View File

@@ -1,27 +1,13 @@
<template>
<div class="list-page h-full w-full bg-white dark:bg-black transition-colors duration-500">
<!-- 专辑地区分类 - 保持固定在顶部 -->
<div
class="play-list-type border-b border-gray-100 dark:border-gray-800 bg-white dark:bg-black z-10"
>
<n-scrollbar ref="scrollbarRef" x-scrollable>
<div class="categories-wrapper py-4 pr-4 sm:pr-6 lg:pr-8" @wheel.prevent="handleWheel">
<span
v-for="(item, index) in areas"
:key="item.value"
class="play-list-type-item"
:class="[
setAnimationClass('animate__bounceIn'),
{ active: currentArea === item.value }
]"
:style="getAnimationDelay(index)"
@click="handleClickArea(item)"
>
{{ item.name }}
</span>
</div>
</n-scrollbar>
</div>
<category-selector
:model-value="currentArea"
:categories="areas"
label-key="name"
value-key="value"
@change="handleAreaChange"
/>
<!-- 专辑列表 -->
<n-scrollbar
@@ -31,7 +17,7 @@
:size="100"
@scroll="handleScroll"
>
<div class="list-content w-full pb-32 pt-6 pr-4 sm:pr-6 lg:pr-8">
<div class="list-content w-full pb-32 pt-6 px-4 sm:px-6 lg:px-8 lg:pl-0">
<!-- 列表标题 -->
<div class="mb-8">
<h1 class="text-2xl md:text-3xl font-bold text-neutral-900 dark:text-white mb-2">
@@ -127,16 +113,17 @@
</template>
<script lang="ts" setup>
import { computed, onMounted, ref, watch } from 'vue';
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { getNewAlbums } from '@/api/album';
import { getAlbum } from '@/api/list';
import CategorySelector from '@/components/common/CategorySelector.vue';
import { navigateToMusicList } from '@/components/common/MusicListNavigator';
import { usePlayerCoreStore } from '@/store/modules/playerCore';
import { usePlaylistStore } from '@/store/modules/playlist';
import { calculateAnimationDelay, getImgUrl, setAnimationClass, setAnimationDelay } from '@/utils';
import { calculateAnimationDelay, getImgUrl } from '@/utils';
defineOptions({
name: 'Album'
@@ -167,9 +154,12 @@ const currentAreaName = computed(
() => areas.find((a) => a.value === currentArea.value)?.name || '全部'
);
const getAnimationDelay = computed(() => {
return (index: number) => setAnimationDelay(index, 30);
});
const contentScrollbarRef = ref();
const handleAreaChange = (value: string) => {
router.replace({ query: { area: value } });
loadList(value);
};
const loadList = async (area: string, isLoadMore = false) => {
if (!hasMore.value && isLoadMore) return;
@@ -179,6 +169,7 @@ const loadList = async (area: string, isLoadMore = false) => {
loading.value = true;
page.value = 0;
albumList.value = [];
await nextTick();
contentScrollbarRef.value?.scrollTo({ top: 0 });
}
@@ -218,24 +209,6 @@ const handleScroll = (e: any) => {
}
};
const handleClickArea = (item: { name: string; value: string }) => {
if (currentArea.value === item.value) return;
currentArea.value = item.value;
router.replace({ query: { area: item.value } });
loadList(item.value);
};
const scrollbarRef = ref();
const contentScrollbarRef = ref();
const handleWheel = (e: WheelEvent) => {
const scrollbar = scrollbarRef.value;
if (scrollbar) {
const delta = e.deltaY || e.detail;
scrollbar.scrollBy({ left: delta });
}
};
const getArtistNames = (album: any) => {
if (album.artists) {
return album.artists.map((ar: any) => ar.name).join(' / ');
@@ -302,24 +275,6 @@ watch(
</script>
<style lang="scss" scoped>
.play-list-type {
.categories-wrapper {
@apply flex items-center;
white-space: nowrap;
}
&-item {
@apply py-1.5 px-4 mr-3 inline-block rounded-full cursor-pointer transition-all duration-300;
@apply text-sm font-medium;
@apply bg-gray-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400;
@apply hover:bg-gray-200 dark:hover:bg-neutral-700 hover:text-neutral-900 dark:hover:text-white;
&.active {
@apply bg-primary text-white shadow-lg shadow-primary/25 scale-105;
}
}
}
.animate-item {
animation: fadeInUp 0.6s cubic-bezier(0.16, 1, 0.3, 1) backwards;
}

View File

@@ -92,7 +92,12 @@ const loading = ref(true);
const albumTracksMap = reactive<Record<number, any[]>>({});
// Calculate display count to fill exactly N rows
const displayCount = computed(() => props.columns * props.rows);
const displayCount = computed(() => {
if (isMobile.value) {
return 6;
}
return props.columns * props.rows;
});
const displayAlbums = computed(() => {
const count = displayCount.value;

View File

@@ -222,13 +222,14 @@ import { useRouter } from 'vue-router';
import { getHotSearch, getPersonalFM } from '@/api/home';
import { navigateToMusicList } from '@/components/common/MusicListNavigator';
import { useIntelligenceModeStore, useRecommendStore } from '@/store';
import { useIntelligenceModeStore, useRecommendStore, useUserStore } from '@/store';
import { calculateAnimationDelay, getImgUrl } from '@/utils';
const { t } = useI18n();
const router = useRouter();
const recommendStore = useRecommendStore();
const intelligenceModeStore = useIntelligenceModeStore();
const userStore = useUserStore();
const loading = ref(false);
const personalFmSong = ref<any>(null);
@@ -240,68 +241,100 @@ const personalFmCover = computed(() => personalFmSong.value?.album?.picUrl || ''
const isIntelligenceMode = computed(() => intelligenceModeStore.isIntelligenceMode);
// Quick Nav Configuration
const quickNavItems = computed(() => [
{
key: 'intelligence',
label: t('comp.homeHero.intelligenceMode'),
icon: 'ri-heart-pulse-fill',
iconBg: 'bg-rose-100 dark:bg-rose-900/40',
iconColor: 'text-rose-500 dark:text-rose-400',
active: isIntelligenceMode.value,
badge: isIntelligenceMode.value ? t('comp.homeHero.playing') : null,
action: toggleIntelligenceMode
},
{
key: 'toplist',
label: t('comp.toplist'),
icon: 'ri-trophy-fill',
iconBg: 'bg-amber-100 dark:bg-amber-900/40',
iconColor: 'text-amber-500 dark:text-amber-400',
active: false,
badge: null,
action: () => router.push('/toplist')
},
{
key: 'mv',
label: t('comp.mv'),
icon: 'ri-movie-2-fill',
iconBg: 'bg-violet-100 dark:bg-violet-900/40',
iconColor: 'text-violet-500 dark:text-violet-400',
active: false,
badge: null,
action: () => router.push('/mv')
},
{
key: 'playlist',
label: t('comp.list'),
icon: 'ri-play-list-2-fill',
iconBg: 'bg-sky-100 dark:bg-sky-900/40',
iconColor: 'text-sky-500 dark:text-sky-400',
active: false,
badge: null,
action: () => router.push('/list')
},
{
key: 'album',
label: t('comp.newAlbum.title'),
icon: 'ri-album-fill',
iconBg: 'bg-orange-100 dark:bg-orange-900/40',
iconColor: 'text-orange-500 dark:text-orange-400',
active: false,
badge: null,
action: () => router.push('/album')
},
{
key: 'history',
label: t('comp.history'),
icon: 'ri-history-fill',
iconBg: 'bg-emerald-100 dark:bg-emerald-900/40',
iconColor: 'text-emerald-500 dark:text-emerald-400',
active: false,
badge: null,
action: () => router.push('/history')
}
]);
const quickNavItems = computed(() => {
const items = [
{
key: 'intelligence',
label: t('comp.homeHero.intelligenceMode'),
icon: 'ri-heart-pulse-fill',
iconBg: 'bg-rose-100 dark:bg-rose-900/40',
iconColor: 'text-rose-500 dark:text-rose-400',
active: isIntelligenceMode.value,
badge: isIntelligenceMode.value ? t('comp.homeHero.playing') : null,
action: toggleIntelligenceMode,
show: !!userStore.user
},
{
key: 'toplist',
label: t('comp.toplist'),
icon: 'ri-trophy-fill',
iconBg: 'bg-amber-100 dark:bg-amber-900/40',
iconColor: 'text-amber-500 dark:text-amber-400',
active: false,
badge: null,
action: () => router.push('/toplist'),
show: true
},
{
key: 'favorite',
label: t('comp.homeHero.quickNav.myFavorite'),
icon: 'ri-heart-fill',
iconBg: 'bg-red-100 dark:bg-red-900/40',
iconColor: 'text-red-500 dark:text-red-400',
active: false,
badge: null,
action: () => router.push('/favorite'),
show: true
},
{
key: 'podcast',
label: t('podcast.podcast'),
icon: 'ri-radio-2-fill',
iconBg: 'bg-cyan-100 dark:bg-cyan-900/40',
iconColor: 'text-cyan-500 dark:text-cyan-400',
active: false,
badge: null,
action: () => router.push('/podcast'),
show: true
},
{
key: 'mv',
label: t('comp.mv'),
icon: 'ri-movie-2-fill',
iconBg: 'bg-violet-100 dark:bg-violet-900/40',
iconColor: 'text-violet-500 dark:text-violet-400',
active: false,
badge: null,
action: () => router.push('/mv'),
show: true
},
{
key: 'playlist',
label: t('comp.list'),
icon: 'ri-play-list-2-fill',
iconBg: 'bg-sky-100 dark:bg-sky-900/40',
iconColor: 'text-sky-500 dark:text-sky-400',
active: false,
badge: null,
action: () => router.push('/list'),
show: true
},
{
key: 'album',
label: t('comp.newAlbum.title'),
icon: 'ri-album-fill',
iconBg: 'bg-orange-100 dark:bg-orange-900/40',
iconColor: 'text-orange-500 dark:text-orange-400',
active: false,
badge: null,
action: () => router.push('/album'),
show: true
},
{
key: 'history',
label: t('comp.history'),
icon: 'ri-history-fill',
iconBg: 'bg-emerald-100 dark:bg-emerald-900/40',
iconColor: 'text-emerald-500 dark:text-emerald-400',
active: false,
badge: null,
action: () => router.push('/history'),
show: true
}
];
return items.filter((item) => item.show);
});
const fetchHeroData = async () => {
try {

View File

@@ -1,24 +1,13 @@
<template>
<div class="list-page h-full w-full bg-white dark:bg-black transition-colors duration-500">
<!-- 歌单分类 - 保持固定在顶部 -->
<div
class="play-list-type border-b border-gray-100 dark:border-gray-800 bg-white dark:bg-black z-10"
>
<n-scrollbar ref="scrollbarRef" x-scrollable>
<div class="categories-wrapper py-4 pr-4 sm:pr-6 lg:pr-8" @wheel.prevent="handleWheel">
<span
v-for="(item, index) in playlistCategory?.sub"
:key="item.name"
class="play-list-type-item"
:class="[setAnimationClass('animate__bounceIn'), { active: currentType === item.name }]"
:style="getAnimationDelay(index)"
@click="handleClickPlaylistType(item.name)"
>
{{ item.name }}
</span>
</div>
</n-scrollbar>
</div>
<category-selector
:model-value="currentType"
:categories="playlistCategory?.sub || []"
label-key="name"
value-key="name"
@change="handleTypeChange"
/>
<!-- 歌单列表 -->
<n-scrollbar
@@ -28,7 +17,7 @@
:size="100"
@scroll="handleScroll"
>
<div class="list-content w-full pb-32 pt-6 pr-4 sm:pr-6 lg:pr-8">
<div class="list-content w-full pb-32 pt-6 px-4 sm:px-6 lg:px-8 lg:pl-0">
<!-- 列表标题 -->
<div class="mb-8">
<h1 class="text-2xl md:text-3xl font-bold text-neutral-900 dark:text-white mb-2">
@@ -53,9 +42,15 @@
<div
v-for="(item, index) in recommendList"
:key="item.id"
class="list-card group cursor-pointer animate-item"
:style="{ animationDelay: calculateAnimationDelay(index % TOTAL_ITEMS, 0.05) }"
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
@@ -114,20 +109,15 @@
</template>
<script lang="ts" setup>
import { computed, onMounted, ref, watch } from 'vue';
import { nextTick, onDeactivated, onMounted, reactive, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { getPlaylistCategory } from '@/api/home';
import { getListByCat } from '@/api/list';
import CategorySelector from '@/components/common/CategorySelector.vue';
import { navigateToMusicList } from '@/components/common/MusicListNavigator';
import type { IPlayListSort } from '@/types/playlist';
import {
calculateAnimationDelay,
formatNumber,
getImgUrl,
setAnimationClass,
setAnimationDelay
} from '@/utils';
import { calculateAnimationDelay, formatNumber, getImgUrl } from '@/utils';
defineOptions({
name: 'List'
@@ -139,6 +129,7 @@ const recommendList = ref<any[]>([]);
const page = ref(0);
const hasMore = ref(true);
const isLoadingMore = ref(false);
const animatedIds = reactive(new Set<number>());
const router = useRouter();
@@ -164,6 +155,7 @@ const loadList = async (type: string, isLoadMore = false) => {
loading.value = true;
page.value = 0;
recommendList.value = [];
await nextTick();
contentScrollbarRef.value?.scrollTo({ top: 0 });
}
@@ -202,9 +194,7 @@ const handleScroll = (e: any) => {
const playlistCategory = ref<IPlayListSort>();
const currentType = ref((route.query.type as string) || '每日推荐');
const getAnimationDelay = computed(() => {
return (index: number) => setAnimationDelay(index, 30);
});
const contentScrollbarRef = ref();
// 加载歌单分类
const loadPlaylistCategory = async () => {
@@ -221,23 +211,8 @@ const loadPlaylistCategory = async () => {
};
};
const handleClickPlaylistType = (type: string) => {
if (currentType.value === type) return;
currentType.value = type;
listTitle.value = type;
loading.value = true;
loadList(type);
};
const scrollbarRef = ref();
const contentScrollbarRef = ref();
const handleWheel = (e: WheelEvent) => {
const scrollbar = scrollbarRef.value;
if (scrollbar) {
const delta = e.deltaY || e.detail;
scrollbar.scrollBy({ left: delta });
}
const handleTypeChange = (type: string) => {
router.replace({ query: { ...route.query, type } });
};
onMounted(() => {
@@ -246,41 +221,27 @@ onMounted(() => {
loadList(currentType.value);
});
onDeactivated(() => {
recommendList.value.forEach((item) => animatedIds.add(item.id));
});
watch(
() => route.query,
async (newParams) => {
if (newParams.type) {
// 如果路由参数变化,且与当前类型不同,则重新加载
if (newParams.type !== currentType.value) {
listTitle.value = (newParams.type as string) || '歌单列表';
currentType.value = newParams.type as string;
loading.value = true;
loadList(newParams.type as string);
}
if (route.path !== '/list') return;
const newType = (newParams.type as string) || '每日推荐';
// 如果路由参数变化,且与当前类型不同,则重新加载
if (newType !== currentType.value) {
listTitle.value = newType;
currentType.value = newType;
loading.value = true;
loadList(newType);
}
}
);
</script>
<style lang="scss" scoped>
.play-list-type {
.categories-wrapper {
@apply flex items-center;
white-space: nowrap;
}
&-item {
@apply py-1.5 px-4 mr-3 inline-block rounded-full cursor-pointer transition-all duration-300;
@apply text-sm font-medium;
@apply bg-gray-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400;
@apply hover:bg-gray-200 dark:hover:bg-neutral-700 hover:text-neutral-900 dark:hover:text-white;
&.active {
@apply bg-primary text-white shadow-lg shadow-primary/25 scale-105;
}
}
}
.animate-item {
animation: fadeInUp 0.6s cubic-bezier(0.16, 1, 0.3, 1) backwards;
}

View File

@@ -37,14 +37,18 @@
/>
<!-- Play overlay on cover -->
<div
class="absolute inset-0 flex items-center justify-center bg-transparent group-hover:bg-black/30 transition-all duration-300 cursor-pointer"
@click="handlePlayAll"
class="absolute inset-0 flex items-center justify-center bg-transparent group-hover:bg-black/30 transition-all duration-300"
:class="isMobile ? 'pointer-events-none' : 'cursor-pointer'"
@click="!isMobile && handlePlayAll()"
>
<div
class="play-icon w-16 h-16 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 hover:scale-110 active:scale-95"
<button
v-if="!isMobile"
type="button"
class="play-icon w-16 h-16 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 hover:scale-110 active:scale-95 pointer-events-auto"
@click.stop="handlePlayAll"
>
<i class="ri-play-fill text-3xl text-neutral-900 ml-1" />
</div>
</button>
</div>
</div>
</div>

View File

@@ -1,34 +1,29 @@
<template>
<div class="mv-list-page h-full w-full bg-white dark:bg-black transition-colors duration-500">
<n-scrollbar class="h-full" @scroll="handleScroll">
<div class="mv-content w-full pb-32 pt-6 px-4 sm:px-6 lg:px-8 lg:pl-0">
<!-- 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"
>
MV
</h1>
<p class="text-neutral-500 dark:text-neutral-400">探索精彩视频内容</p>
</div>
<!-- MV 分类 - 保持固定在顶部 -->
<category-selector
:model-value="selectedCategory"
:categories="categories"
@change="handleCategoryChange"
/>
<!-- Category Selector (Pills) -->
<div class="flex flex-wrap gap-2">
<button
v-for="category in categories"
:key="category.value"
class="px-4 py-1.5 rounded-full text-sm font-medium transition-all duration-300"
:class="[
selectedCategory === category.value
? 'bg-primary text-white shadow-lg shadow-primary/25 scale-105'
: 'bg-neutral-100 dark:bg-neutral-900 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-200 dark:hover:bg-neutral-800'
]"
@click="selectedCategory = category.value"
>
{{ category.label }}
</button>
</div>
<!-- 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 px-4 sm:px-6 lg:px-8 lg:pl-0">
<!-- 页面标题 -->
<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">探索精彩视频内容</p>
</div>
<!-- MV Grid Container -->
@@ -129,9 +124,11 @@
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { getAllMv, getTopMv } from '@/api/mv';
import CategorySelector from '@/components/common/CategorySelector.vue';
import MvPlayer from '@/components/MvPlayer.vue';
import { audioService } from '@/services/audioService';
import { usePlayerStore } from '@/store/modules/player';
@@ -162,16 +159,38 @@ const categories = [
];
const selectedCategory = ref('全部');
const router = useRouter();
const route = useRoute();
const contentScrollbarRef = ref();
const playerStore = usePlayerStore();
watch(selectedCategory, async () => {
const handleCategoryChange = async (value: string) => {
selectedCategory.value = value;
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;
}
}
);
onMounted(async () => {
// 从路由获取初始分类
selectedCategory.value = (route.query.area as string) || '全部';
await loadMvList();
});
@@ -259,10 +278,6 @@ const isPrevDisabled = computed(() => currentIndex.value === 0);
</script>
<style scoped lang="scss">
.mv-list-page {
position: relative;
}
.animate-item {
animation: fadeInUp 0.6s cubic-bezier(0.16, 1, 0.3, 1) backwards;
}

View File

@@ -1,111 +0,0 @@
<template>
<div
class="category-page-container h-full w-full bg-white dark:bg-black transition-colors duration-500"
>
<n-scrollbar class="h-full">
<div class="category-page-content w-full pb-32 pt-6 px-4 sm:px-6 lg:px-8 lg:pl-0">
<!-- 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"
>
{{ currentCategory?.name || t('podcast.categoryRadios') }}
</h1>
<p class="text-neutral-500 dark:text-neutral-400">
{{ t('podcast.exploreCategoryRadios') }}
</p>
</div>
<!-- Radios Grid -->
<section>
<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.hotRadios') }}
</h2>
<div class="h-1.5 w-1.5 rounded-full bg-primary" />
</div>
</div>
<!-- Loading Skeletons -->
<div v-if="loading" class="grid gap-6" :style="gridStyle">
<div v-for="i in 15" :key="i" class="space-y-3">
<div
class="aspect-square animate-pulse rounded-2xl bg-neutral-200 dark:bg-neutral-800"
/>
<div class="h-4 w-3/4 animate-pulse rounded bg-neutral-200 dark:bg-neutral-800" />
<div class="h-3 w-1/2 animate-pulse rounded bg-neutral-200 dark:bg-neutral-800" />
</div>
</div>
<!-- Radios Grid -->
<div v-else-if="radios.length > 0" class="grid gap-6" :style="gridStyle">
<radio-card
v-for="(radio, index) in radios"
:key="radio.id"
:radio="radio"
:show-subscribe-button="true"
:animation-delay="calculateAnimationDelay(index, 0.04)"
/>
</div>
<!-- Empty State -->
<div v-else 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>
</section>
</div>
</n-scrollbar>
</div>
</template>
<script setup lang="ts">
import { NScrollbar } from 'naive-ui';
import { computed, onMounted, ref, shallowRef } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { getDjRadioHot } from '@/api/podcast';
import RadioCard from '@/components/podcast/RadioCard.vue';
import { usePodcastStore } from '@/store';
import type { DjRadio } from '@/types/podcast';
import { calculateAnimationDelay } from '@/utils';
defineOptions({
name: 'PodcastCategory'
});
const { t } = useI18n();
const route = useRoute();
const podcastStore = usePodcastStore();
const categoryId = computed(() => Number(route.params.id));
const radios = shallowRef<DjRadio[]>([]);
const loading = ref(false);
const gridStyle = computed(() => ({
gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))'
}));
const currentCategory = computed(() => {
return podcastStore.categories.find((c) => c.id === categoryId.value);
});
const fetchRadios = async () => {
try {
loading.value = true;
const res = await getDjRadioHot(categoryId.value);
radios.value = res.data?.djRadios || [];
} catch (error) {
console.error('获取分类电台失败:', error);
} finally {
loading.value = false;
}
};
onMounted(fetchRadios);
</script>

View File

@@ -1,300 +1,280 @@
<template>
<div
class="podcast-container h-full w-full bg-white dark:bg-black transition-colors duration-500"
class="podcast-container h-full w-full bg-white dark:bg-black transition-colors duration-500 flex flex-col"
>
<n-scrollbar class="h-full">
<!-- Top Categories Bar -->
<category-selector
:model-value="currentCategoryId"
:categories="categoryList"
label-key="name"
value-key="id"
@change="handleCategoryChange"
/>
<!-- Main Content Scrollbar -->
<n-scrollbar ref="contentScrollbarRef" class="flex-1" :size="100" @scroll="handleScroll">
<div class="podcast-content w-full pb-32 pt-6 px-4 sm:px-6 lg:px-8 lg:pl-0">
<!-- Hero Section -->
<div class="mb-8 flex flex-col md:flex-row md:items-end justify-between gap-6">
<div>
<!-- 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"
>
{{ t('podcast.podcast') }}
</h1>
<p class="text-neutral-500 dark:text-neutral-400">
{{ t('podcast.discover') }}
</p>
</div>
</div>
<!-- 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 animate-pulse rounded-2xl bg-neutral-200 dark:bg-neutral-800"
/>
<div class="h-4 w-3/4 animate-pulse rounded bg-neutral-200 dark:bg-neutral-800" />
<div class="h-3 w-1/2 animate-pulse rounded bg-neutral-200 dark:bg-neutral-800" />
</div>
</div>
<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>
<!-- 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"
>
{{ t('podcast.podcast') }}
{{ currentCategoryName }}
</h1>
<p class="text-neutral-500 dark:text-neutral-400">
{{ t('podcast.discover') }}
{{ t('podcast.exploreCategoryRadios') }}
</p>
</div>
<!-- Search Bar -->
<div class="relative w-full md:w-80 group">
<n-input
v-model:value="searchKeyword"
round
:placeholder="t('podcast.searchPlaceholder')"
class="!bg-neutral-100 dark:!bg-neutral-900 border-none transition-all duration-300 group-hover:shadow-lg focus:shadow-lg"
@keypress.enter="handleSearch"
>
<template #prefix>
<i class="ri-search-line text-neutral-400"></i>
</template>
<template #suffix>
<n-spin v-if="isSearching" size="small" />
</template>
</n-input>
</div>
</div>
<!-- Search Results Section -->
<div v-if="showSearchResults" class="content-sections mb-12">
<section>
<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.searchResults') }}
</h2>
<div class="h-1.5 w-1.5 rounded-full bg-primary" />
</div>
<n-button text class="text-neutral-400 hover:text-primary" @click="clearSearch">
{{ t('common.close') }}
</n-button>
</div>
<div
v-if="searchResults.length === 0 && !isSearching"
class="py-10 text-center text-neutral-400"
>
{{ t('common.noData') }}
</div>
<div v-else class="grid gap-6" :style="gridStyle">
<radio-card
v-for="(radio, index) in searchResults"
:key="radio.id"
:radio="radio"
:show-subscribe-button="true"
:animation-delay="calculateAnimationDelay(index, 0.04)"
/>
</div>
<div v-if="hasMoreSearch && !isSearching" class="mt-8 flex justify-center">
<n-button secondary round @click="loadMoreSearch">
{{ t('common.loadMore') }}
</n-button>
</div>
</section>
</div>
<!-- Main Content Sections -->
<div
v-if="!showSearchResults"
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="program.id"
:radio="program.radio as any"
:program="program"
:animation-delay="calculateAnimationDelay(index, 0.04)"
/>
</div>
</section>
<!-- Today's Perfered Section -->
<section v-if="podcastStore.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="podcastStore.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 podcastStore.todayPerfered.slice(0, 5)"
:key="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"
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 / 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="podcastStore.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 podcastStore.subscribedRadios.slice(0, 10)"
:key="radio.id"
:radio="radio"
:show-subscribe-button="true"
:animation-delay="calculateAnimationDelay(index, 0.04)"
/>
</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="podcastStore.isLoading"
class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-6"
>
<div v-for="i in 10" :key="i" class="space-y-3">
<!-- 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 animate-pulse rounded-2xl bg-neutral-200 dark:bg-neutral-800"
/>
<div class="h-4 w-3/4 animate-pulse rounded bg-neutral-200 dark:bg-neutral-800" />
<div class="h-3 w-1/2 animate-pulse rounded bg-neutral-200 dark:bg-neutral-800" />
</div>
</div>
</template>
<div v-else class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-6">
<template v-else>
<radio-card
v-for="(radio, index) in podcastStore.recommendRadios.slice(0, 10)"
:key="radio.id"
v-for="(radio, index) in categoryRadios"
:key="`cat-${radio.id}`"
:radio="radio"
:show-subscribe-button="true"
:animation-delay="calculateAnimationDelay(index, 0.04)"
:is-subscribed="isRadioSubscribed(radio.id)"
:animation-delay="calculateAnimationDelay(index % 30, 0.04)"
@subscribe="handleSubscribe"
/>
</div>
</section>
</template>
</div>
<!-- Categories Section -->
<section>
<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.popularCategories') }}
</h2>
<div class="h-1.5 w-1.5 rounded-full bg-primary" />
</div>
</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>
<div class="grid grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
<div
v-for="(category, index) in podcastStore.categories.slice(0, 12)"
:key="category.id"
class="category-card flex flex-col items-center gap-3 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.03) }"
@click="router.push(`/podcast/category/${category.id}`)"
>
<img
v-if="category.pic84x84Url"
:src="category.pic84x84Url"
:alt="category.name"
class="w-12 h-12 md:w-16 md:h-16 rounded-full object-cover group-hover:scale-110 transition-transform duration-300"
/>
<span
class="text-xs md:text-sm text-center font-medium text-neutral-700 dark:text-neutral-300 group-hover:text-primary dark:group-hover:text-white transition-colors"
>
{{ category.name }}
</span>
</div>
</div>
</section>
<!-- 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>
</n-scrollbar>
@@ -302,16 +282,25 @@
</template>
<script setup lang="ts">
import { createDiscreteApi, NButton, NInput, NScrollbar, NSpin } from 'naive-ui';
import { computed, onMounted, ref } from 'vue';
import { createDiscreteApi } from 'naive-ui';
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useRoute, useRouter } from 'vue-router';
import { getSearch } from '@/api/search';
import {
getDjCategoryList,
getDjRadioHot,
getDjRecommend,
getDjSublist,
getDjTodayPerfered,
getRecentDj,
subscribeDj
} from '@/api/podcast';
import CategorySelector from '@/components/common/CategorySelector.vue';
import RadioCard from '@/components/podcast/RadioCard.vue';
import { usePodcastHistory } from '@/hooks/PodcastHistoryHook';
import { usePlayerStore, usePlaylistStore, usePodcastStore, useUserStore } from '@/store';
import type { DjProgram, DjRadio } from '@/types/podcast';
import { usePlayerStore, usePlaylistStore, useUserStore } from '@/store';
import type { DjCategory, DjProgram, DjRadio } from '@/types/podcast';
import { calculateAnimationDelay, formatNumber, getImgUrl, secondToMinute } from '@/utils';
import { mapDjProgramToSongResult } from '@/utils/podcastUtils';
@@ -322,100 +311,51 @@ defineOptions({
const { t } = useI18n();
const { message } = createDiscreteApi(['message']);
const router = useRouter();
const podcastStore = usePodcastStore();
const route = useRoute();
const playlistStore = usePlaylistStore();
const playerStore = usePlayerStore();
const userStore = useUserStore();
const { addPodcast, podcastList, clearPodcastHistory } = usePodcastHistory();
const { podcastList, clearPodcastHistory } = usePodcastHistory();
const contentScrollbarRef = ref();
const recommendedSection = ref<HTMLElement | null>(null);
// Search State
const searchKeyword = ref('');
const isSearching = ref(false);
const searchResults = ref<DjRadio[]>([]);
const searchPage = ref(0);
const hasMoreSearch = ref(false);
const showSearchResults = ref(false);
const currentCategoryId = ref(-1);
const gridStyle = computed(() => ({
gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))'
}));
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[]>([]);
const categoryLoading = ref(false);
const categoryLoadingMore = ref(false);
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 currentCategoryName = computed(() => {
if (currentCategoryId.value === -1) return t('podcast.recommend');
return categories.value.find((c) => c.id === currentCategoryId.value)?.name || '';
});
const displayRecentPrograms = computed(() => {
if (userStore.user) {
return podcastStore.recentPrograms.slice(0, 5);
return recentPrograms.value.slice(0, 5);
}
return podcastList.value.slice(0, 5);
});
const handlePlayTodayPerfered = () => {
if (podcastStore.todayPerfered.length === 0) return;
const songList = podcastStore.todayPerfered.map((p) => mapDjProgramToSongResult(p));
playlistStore.setPlayList(songList);
if (songList[0]) {
playerStore.setPlay(songList[0]);
}
};
const subscribedIdSet = computed(() => new Set(subscribedRadios.value.map((radio) => radio.id)));
const handleSearch = async () => {
if (!searchKeyword.value.trim()) return;
isSearching.value = true;
showSearchResults.value = true;
searchPage.value = 0;
searchResults.value = [];
try {
const { data } = await getSearch({
keywords: searchKeyword.value,
type: 1009, // DJ Radio
limit: 30,
offset: 0
});
searchResults.value = data.result?.djRadios || [];
hasMoreSearch.value = data.result?.djRadioCount > searchResults.value.length;
} catch (error) {
console.error('搜索播客失败:', error);
message.error(t('common.searchFailed'));
} finally {
isSearching.value = false;
}
};
const loadMoreSearch = async () => {
if (isSearching.value) return;
isSearching.value = true;
searchPage.value++;
try {
const { data } = await getSearch({
keywords: searchKeyword.value,
type: 1009,
limit: 30,
offset: searchPage.value * 30
});
const newResults = data.result?.djRadios || [];
searchResults.value.push(...newResults);
hasMoreSearch.value = data.result?.djRadioCount > searchResults.value.length;
} catch (error) {
console.error('加载更多搜索结果失败:', error);
} finally {
isSearching.value = false;
}
};
const clearSearch = () => {
showSearchResults.value = false;
searchKeyword.value = '';
searchResults.value = [];
};
const clearLocalHistory = () => {
clearPodcastHistory();
};
const isRadioSubscribed = (id: number) => subscribedIdSet.value.has(id);
const formatDate = (timestamp: number): string => {
const date = new Date(timestamp);
@@ -434,43 +374,204 @@ const formatDate = (timestamp: number): string => {
return `${date.getMonth() + 1}月${date.getDate()}日`;
};
const playProgram = async (program: DjProgram) => {
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;
};
const loadCategoryRadios = async (id: number, loadMore = false) => {
if (loadMore) {
if (categoryLoadingMore.value || !categoryHasMore.value) return;
categoryLoadingMore.value = true;
} else {
if (categoryLoading.value) return;
categoryLoading.value = true;
categoryPage.value = 0;
categoryRadios.value = [];
categoryHasMore.value = true;
await nextTick();
contentScrollbarRef.value?.scrollTo({ top: 0 });
}
try {
const songData = mapDjProgramToSongResult(program);
await playerStore.setPlay(songData);
addPodcast(program);
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;
}
categoryHasMore.value = radios.length === categoryLimit.value;
categoryPage.value++;
} catch (error) {
console.error('播放节目失败:', error);
console.error('获取分类电台失败:', error);
message.error(t('common.loadFailed'));
} finally {
categoryLoading.value = false;
categoryLoadingMore.value = false;
}
};
const scrollToRecommended = () => {
if (recommendedSection.value) {
recommendedSection.value.scrollIntoView({ behavior: 'smooth', block: 'start' });
const handleScroll = (e: Event) => {
if (currentCategoryId.value === -1) return;
const target = e.target as Element;
const { scrollTop, clientHeight, scrollHeight } = target;
const threshold = 150;
if (scrollHeight - (scrollTop + clientHeight) < threshold) {
loadCategoryRadios(currentCategoryId.value, true);
}
};
const scrollToRecommended = async () => {
await nextTick();
recommendedSection.value?.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
const clearLocalHistory = () => {
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]);
await playerStore.setPlay(song);
};
const handlePlayTodayPerfered = async () => {
if (todayPerfered.value.length === 0) return;
const songList = todayPerfered.value.map((program) => mapDjProgramToSongResult(program));
playlistStore.setPlayList(songList);
await playerStore.setPlay(songList[0]);
};
const loadCategories = async () => {
try {
const res = await getDjCategoryList();
categories.value = res.data?.categories || [];
} catch (error) {
console.error('获取分类列表失败:', error);
}
};
const loadRecommendRadios = async () => {
try {
recommendLoading.value = true;
const res = await getDjRecommend();
recommendRadios.value = res.data?.djRadios || [];
} catch (error) {
console.error('获取推荐电台失败:', error);
} finally {
recommendLoading.value = false;
}
};
const loadTodayPerfered = async () => {
try {
const res = await getDjTodayPerfered();
todayPerfered.value = res.data?.data || [];
} catch (error) {
console.error('获取今日优选失败:', error);
}
};
const loadSubscribedRadios = async () => {
if (!userStore.user) return;
try {
const res = await getDjSublist();
subscribedRadios.value = res.data?.djRadios || [];
} catch (error) {
console.error('获取订阅列表失败:', error);
}
};
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()]);
};
const loadData = async (categoryId: number) => {
if (categoryId === -1) {
resetCategoryState();
await loadDashboard();
} else {
await loadCategoryRadios(categoryId);
}
};
watch(
() => route.query.category,
async (newCategory) => {
if (route.path !== '/podcast') return;
const newId = newCategory ? Number(newCategory) : -1;
if (newId !== currentCategoryId.value) {
currentCategoryId.value = newId;
await loadData(newId);
}
}
);
watch(
() => userStore.user,
async (user) => {
if (user) {
await Promise.all([loadSubscribedRadios(), loadRecentPrograms()]);
} else {
subscribedRadios.value = [];
recentPrograms.value = [];
}
}
);
onMounted(async () => {
const tasks = [
podcastStore.fetchCategories(),
podcastStore.fetchRecommendRadios(),
podcastStore.fetchTodayPerfered()
];
if (userStore.user) {
tasks.push(podcastStore.fetchSubscribedRadios());
tasks.push(podcastStore.fetchRecentPrograms());
}
await Promise.all(tasks);
const queryId = route.query.category ? Number(route.query.category) : -1;
currentCategoryId.value = queryId;
await loadData(queryId);
});
</script>
<style lang="scss" scoped>
.podcast-container {
position: relative;
}
.animate-item {
animation: fadeInUp 0.6s cubic-bezier(0.16, 1, 0.3, 1) backwards;
}

View File

@@ -4,8 +4,8 @@
>
<n-scrollbar class="h-full" @scroll="handleScroll">
<div class="radio-detail-content w-full pb-32">
<n-spin :show="podcastStore.isLoading && !podcastStore.currentRadio">
<div v-if="podcastStore.currentRadio" class="radio-content">
<n-spin :show="isLoading && !currentRadio">
<div v-if="currentRadio" class="radio-content">
<!-- Hero Section -->
<section class="hero-section relative overflow-hidden rounded-tl-2xl">
<!-- Background Image with Blur -->
@@ -13,7 +13,7 @@
<div
class="absolute inset-0 bg-cover bg-center scale-110 blur-2xl opacity-40 dark:opacity-30"
:style="{
backgroundImage: `url(${getImgUrl(podcastStore.currentRadio.picUrl, '800y800')})`
backgroundImage: `url(${getImgUrl(currentRadio.picUrl, '800y800')})`
}"
/>
<div
@@ -33,8 +33,8 @@
class="cover-container relative w-48 h-48 md:w-56 md:h-56 rounded-2xl overflow-hidden shadow-2xl ring-4 ring-white/50 dark:ring-neutral-800/50"
>
<img
:src="getImgUrl(podcastStore.currentRadio.picUrl, '500y500')"
:alt="podcastStore.currentRadio.name"
:src="getImgUrl(currentRadio.picUrl, '500y500')"
:alt="currentRadio.name"
class="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
/>
<!-- Play overlay on cover -->
@@ -58,13 +58,13 @@
class="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-primary/10 dark:bg-primary/20 text-primary text-xs font-semibold uppercase tracking-wider"
>
<i class="ri-radio-line text-sm" />
{{ podcastStore.currentRadio.category }}
{{ currentRadio.category }}
</span>
</div>
<h1
class="radio-name text-2xl md:text-3xl lg:text-4xl font-bold text-neutral-900 dark:text-white tracking-tight"
>
{{ podcastStore.currentRadio.name }}
{{ currentRadio.name }}
</h1>
<!-- Stats -->
@@ -75,7 +75,7 @@
<i class="ri-user-follow-line text-primary text-lg" />
<span class="text-sm font-medium text-neutral-600 dark:text-neutral-300">
<span class="font-bold text-neutral-900 dark:text-white">{{
formatNumber(podcastStore.currentRadio.subCount)
formatNumber(currentRadio.subCount)
}}</span>
{{ t('podcast.subscribeCount') }}
</span>
@@ -84,7 +84,7 @@
<i class="ri-play-list-2-line text-primary text-lg" />
<span class="text-sm font-medium text-neutral-600 dark:text-neutral-300">
<span class="font-bold text-neutral-900 dark:text-white">{{
podcastStore.currentRadio.programCount
currentRadio.programCount
}}</span>
{{ t('podcast.programCount') }}
</span>
@@ -94,7 +94,7 @@
<p
class="mt-4 text-sm md:text-base text-neutral-600 dark:text-neutral-300 line-clamp-2 leading-relaxed max-w-2xl"
>
{{ podcastStore.currentRadio.desc }}
{{ currentRadio.desc }}
</p>
</div>
</div>
@@ -149,13 +149,10 @@
<div class="h-1.5 w-1.5 rounded-full bg-primary" />
</div>
<program-list
:programs="podcastStore.currentPrograms"
:loading="podcastStore.isLoading"
/>
<program-list :programs="currentPrograms" :loading="isLoading" />
<!-- Loading state for pagination -->
<div v-if="podcastStore.isLoading" class="mt-8 flex justify-center">
<div v-if="loadingMore" class="mt-8 flex justify-center">
<n-spin size="small" />
</div>
</section>
@@ -167,15 +164,16 @@
</template>
<script setup lang="ts">
import { NScrollbar, NSpin, useMessage } from 'naive-ui';
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { createDiscreteApi, NScrollbar, NSpin } from 'naive-ui';
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { getDjProgram } from '@/api/podcast';
import { getDjDetail, getDjProgram, getDjSublist, subscribeDj } from '@/api/podcast';
import ProgramList from '@/components/podcast/ProgramList.vue';
import { usePlayerStore, usePlaylistStore, usePodcastStore, useUserStore } from '@/store';
import { usePlayerStore, usePlaylistStore, useUserStore } from '@/store';
import type { SongResult } from '@/types/music';
import type { DjProgram, DjRadio } from '@/types/podcast';
import { formatNumber, getImgUrl } from '@/utils';
import { mapDjProgramToSongResult } from '@/utils/podcastUtils';
@@ -184,42 +182,109 @@ defineOptions({
});
const { t } = useI18n();
const { message } = createDiscreteApi(['message']);
const route = useRoute();
const message = useMessage();
const podcastStore = usePodcastStore();
const playlistStore = usePlaylistStore();
const playerStore = usePlayerStore();
const userStore = useUserStore();
const radioId = computed(() => Number(route.params.id));
const isSubscribed = computed(() => podcastStore.isRadioSubscribed(radioId.value));
// 本地状态
const currentRadio = ref<DjRadio | null>(null);
const currentPrograms = ref<DjProgram[]>([]);
const subscribedRadioIds = ref<Set<number>>(new Set());
const isLoading = ref(false);
const loadingMore = ref(false);
const offset = ref(0);
const limit = 30;
const radioId = computed(() => Number(route.params.id));
const isSubscribed = computed(() => subscribedRadioIds.value.has(radioId.value));
const hasMore = computed(() => {
if (!podcastStore.currentRadio) return false;
return podcastStore.currentPrograms.length < podcastStore.currentRadio.programCount;
if (!currentRadio.value) return false;
return currentPrograms.value.length < currentRadio.value.programCount;
});
// 加载电台详情
const loadRadioDetail = async () => {
try {
isLoading.value = true;
const res = await getDjDetail(radioId.value);
currentRadio.value = res.data?.data || null;
} catch (error) {
console.error('获取电台详情失败:', error);
message.error(t('common.loadFailed'));
} finally {
isLoading.value = false;
}
};
// 加载节目列表
const loadPrograms = async (loadMore = false) => {
if (loadMore) {
if (loadingMore.value || !hasMore.value) return;
loadingMore.value = true;
} else {
isLoading.value = true;
offset.value = 0;
currentPrograms.value = [];
}
try {
const res = await getDjProgram(radioId.value, limit, offset.value);
const programs = res.data?.programs || [];
if (loadMore) {
currentPrograms.value.push(...programs);
} else {
currentPrograms.value = programs;
}
offset.value += limit;
} catch (error) {
console.error('获取节目列表失败:', error);
} finally {
isLoading.value = false;
loadingMore.value = false;
}
};
// 加载订阅列表
const loadSubscribedRadios = async () => {
if (!userStore.user) return;
try {
const res = await getDjSublist();
const radios = res.data?.djRadios || [];
subscribedRadioIds.value = new Set(radios.map((r: DjRadio) => r.id));
} catch (error) {
console.error('获取订阅列表失败:', error);
}
};
// 滚动加载更多
const handleScroll = async (e: any) => {
const { scrollTop, scrollHeight, clientHeight } = e.target;
if (scrollTop + clientHeight >= scrollHeight - 100) {
if (!podcastStore.isLoading && hasMore.value) {
offset.value += 30;
await podcastStore.fetchRadioPrograms(radioId.value, offset.value);
if (!loadingMore.value && hasMore.value) {
await loadPrograms(true);
}
}
};
// 播放全部
const handlePlayAll = async () => {
if (!podcastStore.currentRadio) return;
if (!currentRadio.value) return;
const total = podcastStore.currentRadio.programCount;
const total = currentRadio.value.programCount;
try {
message.loading(t('common.loading'));
const { data } = await getDjProgram(radioId.value, total);
const allPrograms = data.programs || [];
const songList: SongResult[] = allPrograms.map((program) => mapDjProgramToSongResult(program));
const songList: SongResult[] = allPrograms.map((program: DjProgram) =>
mapDjProgramToSongResult(program)
);
playlistStore.setPlayList(songList);
if (songList[0]) {
@@ -227,27 +292,44 @@ const handlePlayAll = async () => {
}
} catch (error) {
console.error('获取全部节目失败:', error);
message.error(t('common.loadFailed'));
}
};
onMounted(async () => {
await podcastStore.fetchRadioDetail(radioId.value);
await podcastStore.fetchRadioPrograms(radioId.value);
});
onUnmounted(() => {
podcastStore.clearCurrentRadio();
});
// 订阅/取消订阅
const handleSubscribe = async () => {
if (!userStore.user) {
message.warning(t('history.needLogin'));
return;
}
if (podcastStore.currentRadio) {
await podcastStore.toggleSubscribe(podcastStore.currentRadio);
const isSubed = isSubscribed.value;
try {
await subscribeDj(radioId.value, isSubed ? 0 : 1);
// 更新本地订阅状态
if (isSubed) {
subscribedRadioIds.value.delete(radioId.value);
} else {
subscribedRadioIds.value.add(radioId.value);
}
// 更新电台订阅数
if (currentRadio.value && currentRadio.value.subCount !== undefined) {
currentRadio.value.subCount = Math.max(0, currentRadio.value.subCount + (isSubed ? -1 : 1));
}
message.success(isSubed ? t('podcast.unsubscribed') : t('podcast.subscribeSuccess'));
} catch (error) {
console.error('订阅操作失败:', error);
message.error(isSubed ? t('podcast.unsubscribeFailed') : t('podcast.subscribeFailed'));
}
};
onMounted(async () => {
await Promise.all([loadRadioDetail(), loadPrograms(), loadSubscribedRadios()]);
});
</script>
<style scoped lang="scss">