mirror of
https://github.com/algerkong/AlgerMusicPlayer.git
synced 2026-04-15 07:20:49 +08:00
feat: 优化 UI 逻辑适配移动端
This commit is contained in:
121
src/renderer/components/common/CategorySelector.vue
Normal file
121
src/renderer/components/common/CategorySelector.vue
Normal 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>
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
// 返回上一页
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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']
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user