mirror of
https://github.com/algerkong/AlgerMusicPlayer.git
synced 2026-04-29 11:27:23 +08:00
feat: 优化 UI 逻辑适配移动端
This commit is contained in:
@@ -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">
|
<script setup lang="ts">
|
||||||
import { useMessage } from 'naive-ui';
|
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
import { usePodcastStore, useUserStore } from '@/store';
|
|
||||||
import type { DjProgram, DjRadio } from '@/types/podcast';
|
import type { DjProgram, DjRadio } from '@/types/podcast';
|
||||||
import { formatNumber, getImgUrl } from '@/utils';
|
import { formatNumber, getImgUrl } from '@/utils';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = withDefaults(
|
||||||
radio: DjRadio;
|
defineProps<{
|
||||||
program?: DjProgram;
|
radio: DjRadio;
|
||||||
showSubscribeButton?: boolean;
|
program?: DjProgram;
|
||||||
animationDelay?: string;
|
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 router = useRouter();
|
||||||
const message = useMessage();
|
|
||||||
const { t } = useI18n();
|
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();
|
e.stopPropagation();
|
||||||
if (!userStore.user) {
|
emit('subscribe', props.radio);
|
||||||
message.warning(t('history.needLogin'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await podcastStore.toggleSubscribe(props.radio);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const goToDetail = () => {
|
const goToDetail = () => {
|
||||||
|
|||||||
@@ -212,7 +212,11 @@ const { zoomFactor, initZoomFactor, increaseZoom, decreaseZoom, resetZoom, isZoo
|
|||||||
|
|
||||||
// 显示返回按钮
|
// 显示返回按钮
|
||||||
const showBackButton = computed(() => {
|
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;
|
||||||
});
|
});
|
||||||
|
|
||||||
// 返回上一页
|
// 返回上一页
|
||||||
|
|||||||
+14
-12
@@ -61,10 +61,23 @@ const layoutRouter = [
|
|||||||
title: 'comp.mv',
|
title: 'comp.mv',
|
||||||
icon: 'icon-recordfill',
|
icon: 'icon-recordfill',
|
||||||
keepAlive: true,
|
keepAlive: true,
|
||||||
isMobile: false
|
isMobile: false,
|
||||||
|
back: true
|
||||||
},
|
},
|
||||||
component: () => import('@/views/mv/index.vue')
|
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',
|
path: '/history',
|
||||||
name: 'history',
|
name: 'history',
|
||||||
@@ -88,17 +101,6 @@ const layoutRouter = [
|
|||||||
},
|
},
|
||||||
component: () => import('@/views/user/index.vue')
|
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',
|
path: '/set',
|
||||||
name: 'set',
|
name: 'set',
|
||||||
|
|||||||
@@ -133,16 +133,15 @@ const otherRouter = [
|
|||||||
component: () => import('@/views/podcast/radio.vue')
|
component: () => import('@/views/podcast/radio.vue')
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/podcast/category/:id',
|
path: '/favorite',
|
||||||
name: 'podcastCategory',
|
name: 'favorite',
|
||||||
meta: {
|
meta: {
|
||||||
title: 'podcast.category',
|
title: 'comp.homeHero.quickNav.myFavorite',
|
||||||
keepAlive: false,
|
icon: 'ri-heart-fill',
|
||||||
showInMenu: false,
|
keepAlive: true,
|
||||||
back: true,
|
back: true
|
||||||
isMobile: true
|
|
||||||
},
|
},
|
||||||
component: () => import('@/views/podcast/category.vue')
|
component: () => import('@/views/favorite/index.vue')
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/search-result',
|
path: '/search-result',
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ export * from './modules/music';
|
|||||||
export * from './modules/player';
|
export * from './modules/player';
|
||||||
export * from './modules/playerCore';
|
export * from './modules/playerCore';
|
||||||
export * from './modules/playlist';
|
export * from './modules/playlist';
|
||||||
export * from './modules/podcast';
|
|
||||||
export * from './modules/recommend';
|
export * from './modules/recommend';
|
||||||
export * from './modules/search';
|
export * from './modules/search';
|
||||||
export * from './modules/settings';
|
export * from './modules/settings';
|
||||||
|
|||||||
@@ -1,13 +1,24 @@
|
|||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import { ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
import homeRouter from '@/router/home';
|
import homeRouter from '@/router/home';
|
||||||
|
import { useSettingsStore } from '@/store/modules/settings';
|
||||||
|
|
||||||
export const useMenuStore = defineStore('menu', () => {
|
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[]) => {
|
const setMenus = (newMenus: any[]) => {
|
||||||
menus.value = newMenus;
|
allMenus.value = newMenus;
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
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>
|
<template>
|
||||||
<div class="list-page h-full w-full bg-white dark:bg-black transition-colors duration-500">
|
<div class="list-page h-full w-full bg-white dark:bg-black transition-colors duration-500">
|
||||||
<!-- 专辑地区分类 - 保持固定在顶部 -->
|
<!-- 专辑地区分类 - 保持固定在顶部 -->
|
||||||
<div
|
<category-selector
|
||||||
class="play-list-type border-b border-gray-100 dark:border-gray-800 bg-white dark:bg-black z-10"
|
:model-value="currentArea"
|
||||||
>
|
:categories="areas"
|
||||||
<n-scrollbar ref="scrollbarRef" x-scrollable>
|
label-key="name"
|
||||||
<div class="categories-wrapper py-4 pr-4 sm:pr-6 lg:pr-8" @wheel.prevent="handleWheel">
|
value-key="value"
|
||||||
<span
|
@change="handleAreaChange"
|
||||||
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>
|
|
||||||
|
|
||||||
<!-- 专辑列表 -->
|
<!-- 专辑列表 -->
|
||||||
<n-scrollbar
|
<n-scrollbar
|
||||||
@@ -31,7 +17,7 @@
|
|||||||
:size="100"
|
:size="100"
|
||||||
@scroll="handleScroll"
|
@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">
|
<div class="mb-8">
|
||||||
<h1 class="text-2xl md:text-3xl font-bold text-neutral-900 dark:text-white mb-2">
|
<h1 class="text-2xl md:text-3xl font-bold text-neutral-900 dark:text-white mb-2">
|
||||||
@@ -127,16 +113,17 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<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 { useI18n } from 'vue-i18n';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
|
||||||
import { getNewAlbums } from '@/api/album';
|
import { getNewAlbums } from '@/api/album';
|
||||||
import { getAlbum } from '@/api/list';
|
import { getAlbum } from '@/api/list';
|
||||||
|
import CategorySelector from '@/components/common/CategorySelector.vue';
|
||||||
import { navigateToMusicList } from '@/components/common/MusicListNavigator';
|
import { navigateToMusicList } from '@/components/common/MusicListNavigator';
|
||||||
import { usePlayerCoreStore } from '@/store/modules/playerCore';
|
import { usePlayerCoreStore } from '@/store/modules/playerCore';
|
||||||
import { usePlaylistStore } from '@/store/modules/playlist';
|
import { usePlaylistStore } from '@/store/modules/playlist';
|
||||||
import { calculateAnimationDelay, getImgUrl, setAnimationClass, setAnimationDelay } from '@/utils';
|
import { calculateAnimationDelay, getImgUrl } from '@/utils';
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
name: 'Album'
|
name: 'Album'
|
||||||
@@ -167,9 +154,12 @@ const currentAreaName = computed(
|
|||||||
() => areas.find((a) => a.value === currentArea.value)?.name || '全部'
|
() => areas.find((a) => a.value === currentArea.value)?.name || '全部'
|
||||||
);
|
);
|
||||||
|
|
||||||
const getAnimationDelay = computed(() => {
|
const contentScrollbarRef = ref();
|
||||||
return (index: number) => setAnimationDelay(index, 30);
|
|
||||||
});
|
const handleAreaChange = (value: string) => {
|
||||||
|
router.replace({ query: { area: value } });
|
||||||
|
loadList(value);
|
||||||
|
};
|
||||||
|
|
||||||
const loadList = async (area: string, isLoadMore = false) => {
|
const loadList = async (area: string, isLoadMore = false) => {
|
||||||
if (!hasMore.value && isLoadMore) return;
|
if (!hasMore.value && isLoadMore) return;
|
||||||
@@ -179,6 +169,7 @@ const loadList = async (area: string, isLoadMore = false) => {
|
|||||||
loading.value = true;
|
loading.value = true;
|
||||||
page.value = 0;
|
page.value = 0;
|
||||||
albumList.value = [];
|
albumList.value = [];
|
||||||
|
await nextTick();
|
||||||
contentScrollbarRef.value?.scrollTo({ top: 0 });
|
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) => {
|
const getArtistNames = (album: any) => {
|
||||||
if (album.artists) {
|
if (album.artists) {
|
||||||
return album.artists.map((ar: any) => ar.name).join(' / ');
|
return album.artists.map((ar: any) => ar.name).join(' / ');
|
||||||
@@ -302,24 +275,6 @@ watch(
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<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 {
|
.animate-item {
|
||||||
animation: fadeInUp 0.6s cubic-bezier(0.16, 1, 0.3, 1) backwards;
|
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[]>>({});
|
const albumTracksMap = reactive<Record<number, any[]>>({});
|
||||||
|
|
||||||
// Calculate display count to fill exactly N rows
|
// 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 displayAlbums = computed(() => {
|
||||||
const count = displayCount.value;
|
const count = displayCount.value;
|
||||||
|
|||||||
@@ -222,13 +222,14 @@ import { useRouter } from 'vue-router';
|
|||||||
|
|
||||||
import { getHotSearch, getPersonalFM } from '@/api/home';
|
import { getHotSearch, getPersonalFM } from '@/api/home';
|
||||||
import { navigateToMusicList } from '@/components/common/MusicListNavigator';
|
import { navigateToMusicList } from '@/components/common/MusicListNavigator';
|
||||||
import { useIntelligenceModeStore, useRecommendStore } from '@/store';
|
import { useIntelligenceModeStore, useRecommendStore, useUserStore } from '@/store';
|
||||||
import { calculateAnimationDelay, getImgUrl } from '@/utils';
|
import { calculateAnimationDelay, getImgUrl } from '@/utils';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const recommendStore = useRecommendStore();
|
const recommendStore = useRecommendStore();
|
||||||
const intelligenceModeStore = useIntelligenceModeStore();
|
const intelligenceModeStore = useIntelligenceModeStore();
|
||||||
|
const userStore = useUserStore();
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const personalFmSong = ref<any>(null);
|
const personalFmSong = ref<any>(null);
|
||||||
@@ -240,68 +241,100 @@ const personalFmCover = computed(() => personalFmSong.value?.album?.picUrl || ''
|
|||||||
const isIntelligenceMode = computed(() => intelligenceModeStore.isIntelligenceMode);
|
const isIntelligenceMode = computed(() => intelligenceModeStore.isIntelligenceMode);
|
||||||
|
|
||||||
// Quick Nav Configuration
|
// Quick Nav Configuration
|
||||||
const quickNavItems = computed(() => [
|
const quickNavItems = computed(() => {
|
||||||
{
|
const items = [
|
||||||
key: 'intelligence',
|
{
|
||||||
label: t('comp.homeHero.intelligenceMode'),
|
key: 'intelligence',
|
||||||
icon: 'ri-heart-pulse-fill',
|
label: t('comp.homeHero.intelligenceMode'),
|
||||||
iconBg: 'bg-rose-100 dark:bg-rose-900/40',
|
icon: 'ri-heart-pulse-fill',
|
||||||
iconColor: 'text-rose-500 dark:text-rose-400',
|
iconBg: 'bg-rose-100 dark:bg-rose-900/40',
|
||||||
active: isIntelligenceMode.value,
|
iconColor: 'text-rose-500 dark:text-rose-400',
|
||||||
badge: isIntelligenceMode.value ? t('comp.homeHero.playing') : null,
|
active: isIntelligenceMode.value,
|
||||||
action: toggleIntelligenceMode
|
badge: isIntelligenceMode.value ? t('comp.homeHero.playing') : null,
|
||||||
},
|
action: toggleIntelligenceMode,
|
||||||
{
|
show: !!userStore.user
|
||||||
key: 'toplist',
|
},
|
||||||
label: t('comp.toplist'),
|
{
|
||||||
icon: 'ri-trophy-fill',
|
key: 'toplist',
|
||||||
iconBg: 'bg-amber-100 dark:bg-amber-900/40',
|
label: t('comp.toplist'),
|
||||||
iconColor: 'text-amber-500 dark:text-amber-400',
|
icon: 'ri-trophy-fill',
|
||||||
active: false,
|
iconBg: 'bg-amber-100 dark:bg-amber-900/40',
|
||||||
badge: null,
|
iconColor: 'text-amber-500 dark:text-amber-400',
|
||||||
action: () => router.push('/toplist')
|
active: false,
|
||||||
},
|
badge: null,
|
||||||
{
|
action: () => router.push('/toplist'),
|
||||||
key: 'mv',
|
show: true
|
||||||
label: t('comp.mv'),
|
},
|
||||||
icon: 'ri-movie-2-fill',
|
{
|
||||||
iconBg: 'bg-violet-100 dark:bg-violet-900/40',
|
key: 'favorite',
|
||||||
iconColor: 'text-violet-500 dark:text-violet-400',
|
label: t('comp.homeHero.quickNav.myFavorite'),
|
||||||
active: false,
|
icon: 'ri-heart-fill',
|
||||||
badge: null,
|
iconBg: 'bg-red-100 dark:bg-red-900/40',
|
||||||
action: () => router.push('/mv')
|
iconColor: 'text-red-500 dark:text-red-400',
|
||||||
},
|
active: false,
|
||||||
{
|
badge: null,
|
||||||
key: 'playlist',
|
action: () => router.push('/favorite'),
|
||||||
label: t('comp.list'),
|
show: true
|
||||||
icon: 'ri-play-list-2-fill',
|
},
|
||||||
iconBg: 'bg-sky-100 dark:bg-sky-900/40',
|
{
|
||||||
iconColor: 'text-sky-500 dark:text-sky-400',
|
key: 'podcast',
|
||||||
active: false,
|
label: t('podcast.podcast'),
|
||||||
badge: null,
|
icon: 'ri-radio-2-fill',
|
||||||
action: () => router.push('/list')
|
iconBg: 'bg-cyan-100 dark:bg-cyan-900/40',
|
||||||
},
|
iconColor: 'text-cyan-500 dark:text-cyan-400',
|
||||||
{
|
active: false,
|
||||||
key: 'album',
|
badge: null,
|
||||||
label: t('comp.newAlbum.title'),
|
action: () => router.push('/podcast'),
|
||||||
icon: 'ri-album-fill',
|
show: true
|
||||||
iconBg: 'bg-orange-100 dark:bg-orange-900/40',
|
},
|
||||||
iconColor: 'text-orange-500 dark:text-orange-400',
|
{
|
||||||
active: false,
|
key: 'mv',
|
||||||
badge: null,
|
label: t('comp.mv'),
|
||||||
action: () => router.push('/album')
|
icon: 'ri-movie-2-fill',
|
||||||
},
|
iconBg: 'bg-violet-100 dark:bg-violet-900/40',
|
||||||
{
|
iconColor: 'text-violet-500 dark:text-violet-400',
|
||||||
key: 'history',
|
active: false,
|
||||||
label: t('comp.history'),
|
badge: null,
|
||||||
icon: 'ri-history-fill',
|
action: () => router.push('/mv'),
|
||||||
iconBg: 'bg-emerald-100 dark:bg-emerald-900/40',
|
show: true
|
||||||
iconColor: 'text-emerald-500 dark:text-emerald-400',
|
},
|
||||||
active: false,
|
{
|
||||||
badge: null,
|
key: 'playlist',
|
||||||
action: () => router.push('/history')
|
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 () => {
|
const fetchHeroData = async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,24 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="list-page h-full w-full bg-white dark:bg-black transition-colors duration-500">
|
<div class="list-page h-full w-full bg-white dark:bg-black transition-colors duration-500">
|
||||||
<!-- 歌单分类 - 保持固定在顶部 -->
|
<!-- 歌单分类 - 保持固定在顶部 -->
|
||||||
<div
|
<category-selector
|
||||||
class="play-list-type border-b border-gray-100 dark:border-gray-800 bg-white dark:bg-black z-10"
|
:model-value="currentType"
|
||||||
>
|
:categories="playlistCategory?.sub || []"
|
||||||
<n-scrollbar ref="scrollbarRef" x-scrollable>
|
label-key="name"
|
||||||
<div class="categories-wrapper py-4 pr-4 sm:pr-6 lg:pr-8" @wheel.prevent="handleWheel">
|
value-key="name"
|
||||||
<span
|
@change="handleTypeChange"
|
||||||
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>
|
|
||||||
|
|
||||||
<!-- 歌单列表 -->
|
<!-- 歌单列表 -->
|
||||||
<n-scrollbar
|
<n-scrollbar
|
||||||
@@ -28,7 +17,7 @@
|
|||||||
:size="100"
|
:size="100"
|
||||||
@scroll="handleScroll"
|
@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">
|
<div class="mb-8">
|
||||||
<h1 class="text-2xl md:text-3xl font-bold text-neutral-900 dark:text-white mb-2">
|
<h1 class="text-2xl md:text-3xl font-bold text-neutral-900 dark:text-white mb-2">
|
||||||
@@ -53,9 +42,15 @@
|
|||||||
<div
|
<div
|
||||||
v-for="(item, index) in recommendList"
|
v-for="(item, index) in recommendList"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
class="list-card group cursor-pointer animate-item"
|
class="list-card group cursor-pointer"
|
||||||
:style="{ animationDelay: calculateAnimationDelay(index % TOTAL_ITEMS, 0.05) }"
|
:class="{ 'animate-item': !animatedIds.has(item.id) }"
|
||||||
|
:style="{
|
||||||
|
animationDelay: !animatedIds.has(item.id)
|
||||||
|
? calculateAnimationDelay(index % TOTAL_ITEMS, 0.05)
|
||||||
|
: '0s'
|
||||||
|
}"
|
||||||
@click.stop="openPlaylist(item)"
|
@click.stop="openPlaylist(item)"
|
||||||
|
@animationend="animatedIds.add(item.id)"
|
||||||
>
|
>
|
||||||
<!-- Cover Image -->
|
<!-- Cover Image -->
|
||||||
<div
|
<div
|
||||||
@@ -114,20 +109,15 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<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 { useRoute, useRouter } from 'vue-router';
|
||||||
|
|
||||||
import { getPlaylistCategory } from '@/api/home';
|
import { getPlaylistCategory } from '@/api/home';
|
||||||
import { getListByCat } from '@/api/list';
|
import { getListByCat } from '@/api/list';
|
||||||
|
import CategorySelector from '@/components/common/CategorySelector.vue';
|
||||||
import { navigateToMusicList } from '@/components/common/MusicListNavigator';
|
import { navigateToMusicList } from '@/components/common/MusicListNavigator';
|
||||||
import type { IPlayListSort } from '@/types/playlist';
|
import type { IPlayListSort } from '@/types/playlist';
|
||||||
import {
|
import { calculateAnimationDelay, formatNumber, getImgUrl } from '@/utils';
|
||||||
calculateAnimationDelay,
|
|
||||||
formatNumber,
|
|
||||||
getImgUrl,
|
|
||||||
setAnimationClass,
|
|
||||||
setAnimationDelay
|
|
||||||
} from '@/utils';
|
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
name: 'List'
|
name: 'List'
|
||||||
@@ -139,6 +129,7 @@ const recommendList = ref<any[]>([]);
|
|||||||
const page = ref(0);
|
const page = ref(0);
|
||||||
const hasMore = ref(true);
|
const hasMore = ref(true);
|
||||||
const isLoadingMore = ref(false);
|
const isLoadingMore = ref(false);
|
||||||
|
const animatedIds = reactive(new Set<number>());
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@@ -164,6 +155,7 @@ const loadList = async (type: string, isLoadMore = false) => {
|
|||||||
loading.value = true;
|
loading.value = true;
|
||||||
page.value = 0;
|
page.value = 0;
|
||||||
recommendList.value = [];
|
recommendList.value = [];
|
||||||
|
await nextTick();
|
||||||
contentScrollbarRef.value?.scrollTo({ top: 0 });
|
contentScrollbarRef.value?.scrollTo({ top: 0 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,9 +194,7 @@ const handleScroll = (e: any) => {
|
|||||||
const playlistCategory = ref<IPlayListSort>();
|
const playlistCategory = ref<IPlayListSort>();
|
||||||
const currentType = ref((route.query.type as string) || '每日推荐');
|
const currentType = ref((route.query.type as string) || '每日推荐');
|
||||||
|
|
||||||
const getAnimationDelay = computed(() => {
|
const contentScrollbarRef = ref();
|
||||||
return (index: number) => setAnimationDelay(index, 30);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 加载歌单分类
|
// 加载歌单分类
|
||||||
const loadPlaylistCategory = async () => {
|
const loadPlaylistCategory = async () => {
|
||||||
@@ -221,23 +211,8 @@ const loadPlaylistCategory = async () => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClickPlaylistType = (type: string) => {
|
const handleTypeChange = (type: string) => {
|
||||||
if (currentType.value === type) return;
|
router.replace({ query: { ...route.query, type } });
|
||||||
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 });
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@@ -246,41 +221,27 @@ onMounted(() => {
|
|||||||
loadList(currentType.value);
|
loadList(currentType.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onDeactivated(() => {
|
||||||
|
recommendList.value.forEach((item) => animatedIds.add(item.id));
|
||||||
|
});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => route.query,
|
() => route.query,
|
||||||
async (newParams) => {
|
async (newParams) => {
|
||||||
if (newParams.type) {
|
if (route.path !== '/list') return;
|
||||||
// 如果路由参数变化,且与当前类型不同,则重新加载
|
const newType = (newParams.type as string) || '每日推荐';
|
||||||
if (newParams.type !== currentType.value) {
|
// 如果路由参数变化,且与当前类型不同,则重新加载
|
||||||
listTitle.value = (newParams.type as string) || '歌单列表';
|
if (newType !== currentType.value) {
|
||||||
currentType.value = newParams.type as string;
|
listTitle.value = newType;
|
||||||
loading.value = true;
|
currentType.value = newType;
|
||||||
loadList(newParams.type as string);
|
loading.value = true;
|
||||||
}
|
loadList(newType);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<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 {
|
.animate-item {
|
||||||
animation: fadeInUp 0.6s cubic-bezier(0.16, 1, 0.3, 1) backwards;
|
animation: fadeInUp 0.6s cubic-bezier(0.16, 1, 0.3, 1) backwards;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,14 +37,18 @@
|
|||||||
/>
|
/>
|
||||||
<!-- Play overlay on cover -->
|
<!-- Play overlay on cover -->
|
||||||
<div
|
<div
|
||||||
class="absolute inset-0 flex items-center justify-center bg-transparent group-hover:bg-black/30 transition-all duration-300 cursor-pointer"
|
class="absolute inset-0 flex items-center justify-center bg-transparent group-hover:bg-black/30 transition-all duration-300"
|
||||||
@click="handlePlayAll"
|
:class="isMobile ? 'pointer-events-none' : 'cursor-pointer'"
|
||||||
|
@click="!isMobile && handlePlayAll()"
|
||||||
>
|
>
|
||||||
<div
|
<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"
|
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" />
|
<i class="ri-play-fill text-3xl text-neutral-900 ml-1" />
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,34 +1,29 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="mv-list-page h-full w-full bg-white dark:bg-black transition-colors duration-500">
|
<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">
|
<!-- MV 分类 - 保持固定在顶部 -->
|
||||||
<div class="mv-content w-full pb-32 pt-6 px-4 sm:px-6 lg:px-8 lg:pl-0">
|
<category-selector
|
||||||
<!-- Hero Section -->
|
:model-value="selectedCategory"
|
||||||
<div class="mb-8 flex flex-col md:flex-row md:items-end justify-between gap-6">
|
:categories="categories"
|
||||||
<div>
|
@change="handleCategoryChange"
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- Category Selector (Pills) -->
|
<!-- MV 列表内容 -->
|
||||||
<div class="flex flex-wrap gap-2">
|
<n-scrollbar
|
||||||
<button
|
ref="contentScrollbarRef"
|
||||||
v-for="category in categories"
|
class="h-full"
|
||||||
:key="category.value"
|
style="height: calc(100% - 73px)"
|
||||||
class="px-4 py-1.5 rounded-full text-sm font-medium transition-all duration-300"
|
:size="100"
|
||||||
:class="[
|
@scroll="handleScroll"
|
||||||
selectedCategory === category.value
|
>
|
||||||
? 'bg-primary text-white shadow-lg shadow-primary/25 scale-105'
|
<div class="mv-content w-full pb-32 pt-6 px-4 sm:px-6 lg:px-8 lg:pl-0">
|
||||||
: 'bg-neutral-100 dark:bg-neutral-900 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-200 dark:hover:bg-neutral-800'
|
<!-- 页面标题 -->
|
||||||
]"
|
<div class="mb-8">
|
||||||
@click="selectedCategory = category.value"
|
<h1
|
||||||
>
|
class="text-3xl md:text-4xl font-bold tracking-tight text-neutral-900 dark:text-white mb-2"
|
||||||
{{ category.label }}
|
>
|
||||||
</button>
|
MV
|
||||||
</div>
|
</h1>
|
||||||
|
<p class="text-neutral-500 dark:text-neutral-400">探索精彩视频内容</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- MV Grid Container -->
|
<!-- MV Grid Container -->
|
||||||
@@ -129,9 +124,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { getAllMv, getTopMv } from '@/api/mv';
|
||||||
|
import CategorySelector from '@/components/common/CategorySelector.vue';
|
||||||
import MvPlayer from '@/components/MvPlayer.vue';
|
import MvPlayer from '@/components/MvPlayer.vue';
|
||||||
import { audioService } from '@/services/audioService';
|
import { audioService } from '@/services/audioService';
|
||||||
import { usePlayerStore } from '@/store/modules/player';
|
import { usePlayerStore } from '@/store/modules/player';
|
||||||
@@ -162,16 +159,38 @@ const categories = [
|
|||||||
];
|
];
|
||||||
const selectedCategory = ref('全部');
|
const selectedCategory = ref('全部');
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
|
const contentScrollbarRef = ref();
|
||||||
|
|
||||||
const playerStore = usePlayerStore();
|
const playerStore = usePlayerStore();
|
||||||
|
|
||||||
watch(selectedCategory, async () => {
|
const handleCategoryChange = async (value: string) => {
|
||||||
|
selectedCategory.value = value;
|
||||||
offset.value = 0;
|
offset.value = 0;
|
||||||
mvList.value = [];
|
mvList.value = [];
|
||||||
hasMore.value = true;
|
hasMore.value = true;
|
||||||
|
// 更新路由参数
|
||||||
|
router.replace({ query: { ...route.query, area: value } });
|
||||||
await loadMvList();
|
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 () => {
|
onMounted(async () => {
|
||||||
|
// 从路由获取初始分类
|
||||||
|
selectedCategory.value = (route.query.area as string) || '全部';
|
||||||
await loadMvList();
|
await loadMvList();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -259,10 +278,6 @@ const isPrevDisabled = computed(() => currentIndex.value === 0);
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.mv-list-page {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-item {
|
.animate-item {
|
||||||
animation: fadeInUp 0.6s cubic-bezier(0.16, 1, 0.3, 1) backwards;
|
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>
|
<template>
|
||||||
<div
|
<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">
|
<div class="podcast-content w-full pb-32 pt-6 px-4 sm:px-6 lg:px-8 lg:pl-0">
|
||||||
<!-- Hero Section -->
|
<!-- Dashboard View (Recommend) -->
|
||||||
<div class="mb-8 flex flex-col md:flex-row md:items-end justify-between gap-6">
|
<div v-if="currentCategoryId === -1">
|
||||||
<div>
|
<!-- 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
|
<h1
|
||||||
class="text-3xl md:text-4xl font-bold tracking-tight text-neutral-900 dark:text-white mb-2"
|
class="text-3xl md:text-4xl font-bold tracking-tight text-neutral-900 dark:text-white mb-2"
|
||||||
>
|
>
|
||||||
{{ t('podcast.podcast') }}
|
{{ currentCategoryName }}
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-neutral-500 dark:text-neutral-400">
|
<p class="text-neutral-500 dark:text-neutral-400">
|
||||||
{{ t('podcast.discover') }}
|
{{ t('podcast.exploreCategoryRadios') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Search Bar -->
|
<!-- Radios Grid -->
|
||||||
<div class="relative w-full md:w-80 group">
|
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-6">
|
||||||
<n-input
|
<template v-if="categoryLoading && categoryPage === 0">
|
||||||
v-model:value="searchKeyword"
|
<div v-for="i in 15" :key="`loading-${i}`" class="space-y-3">
|
||||||
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">
|
|
||||||
<div
|
<div
|
||||||
class="aspect-square animate-pulse rounded-2xl bg-neutral-200 dark:bg-neutral-800"
|
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-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 class="h-3 w-1/2 animate-pulse rounded bg-neutral-200 dark:bg-neutral-800" />
|
||||||
</div>
|
</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
|
<radio-card
|
||||||
v-for="(radio, index) in podcastStore.recommendRadios.slice(0, 10)"
|
v-for="(radio, index) in categoryRadios"
|
||||||
:key="radio.id"
|
:key="`cat-${radio.id}`"
|
||||||
:radio="radio"
|
:radio="radio"
|
||||||
:show-subscribe-button="true"
|
: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>
|
</template>
|
||||||
</section>
|
</div>
|
||||||
|
|
||||||
<!-- Categories Section -->
|
<!-- Empty State -->
|
||||||
<section>
|
<div
|
||||||
<div class="mb-6 flex items-center justify-between">
|
v-if="!categoryLoading && categoryRadios.length === 0"
|
||||||
<div class="flex items-center gap-3">
|
class="flex flex-col items-center justify-center py-20 text-neutral-400"
|
||||||
<h2
|
>
|
||||||
class="text-xl font-bold tracking-tight text-neutral-900 md:text-2xl dark:text-white"
|
<i class="ri-radio-line mb-4 text-5xl opacity-20" />
|
||||||
>
|
<p class="text-sm font-medium">{{ t('podcast.noCategoryRadios') }}</p>
|
||||||
{{ t('podcast.popularCategories') }}
|
</div>
|
||||||
</h2>
|
|
||||||
<div class="h-1.5 w-1.5 rounded-full bg-primary" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
<!-- Load More Spinner -->
|
||||||
<div
|
<div v-if="categoryLoadingMore" class="flex justify-center items-center py-8">
|
||||||
v-for="(category, index) in podcastStore.categories.slice(0, 12)"
|
<n-spin size="small" />
|
||||||
:key="category.id"
|
<span class="ml-2 text-neutral-500">{{ t('common.loading') }}</span>
|
||||||
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"
|
</div>
|
||||||
:style="{ animationDelay: calculateAnimationDelay(index, 0.03) }"
|
<div
|
||||||
@click="router.push(`/podcast/category/${category.id}`)"
|
v-if="!categoryHasMore && categoryRadios.length > 0"
|
||||||
>
|
class="text-center py-8 text-neutral-500"
|
||||||
<img
|
>
|
||||||
v-if="category.pic84x84Url"
|
{{ t('common.noMore') }}
|
||||||
:src="category.pic84x84Url"
|
</div>
|
||||||
: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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</n-scrollbar>
|
</n-scrollbar>
|
||||||
@@ -302,16 +282,25 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { createDiscreteApi, NButton, NInput, NScrollbar, NSpin } from 'naive-ui';
|
import { createDiscreteApi } from 'naive-ui';
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, nextTick, onMounted, ref, watch } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
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 RadioCard from '@/components/podcast/RadioCard.vue';
|
||||||
import { usePodcastHistory } from '@/hooks/PodcastHistoryHook';
|
import { usePodcastHistory } from '@/hooks/PodcastHistoryHook';
|
||||||
import { usePlayerStore, usePlaylistStore, usePodcastStore, useUserStore } from '@/store';
|
import { usePlayerStore, usePlaylistStore, useUserStore } from '@/store';
|
||||||
import type { DjProgram, DjRadio } from '@/types/podcast';
|
import type { DjCategory, DjProgram, DjRadio } from '@/types/podcast';
|
||||||
import { calculateAnimationDelay, formatNumber, getImgUrl, secondToMinute } from '@/utils';
|
import { calculateAnimationDelay, formatNumber, getImgUrl, secondToMinute } from '@/utils';
|
||||||
import { mapDjProgramToSongResult } from '@/utils/podcastUtils';
|
import { mapDjProgramToSongResult } from '@/utils/podcastUtils';
|
||||||
|
|
||||||
@@ -322,100 +311,51 @@ defineOptions({
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { message } = createDiscreteApi(['message']);
|
const { message } = createDiscreteApi(['message']);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const podcastStore = usePodcastStore();
|
const route = useRoute();
|
||||||
|
|
||||||
const playlistStore = usePlaylistStore();
|
const playlistStore = usePlaylistStore();
|
||||||
const playerStore = usePlayerStore();
|
const playerStore = usePlayerStore();
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
const { addPodcast, podcastList, clearPodcastHistory } = usePodcastHistory();
|
const { podcastList, clearPodcastHistory } = usePodcastHistory();
|
||||||
|
|
||||||
|
const contentScrollbarRef = ref();
|
||||||
const recommendedSection = ref<HTMLElement | null>(null);
|
const recommendedSection = ref<HTMLElement | null>(null);
|
||||||
|
|
||||||
// Search State
|
const currentCategoryId = ref(-1);
|
||||||
const searchKeyword = ref('');
|
|
||||||
const isSearching = ref(false);
|
|
||||||
const searchResults = ref<DjRadio[]>([]);
|
|
||||||
const searchPage = ref(0);
|
|
||||||
const hasMoreSearch = ref(false);
|
|
||||||
const showSearchResults = ref(false);
|
|
||||||
|
|
||||||
const gridStyle = computed(() => ({
|
const categories = ref<DjCategory[]>([]);
|
||||||
gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))'
|
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(() => {
|
const displayRecentPrograms = computed(() => {
|
||||||
if (userStore.user) {
|
if (userStore.user) {
|
||||||
return podcastStore.recentPrograms.slice(0, 5);
|
return recentPrograms.value.slice(0, 5);
|
||||||
}
|
}
|
||||||
return podcastList.value.slice(0, 5);
|
return podcastList.value.slice(0, 5);
|
||||||
});
|
});
|
||||||
|
|
||||||
const handlePlayTodayPerfered = () => {
|
const subscribedIdSet = computed(() => new Set(subscribedRadios.value.map((radio) => radio.id)));
|
||||||
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 handleSearch = async () => {
|
const isRadioSubscribed = (id: number) => subscribedIdSet.value.has(id);
|
||||||
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 formatDate = (timestamp: number): string => {
|
const formatDate = (timestamp: number): string => {
|
||||||
const date = new Date(timestamp);
|
const date = new Date(timestamp);
|
||||||
@@ -434,43 +374,204 @@ const formatDate = (timestamp: number): string => {
|
|||||||
return `${date.getMonth() + 1}月${date.getDate()}日`;
|
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 {
|
try {
|
||||||
const songData = mapDjProgramToSongResult(program);
|
const offset = categoryPage.value * categoryLimit.value;
|
||||||
await playerStore.setPlay(songData);
|
const res = await getDjRadioHot(id, categoryLimit.value, offset);
|
||||||
addPodcast(program);
|
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) {
|
} catch (error) {
|
||||||
console.error('播放节目失败:', error);
|
console.error('获取分类电台失败:', error);
|
||||||
|
message.error(t('common.loadFailed'));
|
||||||
|
} finally {
|
||||||
|
categoryLoading.value = false;
|
||||||
|
categoryLoadingMore.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const scrollToRecommended = () => {
|
const handleScroll = (e: Event) => {
|
||||||
if (recommendedSection.value) {
|
if (currentCategoryId.value === -1) return;
|
||||||
recommendedSection.value.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
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 () => {
|
onMounted(async () => {
|
||||||
const tasks = [
|
const queryId = route.query.category ? Number(route.query.category) : -1;
|
||||||
podcastStore.fetchCategories(),
|
currentCategoryId.value = queryId;
|
||||||
podcastStore.fetchRecommendRadios(),
|
await loadData(queryId);
|
||||||
podcastStore.fetchTodayPerfered()
|
|
||||||
];
|
|
||||||
|
|
||||||
if (userStore.user) {
|
|
||||||
tasks.push(podcastStore.fetchSubscribedRadios());
|
|
||||||
tasks.push(podcastStore.fetchRecentPrograms());
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all(tasks);
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.podcast-container {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-item {
|
.animate-item {
|
||||||
animation: fadeInUp 0.6s cubic-bezier(0.16, 1, 0.3, 1) backwards;
|
animation: fadeInUp 0.6s cubic-bezier(0.16, 1, 0.3, 1) backwards;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
>
|
>
|
||||||
<n-scrollbar class="h-full" @scroll="handleScroll">
|
<n-scrollbar class="h-full" @scroll="handleScroll">
|
||||||
<div class="radio-detail-content w-full pb-32">
|
<div class="radio-detail-content w-full pb-32">
|
||||||
<n-spin :show="podcastStore.isLoading && !podcastStore.currentRadio">
|
<n-spin :show="isLoading && !currentRadio">
|
||||||
<div v-if="podcastStore.currentRadio" class="radio-content">
|
<div v-if="currentRadio" class="radio-content">
|
||||||
<!-- Hero Section -->
|
<!-- Hero Section -->
|
||||||
<section class="hero-section relative overflow-hidden rounded-tl-2xl">
|
<section class="hero-section relative overflow-hidden rounded-tl-2xl">
|
||||||
<!-- Background Image with Blur -->
|
<!-- Background Image with Blur -->
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
<div
|
<div
|
||||||
class="absolute inset-0 bg-cover bg-center scale-110 blur-2xl opacity-40 dark:opacity-30"
|
class="absolute inset-0 bg-cover bg-center scale-110 blur-2xl opacity-40 dark:opacity-30"
|
||||||
:style="{
|
:style="{
|
||||||
backgroundImage: `url(${getImgUrl(podcastStore.currentRadio.picUrl, '800y800')})`
|
backgroundImage: `url(${getImgUrl(currentRadio.picUrl, '800y800')})`
|
||||||
}"
|
}"
|
||||||
/>
|
/>
|
||||||
<div
|
<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"
|
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
|
<img
|
||||||
:src="getImgUrl(podcastStore.currentRadio.picUrl, '500y500')"
|
:src="getImgUrl(currentRadio.picUrl, '500y500')"
|
||||||
:alt="podcastStore.currentRadio.name"
|
:alt="currentRadio.name"
|
||||||
class="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
|
class="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
|
||||||
/>
|
/>
|
||||||
<!-- Play overlay on cover -->
|
<!-- 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"
|
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" />
|
<i class="ri-radio-line text-sm" />
|
||||||
{{ podcastStore.currentRadio.category }}
|
{{ currentRadio.category }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<h1
|
<h1
|
||||||
class="radio-name text-2xl md:text-3xl lg:text-4xl font-bold text-neutral-900 dark:text-white tracking-tight"
|
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>
|
</h1>
|
||||||
|
|
||||||
<!-- Stats -->
|
<!-- Stats -->
|
||||||
@@ -75,7 +75,7 @@
|
|||||||
<i class="ri-user-follow-line text-primary text-lg" />
|
<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="text-sm font-medium text-neutral-600 dark:text-neutral-300">
|
||||||
<span class="font-bold text-neutral-900 dark:text-white">{{
|
<span class="font-bold text-neutral-900 dark:text-white">{{
|
||||||
formatNumber(podcastStore.currentRadio.subCount)
|
formatNumber(currentRadio.subCount)
|
||||||
}}</span>
|
}}</span>
|
||||||
{{ t('podcast.subscribeCount') }}
|
{{ t('podcast.subscribeCount') }}
|
||||||
</span>
|
</span>
|
||||||
@@ -84,7 +84,7 @@
|
|||||||
<i class="ri-play-list-2-line text-primary text-lg" />
|
<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="text-sm font-medium text-neutral-600 dark:text-neutral-300">
|
||||||
<span class="font-bold text-neutral-900 dark:text-white">{{
|
<span class="font-bold text-neutral-900 dark:text-white">{{
|
||||||
podcastStore.currentRadio.programCount
|
currentRadio.programCount
|
||||||
}}</span>
|
}}</span>
|
||||||
{{ t('podcast.programCount') }}
|
{{ t('podcast.programCount') }}
|
||||||
</span>
|
</span>
|
||||||
@@ -94,7 +94,7 @@
|
|||||||
<p
|
<p
|
||||||
class="mt-4 text-sm md:text-base text-neutral-600 dark:text-neutral-300 line-clamp-2 leading-relaxed max-w-2xl"
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -149,13 +149,10 @@
|
|||||||
<div class="h-1.5 w-1.5 rounded-full bg-primary" />
|
<div class="h-1.5 w-1.5 rounded-full bg-primary" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<program-list
|
<program-list :programs="currentPrograms" :loading="isLoading" />
|
||||||
:programs="podcastStore.currentPrograms"
|
|
||||||
:loading="podcastStore.isLoading"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Loading state for pagination -->
|
<!-- 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" />
|
<n-spin size="small" />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -167,15 +164,16 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { NScrollbar, NSpin, useMessage } from 'naive-ui';
|
import { createDiscreteApi, NScrollbar, NSpin } from 'naive-ui';
|
||||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useRoute } from 'vue-router';
|
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 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 { SongResult } from '@/types/music';
|
||||||
|
import type { DjProgram, DjRadio } from '@/types/podcast';
|
||||||
import { formatNumber, getImgUrl } from '@/utils';
|
import { formatNumber, getImgUrl } from '@/utils';
|
||||||
import { mapDjProgramToSongResult } from '@/utils/podcastUtils';
|
import { mapDjProgramToSongResult } from '@/utils/podcastUtils';
|
||||||
|
|
||||||
@@ -184,42 +182,109 @@ defineOptions({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const { message } = createDiscreteApi(['message']);
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const message = useMessage();
|
|
||||||
const podcastStore = usePodcastStore();
|
|
||||||
const playlistStore = usePlaylistStore();
|
const playlistStore = usePlaylistStore();
|
||||||
const playerStore = usePlayerStore();
|
const playerStore = usePlayerStore();
|
||||||
const userStore = useUserStore();
|
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 offset = ref(0);
|
||||||
|
const limit = 30;
|
||||||
|
|
||||||
|
const radioId = computed(() => Number(route.params.id));
|
||||||
|
|
||||||
|
const isSubscribed = computed(() => subscribedRadioIds.value.has(radioId.value));
|
||||||
|
|
||||||
const hasMore = computed(() => {
|
const hasMore = computed(() => {
|
||||||
if (!podcastStore.currentRadio) return false;
|
if (!currentRadio.value) return false;
|
||||||
return podcastStore.currentPrograms.length < podcastStore.currentRadio.programCount;
|
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 handleScroll = async (e: any) => {
|
||||||
const { scrollTop, scrollHeight, clientHeight } = e.target;
|
const { scrollTop, scrollHeight, clientHeight } = e.target;
|
||||||
if (scrollTop + clientHeight >= scrollHeight - 100) {
|
if (scrollTop + clientHeight >= scrollHeight - 100) {
|
||||||
if (!podcastStore.isLoading && hasMore.value) {
|
if (!loadingMore.value && hasMore.value) {
|
||||||
offset.value += 30;
|
await loadPrograms(true);
|
||||||
await podcastStore.fetchRadioPrograms(radioId.value, offset.value);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 播放全部
|
||||||
const handlePlayAll = async () => {
|
const handlePlayAll = async () => {
|
||||||
if (!podcastStore.currentRadio) return;
|
if (!currentRadio.value) return;
|
||||||
|
|
||||||
const total = podcastStore.currentRadio.programCount;
|
const total = currentRadio.value.programCount;
|
||||||
try {
|
try {
|
||||||
message.loading(t('common.loading'));
|
message.loading(t('common.loading'));
|
||||||
const { data } = await getDjProgram(radioId.value, total);
|
const { data } = await getDjProgram(radioId.value, total);
|
||||||
const allPrograms = data.programs || [];
|
const allPrograms = data.programs || [];
|
||||||
|
|
||||||
const songList: SongResult[] = allPrograms.map((program) => mapDjProgramToSongResult(program));
|
const songList: SongResult[] = allPrograms.map((program: DjProgram) =>
|
||||||
|
mapDjProgramToSongResult(program)
|
||||||
|
);
|
||||||
|
|
||||||
playlistStore.setPlayList(songList);
|
playlistStore.setPlayList(songList);
|
||||||
if (songList[0]) {
|
if (songList[0]) {
|
||||||
@@ -227,27 +292,44 @@ const handlePlayAll = async () => {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取全部节目失败:', 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 () => {
|
const handleSubscribe = async () => {
|
||||||
if (!userStore.user) {
|
if (!userStore.user) {
|
||||||
message.warning(t('history.needLogin'));
|
message.warning(t('history.needLogin'));
|
||||||
return;
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
|
|||||||
Reference in New Issue
Block a user