mirror of
https://github.com/algerkong/AlgerMusicPlayer.git
synced 2026-05-17 02:07:29 +08:00
✨ feat: 重构首页Hero、导航菜单与页面布局统一
HomeHero: - 重建每日推荐(左)+私人FM(右)双栏布局 - FM播放/暂停切换、不喜欢/下一首、背景流动动画、均衡器特效 - 修复FM数据获取(res.data.data双层结构) - 歌单预加载改为hover懒加载避免502 导航优化: - SearchBar顶部菜单: 首页/歌单/专辑/排行榜/MV/本地音乐 - 侧边栏隐藏MV和本地音乐(hideInSidebar) - 修复搜索类型切换时失焦收起(@mousedown.prevent) 页面统一: - 新建StickyTabPage通用布局组件(标题+吸顶tabs+内容slot) - 歌单/专辑/MV/播客页面统一使用StickyTabPage重构 - CategorySelector第一项添加ml-0.5防scale裁切 播客优化: - RadioCard简化去除订阅按钮、容忍radio为undefined - 去除最近播放section、loadDashboard包含loadSubscribedRadios i18n: 新碟上架→专辑(5语言)、新增fmTrash/fmNext(5语言)
This commit is contained in:
@@ -12,6 +12,7 @@
|
||||
class="py-1.5 px-4 mr-3 inline-block rounded-full cursor-pointer transition-all duration-300 text-sm font-medium bg-gray-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 hover:bg-gray-200 dark:hover:bg-neutral-700 hover:text-neutral-900 dark:hover:text-white"
|
||||
:class="[
|
||||
animationClass,
|
||||
index === 0 ? 'ml-0.5' : '',
|
||||
isActive(category) ? 'bg-primary text-white shadow-lg shadow-primary/25 scale-105' : ''
|
||||
]"
|
||||
:style="getAnimationDelay(index)"
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<div class="h-full w-full bg-white transition-colors duration-500 dark:bg-black">
|
||||
<n-scrollbar ref="scrollbarRef" class="h-full" :size="100" @scroll="handleScroll">
|
||||
<div class="w-full pb-32">
|
||||
<!-- Page Header (scrolls away) -->
|
||||
<div ref="headerRef" class="page-padding pt-6 pb-2">
|
||||
<h1 class="mb-2 text-2xl font-bold tracking-tight text-neutral-900 md:text-3xl dark:text-white">
|
||||
{{ title }}
|
||||
</h1>
|
||||
<p v-if="description" class="text-neutral-500 dark:text-neutral-400">
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Tabs (sticky on scroll) -->
|
||||
<div
|
||||
class="sticky-tabs z-10 transition-shadow duration-200"
|
||||
:class="isSticky ? 'sticky top-0 shadow-sm' : ''"
|
||||
>
|
||||
<category-selector
|
||||
:model-value="modelValue"
|
||||
:categories="categories"
|
||||
:label-key="labelKey"
|
||||
:value-key="valueKey"
|
||||
@change="(val: any) => emit('change', val)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Content slot -->
|
||||
<div class="page-padding pt-4">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</n-scrollbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
|
||||
import CategorySelector from '@/components/common/CategorySelector.vue';
|
||||
|
||||
type Category = string | number | { [key: string]: any };
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
title: string;
|
||||
description?: string;
|
||||
modelValue: any;
|
||||
categories: Category[];
|
||||
labelKey?: string;
|
||||
valueKey?: string;
|
||||
}>(),
|
||||
{
|
||||
labelKey: 'label',
|
||||
valueKey: 'value'
|
||||
}
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
change: [value: any];
|
||||
scroll: [e: any];
|
||||
}>();
|
||||
|
||||
const scrollbarRef = ref();
|
||||
const headerRef = ref<HTMLElement>();
|
||||
const isSticky = ref(false);
|
||||
|
||||
const handleScroll = (e: any) => {
|
||||
if (headerRef.value) {
|
||||
const headerBottom = headerRef.value.offsetTop + headerRef.value.offsetHeight;
|
||||
isSticky.value = e.target.scrollTop >= headerBottom;
|
||||
}
|
||||
emit('scroll', e);
|
||||
};
|
||||
|
||||
const scrollTo = (options: ScrollToOptions) => {
|
||||
scrollbarRef.value?.scrollTo(options);
|
||||
};
|
||||
|
||||
defineExpose({ scrollbarRef, scrollTo });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sticky-tabs {
|
||||
background: inherit;
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import type { DjProgram, DjRadio } from '@/types/podcast';
|
||||
@@ -8,90 +6,79 @@ import { formatNumber, getImgUrl } from '@/utils';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
radio: DjRadio;
|
||||
radio?: DjRadio;
|
||||
program?: DjProgram;
|
||||
showSubscribeButton?: boolean;
|
||||
isSubscribed?: boolean;
|
||||
animationDelay?: string;
|
||||
}>(),
|
||||
{
|
||||
showSubscribeButton: false,
|
||||
isSubscribed: false
|
||||
}
|
||||
{}
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
subscribe: [radio: DjRadio];
|
||||
}>();
|
||||
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
|
||||
const isSubscribed = computed(() => props.isSubscribed);
|
||||
|
||||
const handleSubscribe = (e: Event) => {
|
||||
e.stopPropagation();
|
||||
emit('subscribe', props.radio);
|
||||
};
|
||||
|
||||
const goToDetail = () => {
|
||||
router.push(`/podcast/radio/${props.radio.id}`);
|
||||
if (props.radio?.id) {
|
||||
router.push(`/podcast/radio/${props.radio.id}`);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="radio-card animate-item group flex flex-col rounded-2xl bg-neutral-50 dark:bg-neutral-900/50 p-4 cursor-pointer hover:bg-neutral-100 dark:hover:bg-neutral-800/50 transition-all duration-300"
|
||||
class="group cursor-pointer animate-item"
|
||||
:style="{ animationDelay }"
|
||||
@click="goToDetail"
|
||||
>
|
||||
<div class="relative overflow-hidden rounded-xl">
|
||||
<!-- Cover -->
|
||||
<div
|
||||
class="relative aspect-square overflow-hidden rounded-2xl shadow-md group-hover:shadow-xl transition-all duration-500"
|
||||
>
|
||||
<img
|
||||
:src="getImgUrl(radio.picUrl || program?.coverUrl || '', '200y200')"
|
||||
:alt="radio.name"
|
||||
class="w-full aspect-square object-cover group-hover:scale-105 transition-transform duration-500"
|
||||
:src="getImgUrl(radio?.picUrl || program?.coverUrl || '', '400y400')"
|
||||
:alt="radio?.name || ''"
|
||||
class="h-full w-full object-cover transition-transform duration-700 group-hover:scale-110"
|
||||
loading="lazy"
|
||||
/>
|
||||
<!-- Hover overlay -->
|
||||
<div
|
||||
v-if="showSubscribeButton && radio.subCount !== undefined"
|
||||
class="absolute top-2 right-2 z-10"
|
||||
class="absolute inset-0 bg-transparent group-hover:bg-black/20 transition-colors duration-300 flex items-center justify-center"
|
||||
>
|
||||
<n-button
|
||||
:type="isSubscribed ? 'default' : 'primary'"
|
||||
size="small"
|
||||
round
|
||||
@click="handleSubscribe"
|
||||
<div
|
||||
class="w-12 h-12 rounded-full bg-white/90 flex items-center justify-center opacity-0 scale-75 group-hover:opacity-100 group-hover:scale-100 transition-all duration-300 shadow-xl"
|
||||
>
|
||||
{{ isSubscribed ? t('podcast.subscribed') : t('podcast.subscribe') }}
|
||||
</n-button>
|
||||
<i class="ri-play-fill text-2xl text-neutral-900 ml-0.5"></i>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Recent played badge -->
|
||||
<div
|
||||
v-if="program"
|
||||
class="absolute bottom-0 left-0 right-0 p-2 bg-black/40 backdrop-blur-sm text-white text-[10px] truncate"
|
||||
class="absolute bottom-0 left-0 right-0 px-3 py-2 bg-gradient-to-t from-black/60 to-transparent text-white text-xs truncate"
|
||||
>
|
||||
{{ t('podcast.recentPlayed') }}: {{ program.mainSong?.name || program.name }}
|
||||
{{ program.mainSong?.name || program.name }}
|
||||
</div>
|
||||
<!-- Episode count badge -->
|
||||
<div
|
||||
v-if="radio?.programCount && !program"
|
||||
class="absolute top-3 right-3 px-2 py-1 rounded-lg bg-black/40 backdrop-blur-md text-white text-[10px] font-bold flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity duration-300"
|
||||
>
|
||||
<i class="ri-mic-fill"></i>
|
||||
{{ radio.programCount }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3
|
||||
class="mt-3 text-sm md:text-base font-semibold text-neutral-900 dark:text-white line-clamp-2 group-hover:text-primary transition-colors"
|
||||
:title="radio.name"
|
||||
>
|
||||
{{ radio.name }}
|
||||
</h3>
|
||||
|
||||
<p
|
||||
v-if="radio.desc"
|
||||
class="mt-1 text-xs md:text-sm text-neutral-500 dark:text-neutral-400 line-clamp-2"
|
||||
>
|
||||
{{ radio.desc }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="radio.subCount !== undefined"
|
||||
class="mt-2 flex items-center justify-between text-xs text-neutral-400"
|
||||
>
|
||||
<span>{{ formatNumber(radio.subCount) }} {{ t('podcast.subscribeCount') }}</span>
|
||||
<span>{{ radio.programCount }} {{ t('podcast.programCount') }}</span>
|
||||
<!-- Info -->
|
||||
<div class="mt-3 space-y-1">
|
||||
<h3
|
||||
class="text-sm md:text-base font-bold text-neutral-900 dark:text-white line-clamp-1 group-hover:text-primary transition-colors"
|
||||
:title="radio?.name || ''"
|
||||
>
|
||||
{{ radio?.name || program?.name || '' }}
|
||||
</h3>
|
||||
<p
|
||||
v-if="radio?.subCount !== undefined"
|
||||
class="text-xs text-neutral-500 dark:text-neutral-400"
|
||||
>
|
||||
{{ formatNumber(radio?.subCount || 0) }} subscribers
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user