mirror of
https://github.com/algerkong/AlgerMusicPlayer.git
synced 2026-07-08 18:37:31 +08:00
938c497ca3
pr-check.yml 只在 PR 触发,main push 不跑 lint,导致存量错误一直未被发现, PR #675 / #676 都被 Code Quality job 拦下。 - 9 处 prettier/prettier 格式错误:StickyTabPage.vue / RadioCard.vue / list/index.vue / mv/index.vue / podcast/index.vue 的属性换行与 import 排序,全部 eslint --fix 自动修复 - 1 处 no-undef ScrollToOptions(StickyTabPage.vue:79):在 eslint.config.mjs 的 .ts 与 .vue 配置块同时补 ScrollToOptions 到 globals,与既有的 ScrollBehavior 同级;TypeScript DOM lib 类型不在 globals.browser 里, 需手动声明
265 lines
8.1 KiB
Vue
265 lines
8.1 KiB
Vue
<template>
|
|
<div class="h-full w-full">
|
|
<sticky-tab-page
|
|
ref="pageRef"
|
|
title="MV"
|
|
:description="t('comp.pages.mv.desc')"
|
|
:model-value="selectedCategory"
|
|
:categories="categories"
|
|
@change="handleCategoryChange"
|
|
@scroll="handleScroll"
|
|
>
|
|
<!-- MV Grid -->
|
|
<div v-if="initLoading" class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
|
|
<div v-for="i in 12" :key="i" class="space-y-3">
|
|
<div class="aspect-video skeleton-shimmer rounded-2xl" />
|
|
<div class="h-4 w-3/4 skeleton-shimmer rounded-lg" />
|
|
<div class="h-3 w-1/2 skeleton-shimmer rounded-lg" />
|
|
</div>
|
|
</div>
|
|
|
|
<div v-else class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
|
|
<div
|
|
v-for="(item, index) in mvList"
|
|
:key="item.id"
|
|
class="mv-card group cursor-pointer animate-item"
|
|
:style="{ animationDelay: calculateAnimationDelay(index, 0.05) }"
|
|
@click="handleShowMv(item, index)"
|
|
>
|
|
<!-- Cover Image -->
|
|
<div
|
|
class="relative aspect-video overflow-hidden rounded-2xl shadow-md group-hover:shadow-xl transition-all duration-500"
|
|
>
|
|
<img
|
|
:src="getImgUrl(item.cover, '400y225')"
|
|
:alt="item.name"
|
|
class="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
|
|
loading="lazy"
|
|
/>
|
|
|
|
<!-- Play Overlay -->
|
|
<div
|
|
class="absolute inset-0 bg-transparent group-hover:bg-black/40 transition-colors duration-300 flex items-center justify-center"
|
|
>
|
|
<div
|
|
class="play-icon 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"
|
|
>
|
|
<i class="ri-play-fill text-2xl text-neutral-900 ml-1"></i>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Play Count Badge -->
|
|
<div
|
|
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-play-fill"></i>
|
|
{{ formatNumber(item.playCount) }}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 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"
|
|
>
|
|
{{ item.name }}
|
|
</h3>
|
|
<p class="text-xs text-neutral-500 dark:text-neutral-400 line-clamp-1">
|
|
{{ item.artistName }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Loading More / No More -->
|
|
<div class="mt-12 py-8 border-t border-neutral-100 dark:border-neutral-800">
|
|
<div v-if="loadingMore" class="flex flex-col items-center gap-4">
|
|
<n-spin size="small" />
|
|
<span class="text-xs text-neutral-400 font-medium tracking-widest uppercase">
|
|
{{ t('comp.pages.mv.loadingMore') }}
|
|
</span>
|
|
</div>
|
|
<div v-if="!hasMore && !initLoading" class="text-center">
|
|
<span class="text-xs text-neutral-400 font-medium tracking-widest uppercase opacity-50">
|
|
{{ t('comp.pages.mv.noMore') }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</sticky-tab-page>
|
|
|
|
<mv-player
|
|
v-model:show="showMv"
|
|
:current-mv="playMvItem"
|
|
:is-prev-disabled="isPrevDisabled"
|
|
@next="playNextMv"
|
|
@prev="playPrevMv"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, onMounted, ref, watch } from 'vue';
|
|
import { useI18n } from 'vue-i18n';
|
|
import { useRoute, useRouter } from 'vue-router';
|
|
|
|
import { getAllMv, getTopMv } from '@/api/mv';
|
|
import StickyTabPage from '@/components/common/StickyTabPage.vue';
|
|
import MvPlayer from '@/components/MvPlayer.vue';
|
|
import { audioService } from '@/services/audioService';
|
|
import { usePlayerStore } from '@/store/modules/player';
|
|
import { IMvItem } from '@/types/mv';
|
|
import { calculateAnimationDelay, formatNumber, getImgUrl } from '@/utils';
|
|
|
|
defineOptions({
|
|
name: 'Mv'
|
|
});
|
|
|
|
const { t } = useI18n();
|
|
const showMv = ref(false);
|
|
const mvList = ref<Array<IMvItem>>([]);
|
|
const playMvItem = ref<IMvItem>();
|
|
const initLoading = ref(false);
|
|
const loadingMore = ref(false);
|
|
const currentIndex = ref(0);
|
|
const offset = ref(0);
|
|
const limit = ref(40);
|
|
const hasMore = ref(true);
|
|
const pageRef = ref();
|
|
|
|
const categories = computed(() => [
|
|
{ label: t('comp.pages.mv.area.all'), value: '全部' },
|
|
{ label: t('comp.pages.mv.area.mainland'), value: '内地' },
|
|
{ label: t('comp.pages.mv.area.hktw'), value: '港台' },
|
|
{ label: t('comp.pages.mv.area.western'), value: '欧美' },
|
|
{ label: t('comp.pages.mv.area.japan'), value: '日本' },
|
|
{ label: t('comp.pages.mv.area.korea'), value: '韩国' }
|
|
]);
|
|
const selectedCategory = ref('全部');
|
|
|
|
const router = useRouter();
|
|
const route = useRoute();
|
|
|
|
const playerStore = usePlayerStore();
|
|
|
|
const handleCategoryChange = async (value: string) => {
|
|
selectedCategory.value = value;
|
|
offset.value = 0;
|
|
mvList.value = [];
|
|
hasMore.value = true;
|
|
router.replace({ query: { ...route.query, area: value } });
|
|
await loadMvList();
|
|
};
|
|
|
|
watch(
|
|
() => route.query,
|
|
async (newParams) => {
|
|
if (route.path !== '/mv') return;
|
|
const newArea = (newParams.area as string) || '全部';
|
|
if (newArea !== selectedCategory.value) {
|
|
selectedCategory.value = newArea;
|
|
}
|
|
}
|
|
);
|
|
|
|
onMounted(async () => {
|
|
selectedCategory.value = (route.query.area as string) || '全部';
|
|
await loadMvList();
|
|
});
|
|
|
|
const handleShowMv = async (item: IMvItem, index: number) => {
|
|
playerStore.setIsPlay(false);
|
|
audioService.pause();
|
|
showMv.value = true;
|
|
currentIndex.value = index;
|
|
playMvItem.value = item;
|
|
};
|
|
|
|
const playPrevMv = async (setLoading: (value: boolean) => void) => {
|
|
try {
|
|
if (currentIndex.value > 0) {
|
|
const prevItem = mvList.value[currentIndex.value - 1];
|
|
await handleShowMv(prevItem, currentIndex.value - 1);
|
|
}
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const playNextMv = async (setLoading: (value: boolean) => void) => {
|
|
try {
|
|
if (currentIndex.value < mvList.value.length - 1) {
|
|
const nextItem = mvList.value[currentIndex.value + 1];
|
|
await handleShowMv(nextItem, currentIndex.value + 1);
|
|
} else if (hasMore.value) {
|
|
await loadMvList();
|
|
if (mvList.value.length > currentIndex.value + 1) {
|
|
const nextItem = mvList.value[currentIndex.value + 1];
|
|
await handleShowMv(nextItem, currentIndex.value + 1);
|
|
} else {
|
|
showMv.value = false;
|
|
}
|
|
} else {
|
|
showMv.value = false;
|
|
}
|
|
} catch (error) {
|
|
console.error('加载更多MV失败:', error);
|
|
showMv.value = false;
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const loadMvList = async () => {
|
|
try {
|
|
if (!hasMore.value || loadingMore.value) return;
|
|
if (offset.value === 0) {
|
|
initLoading.value = true;
|
|
} else {
|
|
loadingMore.value = true;
|
|
}
|
|
|
|
const params = {
|
|
limit: limit.value,
|
|
offset: offset.value,
|
|
area: selectedCategory.value === '全部' ? '' : selectedCategory.value
|
|
};
|
|
|
|
const res = selectedCategory.value === '全部' ? await getTopMv(params) : await getAllMv(params);
|
|
|
|
const { data } = res.data;
|
|
mvList.value.push(...data);
|
|
hasMore.value = data.length === limit.value;
|
|
offset.value += limit.value;
|
|
} finally {
|
|
initLoading.value = false;
|
|
loadingMore.value = false;
|
|
}
|
|
};
|
|
|
|
const handleScroll = (e: any) => {
|
|
const { scrollTop, clientHeight, scrollHeight } = e.target;
|
|
if (scrollHeight - (scrollTop + clientHeight) < 150) {
|
|
loadMvList();
|
|
}
|
|
};
|
|
|
|
const isPrevDisabled = computed(() => currentIndex.value === 0);
|
|
</script>
|
|
|
|
<style scoped lang="scss">
|
|
.animate-item {
|
|
animation: fadeInUp 0.6s cubic-bezier(0.16, 1, 0.3, 1) backwards;
|
|
}
|
|
|
|
@keyframes fadeInUp {
|
|
from {
|
|
opacity: 0;
|
|
transform: translateY(24px);
|
|
}
|
|
to {
|
|
opacity: 1;
|
|
transform: translateY(0);
|
|
}
|
|
}
|
|
</style>
|