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:
alger
2026-03-16 23:22:35 +08:00
parent 68b3700f3f
commit a3f91c45f0
17 changed files with 1184 additions and 1130 deletions
@@ -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>
+45 -58
View File
@@ -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>