mirror of
https://github.com/algerkong/AlgerMusicPlayer.git
synced 2026-04-03 14:20:50 +08:00
refactor: 调整下载/歌词/MV/歌单/榜单等页面
This commit is contained in:
@@ -1,188 +1,418 @@
|
||||
<template>
|
||||
<n-scrollbar v-loading="loading" class="artist-page">
|
||||
<!-- 歌手信息头部 -->
|
||||
<div class="artist-header">
|
||||
<div class="artist-cover">
|
||||
<n-image
|
||||
:src="getImgUrl(artistInfo?.avatar, '300y300')"
|
||||
class="artist-avatar"
|
||||
preview-disabled
|
||||
/>
|
||||
</div>
|
||||
<div class="artist-info">
|
||||
<h1 class="artist-name">{{ artistInfo?.name }}</h1>
|
||||
<div v-if="artistInfo?.alias?.length" class="artist-alias">
|
||||
{{ artistInfo.alias.join(' / ') }}
|
||||
</div>
|
||||
<div v-if="artistInfo?.briefDesc" class="artist-desc">
|
||||
{{ artistInfo.briefDesc }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 标签页切换 -->
|
||||
<n-tabs v-model:value="activeTab" class="content-tabs" type="line" animated>
|
||||
<n-tab-pane name="songs" :tab="t('artist.hotSongs')">
|
||||
<!-- 添加歌曲操作工具栏 -->
|
||||
<div class="songs-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<n-tooltip placement="bottom" trigger="hover">
|
||||
<template #trigger>
|
||||
<div class="action-button hover-green" @click="handlePlayAll">
|
||||
<i class="icon iconfont ri-play-fill"></i>
|
||||
</div>
|
||||
</template>
|
||||
{{ t('comp.musicList.playAll') }}
|
||||
</n-tooltip>
|
||||
|
||||
<n-tooltip placement="bottom" trigger="hover">
|
||||
<template #trigger>
|
||||
<div class="action-button hover-green" @click="addToPlaylist">
|
||||
<i class="icon iconfont ri-add-line"></i>
|
||||
</div>
|
||||
</template>
|
||||
{{ t('comp.musicList.addToPlaylist') }}
|
||||
</n-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-right">
|
||||
<!-- 布局切换按钮 -->
|
||||
<div class="layout-toggle" v-if="!isMobile">
|
||||
<n-tooltip placement="bottom" trigger="hover">
|
||||
<template #trigger>
|
||||
<div class="toggle-button hover-green" @click="toggleLayout">
|
||||
<i
|
||||
class="icon iconfont"
|
||||
:class="isCompactLayout ? 'ri-list-check-2' : 'ri-grid-line'"
|
||||
></i>
|
||||
<div
|
||||
class="artist-detail-page h-full w-full bg-white dark:bg-neutral-900 transition-colors duration-500"
|
||||
>
|
||||
<n-scrollbar ref="scrollbarRef" class="h-full">
|
||||
<div class="artist-detail-content w-full pb-32">
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="artist-content">
|
||||
<!-- Hero Skeleton -->
|
||||
<div class="hero-section relative h-[400px] overflow-hidden rounded-tl-2xl">
|
||||
<div class="hero-bg absolute inset-0 -top-20">
|
||||
<div class="absolute inset-0 bg-neutral-200 dark:bg-neutral-800" />
|
||||
</div>
|
||||
<div class="hero-content relative z-10 px-4 pb-6 pt-4 md:px-8 md:pt-8">
|
||||
<div class="flex flex-col items-center gap-6 md:flex-row md:items-end md:gap-10">
|
||||
<n-skeleton class="h-36 w-36 rounded-full md:h-48 md:w-48" />
|
||||
<div class="flex-1 space-y-4 text-center md:text-left">
|
||||
<n-skeleton class="h-6 w-20 rounded-full" />
|
||||
<n-skeleton class="h-10 w-1/2 md:h-12" />
|
||||
<div class="flex justify-center gap-4 md:justify-start">
|
||||
<n-skeleton class="h-6 w-24" />
|
||||
<n-skeleton class="h-6 w-24" />
|
||||
</div>
|
||||
</template>
|
||||
{{
|
||||
isCompactLayout
|
||||
? t('comp.musicList.switchToNormal')
|
||||
: t('comp.musicList.switchToCompact')
|
||||
}}
|
||||
</n-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索框 -->
|
||||
<div class="search-container" :class="{ 'search-expanded': isSearchVisible }">
|
||||
<template v-if="isSearchVisible">
|
||||
<n-input
|
||||
v-model:value="searchKeyword"
|
||||
:placeholder="t('comp.musicList.searchSongs')"
|
||||
clearable
|
||||
round
|
||||
size="small"
|
||||
@blur="handleSearchBlur"
|
||||
>
|
||||
<template #prefix>
|
||||
<i class="icon iconfont ri-search-line text-sm"></i>
|
||||
</template>
|
||||
<template #suffix>
|
||||
<i
|
||||
class="icon iconfont ri-close-line text-sm cursor-pointer"
|
||||
@click="closeSearch"
|
||||
></i>
|
||||
</template>
|
||||
</n-input>
|
||||
</template>
|
||||
<template v-else>
|
||||
<n-tooltip placement="bottom" trigger="hover">
|
||||
<template #trigger>
|
||||
<div class="search-button" @click="showSearch">
|
||||
<i class="icon iconfont ri-search-line"></i>
|
||||
</div>
|
||||
</template>
|
||||
{{ t('comp.musicList.searchSongs') }}
|
||||
</n-tooltip>
|
||||
</template>
|
||||
</div>
|
||||
<!-- Content Skeleton -->
|
||||
<div class="mt-8 px-4 md:px-8">
|
||||
<div class="space-y-4">
|
||||
<div v-for="i in 8" :key="i" class="flex items-center gap-4">
|
||||
<n-skeleton class="h-12 w-12 rounded-xl" />
|
||||
<div class="flex-1 space-y-2">
|
||||
<n-skeleton text class="w-1/3" />
|
||||
<n-skeleton text class="w-1/4" />
|
||||
</div>
|
||||
<n-skeleton class="h-8 w-8 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="songs-list">
|
||||
<div class="song-list-content">
|
||||
<div v-if="filteredSongs.length === 0 && searchKeyword" class="no-result">
|
||||
{{ t('comp.musicList.noSearchResults') }}
|
||||
<!-- Main Content -->
|
||||
<div v-else-if="artistInfo" class="artist-content">
|
||||
<!-- Hero Section -->
|
||||
<section class="hero-section relative overflow-hidden overflow-hidden rounded-tl-2xl">
|
||||
<!-- Background Image with Blur -->
|
||||
<div class="hero-bg absolute inset-0 -top-20">
|
||||
<div
|
||||
class="absolute inset-0 bg-cover bg-center scale-110 blur-2xl opacity-40 dark:opacity-30"
|
||||
:style="{
|
||||
backgroundImage: `url(${getImgUrl(artistInfo.cover || artistInfo.picUrl, '800y800')})`
|
||||
}"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-b from-transparent via-white/80 to-white dark:via-neutral-900/80 dark:to-neutral-900"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 替换原来的 v-for 循环为虚拟列表 -->
|
||||
<n-virtual-list
|
||||
ref="songListRef"
|
||||
class="song-virtual-list"
|
||||
style="height: calc(80vh - 60px)"
|
||||
:items="filteredSongs"
|
||||
:item-size="isCompactLayout ? 50 : 70"
|
||||
item-resizable
|
||||
key-field="id"
|
||||
@scroll="handleVirtualScroll"
|
||||
>
|
||||
<template #default="{ item, index }">
|
||||
<div>
|
||||
<div class="double-item">
|
||||
<song-item
|
||||
:index="index"
|
||||
:compact="isCompactLayout"
|
||||
:item="formatSong(item)"
|
||||
@play="handlePlay"
|
||||
<!-- Hero Content -->
|
||||
<div class="hero-content relative z-10 px-4 md:px-8 pt-4 md:pt-8 pb-6">
|
||||
<div class="flex flex-col md:flex-row gap-6 md:gap-10 items-center md:items-end">
|
||||
<!-- Artist Avatar -->
|
||||
<div class="artist-avatar-wrapper relative group">
|
||||
<div
|
||||
class="avatar-glow absolute -inset-2 rounded-full bg-gradient-to-br from-primary/30 via-primary/10 to-transparent blur-xl opacity-0 group-hover:opacity-100 transition-opacity duration-500"
|
||||
/>
|
||||
<div
|
||||
class="avatar-container relative w-36 h-36 md:w-48 md:h-48 rounded-full overflow-hidden shadow-2xl ring-4 ring-white/50 dark:ring-neutral-800/50"
|
||||
>
|
||||
<img
|
||||
:src="getImgUrl(artistInfo.cover || artistInfo.picUrl, '500y500')"
|
||||
:alt="artistInfo.name"
|
||||
class="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
|
||||
/>
|
||||
<!-- Play overlay on avatar -->
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-center bg-black/0 group-hover:bg-black/30 transition-all duration-300"
|
||||
>
|
||||
<div
|
||||
class="play-icon w-14 h-14 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 cursor-pointer hover:scale-110 active:scale-95"
|
||||
@click="handlePlayAll"
|
||||
>
|
||||
<i class="iconfont icon-playfill text-2xl text-neutral-900 ml-1" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="index === filteredSongs.length - 1" class="h-36"></div>
|
||||
</div>
|
||||
</template>
|
||||
</n-virtual-list>
|
||||
|
||||
<div v-if="songLoading" class="loading-more">{{ t('common.loading') }}</div>
|
||||
<!-- Artist Info -->
|
||||
<div class="artist-info flex-1 text-center md:text-left">
|
||||
<div class="artist-badge mb-2 md:mb-3">
|
||||
<span
|
||||
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="iconfont icon-verified text-sm" />
|
||||
Artist
|
||||
</span>
|
||||
</div>
|
||||
<h1
|
||||
class="artist-name text-3xl md:text-4xl lg:text-5xl font-bold text-neutral-900 dark:text-white tracking-tight"
|
||||
>
|
||||
{{ artistInfo.name }}
|
||||
</h1>
|
||||
|
||||
<!-- Stats -->
|
||||
<div
|
||||
class="artist-stats flex flex-wrap items-center justify-center md:justify-start gap-4 md:gap-6 mt-4 md:mt-5"
|
||||
>
|
||||
<div v-if="artistInfo.musicSize" class="stat-item flex items-center gap-2">
|
||||
<i class="iconfont icon-music text-primary text-lg" />
|
||||
<span class="text-sm font-medium text-neutral-600 dark:text-neutral-300">
|
||||
<span class="font-bold text-neutral-900 dark:text-white">{{
|
||||
artistInfo.musicSize
|
||||
}}</span>
|
||||
{{ t('artist.hotSongs') }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="artistInfo.albumSize" class="stat-item flex items-center gap-2">
|
||||
<i class="iconfont icon-album text-primary text-lg" />
|
||||
<span class="text-sm font-medium text-neutral-600 dark:text-neutral-300">
|
||||
<span class="font-bold text-neutral-900 dark:text-white">{{
|
||||
artistInfo.albumSize
|
||||
}}</span>
|
||||
{{ t('artist.albums') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Action Bar -->
|
||||
<section
|
||||
class="action-bar sticky top-0 z-20 px-4 md:px-8 py-3 md:py-4 bg-white/80 dark:bg-neutral-900/80 backdrop-blur-xl border-b border-neutral-100 dark:border-neutral-800/50"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<!-- Left Actions -->
|
||||
<div class="flex items-center gap-2 md:gap-3">
|
||||
<!-- Play All Button -->
|
||||
<button
|
||||
class="play-all-btn flex items-center gap-2 px-4 md:px-5 py-2 md:py-2.5 rounded-full bg-primary hover:bg-primary/90 text-white font-semibold text-sm transition-all duration-200 hover:scale-105 active:scale-95 shadow-lg shadow-primary/25"
|
||||
@click="handlePlayAll"
|
||||
>
|
||||
<i class="iconfont icon-playfill text-lg" />
|
||||
<span class="hidden sm:inline">{{ t('comp.musicList.playAll') }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Add to Playlist Button -->
|
||||
<button
|
||||
class="add-btn flex items-center justify-center w-10 h-10 md:w-auto md:h-auto md:px-4 md:py-2.5 rounded-full md:rounded-full bg-neutral-100 dark:bg-neutral-800 hover:bg-neutral-200 dark:hover:bg-neutral-700 text-neutral-700 dark:text-neutral-200 font-medium text-sm transition-all duration-200 hover:scale-105 active:scale-95"
|
||||
@click="addToPlaylist"
|
||||
>
|
||||
<i class="iconfont icon-add text-lg" />
|
||||
<span class="hidden md:inline ml-2">{{ t('comp.musicList.addToPlaylist') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Right Actions -->
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Search Toggle -->
|
||||
<button
|
||||
v-if="activeTab === 'songs'"
|
||||
class="action-btn w-10 h-10 rounded-full flex items-center justify-center transition-all duration-200 hover:scale-105 active:scale-95"
|
||||
:class="
|
||||
isSearchVisible
|
||||
? 'bg-primary/10 dark:bg-primary/20 text-primary'
|
||||
: 'bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-300 hover:bg-neutral-200 dark:hover:bg-neutral-700'
|
||||
"
|
||||
@click="isSearchVisible ? closeSearch() : showSearch()"
|
||||
>
|
||||
<i class="iconfont" :class="isSearchVisible ? 'icon-close' : 'icon-search'" />
|
||||
</button>
|
||||
|
||||
<!-- Layout Toggle (Desktop only) -->
|
||||
<button
|
||||
v-if="activeTab === 'songs' && !isMobile"
|
||||
class="action-btn w-10 h-10 rounded-full flex items-center justify-center bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-300 hover:bg-neutral-200 dark:hover:bg-neutral-700 transition-all duration-200 hover:scale-105 active:scale-95"
|
||||
:title="
|
||||
isCompactLayout
|
||||
? t('comp.musicList.switchToNormal')
|
||||
: t('comp.musicList.switchToCompact')
|
||||
"
|
||||
@click="toggleLayout"
|
||||
>
|
||||
<i class="iconfont" :class="isCompactLayout ? 'icon-list' : 'icon-menu'" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search Input (Expandable) -->
|
||||
<Transition name="search-slide">
|
||||
<div v-if="isSearchVisible && activeTab === 'songs'" class="search-container mt-3">
|
||||
<div
|
||||
class="relative flex items-center bg-neutral-100 dark:bg-neutral-800 rounded-xl overflow-hidden"
|
||||
>
|
||||
<i class="iconfont icon-search text-neutral-400 dark:text-neutral-500 ml-4" />
|
||||
<input
|
||||
v-model="searchKeyword"
|
||||
type="text"
|
||||
:placeholder="t('comp.musicList.searchSongs')"
|
||||
class="flex-1 px-3 py-2.5 bg-transparent text-sm text-neutral-900 dark:text-white placeholder-neutral-400 dark:placeholder-neutral-500 outline-none"
|
||||
@blur="handleSearchBlur"
|
||||
/>
|
||||
<button
|
||||
v-if="searchKeyword"
|
||||
class="px-3 text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300"
|
||||
@click="searchKeyword = ''"
|
||||
>
|
||||
<i class="iconfont icon-close text-sm" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</section>
|
||||
|
||||
<!-- Tab Navigation -->
|
||||
<section class="tab-nav px-4 md:px-8 pt-4 md:pt-6">
|
||||
<div
|
||||
v-else-if="songPage.hasMore"
|
||||
ref="songsLoadMoreRef"
|
||||
class="load-more-trigger"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</n-tab-pane>
|
||||
class="tab-list relative flex gap-1 p-1 bg-neutral-100 dark:bg-neutral-800/50 rounded-xl w-fit"
|
||||
>
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.value"
|
||||
class="tab-item relative px-4 md:px-6 py-2 md:py-2.5 rounded-lg text-sm font-medium transition-all duration-200"
|
||||
:class="
|
||||
activeTab === tab.value
|
||||
? 'text-neutral-900 dark:text-white'
|
||||
: 'text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300'
|
||||
"
|
||||
@click="activeTab = tab.value"
|
||||
>
|
||||
<span class="relative z-10">{{ tab.label }}</span>
|
||||
<!-- Active indicator -->
|
||||
<Transition name="tab-indicator">
|
||||
<div
|
||||
v-if="activeTab === tab.value"
|
||||
class="absolute inset-0 bg-white dark:bg-neutral-700 rounded-lg shadow-sm"
|
||||
/>
|
||||
</Transition>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<n-tab-pane name="albums" :tab="t('artist.albums')">
|
||||
<div class="albums-list">
|
||||
<div class="albums-grid">
|
||||
<search-item
|
||||
v-for="album in albums"
|
||||
:key="album.id"
|
||||
shape="square"
|
||||
:item="{
|
||||
id: album.id,
|
||||
picUrl: album.picUrl,
|
||||
name: album.name,
|
||||
desc: formatPublishTime(album.publishTime),
|
||||
size: album.size,
|
||||
type: '专辑'
|
||||
}"
|
||||
/>
|
||||
<div v-if="albumLoading" class="loading-more">{{ t('common.loading') }}</div>
|
||||
<div
|
||||
v-else-if="albumPage.hasMore"
|
||||
ref="albumsLoadMoreRef"
|
||||
class="load-more-trigger"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</n-tab-pane>
|
||||
<!-- Tab Content -->
|
||||
<section class="tab-content px-4 md:px-8 py-6 md:py-8">
|
||||
<!-- Songs Tab -->
|
||||
<div v-show="activeTab === 'songs'" class="songs-tab">
|
||||
<!-- No Results -->
|
||||
<div
|
||||
v-if="filteredSongs.length === 0 && searchKeyword"
|
||||
class="empty-state flex flex-col items-center justify-center py-16"
|
||||
>
|
||||
<i
|
||||
class="iconfont icon-search text-5xl text-neutral-300 dark:text-neutral-600 mb-4"
|
||||
/>
|
||||
<p class="text-neutral-500 dark:text-neutral-400">
|
||||
{{ t('comp.musicList.noSearchResults') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<n-tab-pane name="about" :tab="t('artist.description')">
|
||||
<div class="artist-description">
|
||||
<div class="description-content" v-html="artistInfo?.briefDesc"></div>
|
||||
</div>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
<!-- Song List with CSS optimization -->
|
||||
<div v-else class="song-list" :class="{ 'compact-mode': isCompactLayout }">
|
||||
<div
|
||||
v-for="(song, index) in filteredSongs"
|
||||
:key="song.id"
|
||||
class="song-item-container"
|
||||
>
|
||||
<song-item
|
||||
:item="formatSong(song)"
|
||||
:compact="isCompactLayout"
|
||||
:index="index"
|
||||
@play="handlePlay(song)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Load More Trigger -->
|
||||
<div ref="songsLoadMoreRef" class="load-more-trigger py-8">
|
||||
<div v-if="songLoading" class="flex items-center justify-center gap-2">
|
||||
<div
|
||||
class="w-5 h-5 border-2 border-primary/30 border-t-primary rounded-full animate-spin"
|
||||
/>
|
||||
<span class="text-sm text-neutral-400 dark:text-neutral-500">{{
|
||||
t('common.loading') || 'Loading...'
|
||||
}}</span>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!songPage.hasMore && songs.length > 0"
|
||||
class="text-center text-sm text-neutral-400 dark:text-neutral-500"
|
||||
>
|
||||
— {{ t('common.noMore') || 'No more' }} —
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Albums Tab -->
|
||||
<div v-show="activeTab === 'albums'" class="albums-tab">
|
||||
<!-- Album Grid -->
|
||||
<div
|
||||
v-if="albums.length > 0"
|
||||
class="album-grid grid grid-cols-2 gap-4 gap-y-6 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6"
|
||||
>
|
||||
<div
|
||||
v-for="(album, index) in albums"
|
||||
:key="album.id"
|
||||
class="album-card group cursor-pointer"
|
||||
:style="{ animationDelay: calculateAnimationDelay(index, 0.03) }"
|
||||
@click="handleAlbumClick(album)"
|
||||
>
|
||||
<!-- Cover -->
|
||||
<div
|
||||
class="album-cover relative aspect-square overflow-hidden rounded-2xl shadow-lg"
|
||||
>
|
||||
<img
|
||||
:src="getImgUrl(album.picUrl, '500y500')"
|
||||
:alt="album.name"
|
||||
class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
/>
|
||||
<!-- Play Overlay -->
|
||||
<div
|
||||
class="play-overlay absolute inset-0 flex items-center justify-center bg-black/0 opacity-0 group-hover:bg-black/20 group-hover:opacity-100 transition-all duration-300"
|
||||
>
|
||||
<div
|
||||
class="play-icon w-12 h-12 rounded-full bg-white/90 flex items-center justify-center scale-75 group-hover:scale-100 transition-transform duration-300 shadow-xl"
|
||||
>
|
||||
<i class="iconfont icon-playfill text-xl text-neutral-900 ml-0.5" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="album-info mt-3">
|
||||
<h3
|
||||
class="album-name line-clamp-2 text-sm font-semibold text-neutral-800 dark:text-neutral-100 group-hover:text-primary dark:group-hover:text-primary transition-colors"
|
||||
>
|
||||
{{ album.name }}
|
||||
</h3>
|
||||
<p class="album-date mt-1 text-xs text-neutral-400 dark:text-neutral-500">
|
||||
{{ formatPublishTime(album.publishTime) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Load More Trigger -->
|
||||
<div ref="albumsLoadMoreRef" class="load-more-trigger py-8">
|
||||
<div v-if="albumLoading" class="flex items-center justify-center gap-2">
|
||||
<div
|
||||
class="w-5 h-5 border-2 border-primary/30 border-t-primary rounded-full animate-spin"
|
||||
/>
|
||||
<span class="text-sm text-neutral-400 dark:text-neutral-500">{{
|
||||
t('common.loading') || 'Loading...'
|
||||
}}</span>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!albumPage.hasMore && albums.length > 0"
|
||||
class="text-center text-sm text-neutral-400 dark:text-neutral-500"
|
||||
>
|
||||
— {{ t('common.noMore') || 'No more' }} —
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- About Tab -->
|
||||
<div v-show="activeTab === 'about'" class="about-tab">
|
||||
<div class="about-content">
|
||||
<h2
|
||||
class="text-xl md:text-2xl font-bold text-neutral-900 dark:text-white mb-4 md:mb-6"
|
||||
>
|
||||
{{ t('artist.description') }}
|
||||
</h2>
|
||||
<div
|
||||
v-if="artistInfo.briefDesc"
|
||||
class="prose prose-neutral dark:prose-invert max-w-none"
|
||||
>
|
||||
<p
|
||||
class="text-sm md:text-base leading-relaxed text-neutral-600 dark:text-neutral-300 whitespace-pre-line"
|
||||
>
|
||||
{{ artistInfo.briefDesc }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="empty-state flex flex-col items-center justify-center py-16 text-neutral-400 dark:text-neutral-500"
|
||||
>
|
||||
<i class="iconfont icon-info text-5xl mb-4 opacity-50" />
|
||||
<p>{{ t('common.noData') || 'No description available' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Empty State (No Artist) -->
|
||||
<div
|
||||
v-else
|
||||
class="empty-state flex flex-col items-center justify-center min-h-[60vh] text-neutral-400 dark:text-neutral-500"
|
||||
>
|
||||
<i class="iconfont icon-user text-6xl mb-4 opacity-30" />
|
||||
<p>{{ t('common.noData') || 'Artist not found' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</n-scrollbar>
|
||||
|
||||
<!-- Bottom Player Spacer -->
|
||||
<play-bottom />
|
||||
</n-scrollbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useDateFormat } from '@vueuse/core';
|
||||
import { useMessage } from 'naive-ui';
|
||||
import { NScrollbar, useMessage } from 'naive-ui';
|
||||
import PinyinMatch from 'pinyin-match';
|
||||
import {
|
||||
computed,
|
||||
@@ -199,12 +429,13 @@ import { useRoute } from 'vue-router';
|
||||
|
||||
import { getArtistAlbums, getArtistDetail, getArtistTopSongs } from '@/api/artist';
|
||||
import { getMusicDetail } from '@/api/music';
|
||||
import { navigateToMusicList } from '@/components/common/MusicListNavigator';
|
||||
import PlayBottom from '@/components/common/PlayBottom.vue';
|
||||
import SearchItem from '@/components/common/SearchItem.vue';
|
||||
import SongItem from '@/components/common/SongItem.vue';
|
||||
import router from '@/router';
|
||||
import { usePlayerStore } from '@/store';
|
||||
import { IArtist } from '@/types/artist';
|
||||
import { getImgUrl, isMobile } from '@/utils';
|
||||
import { calculateAnimationDelay, getImgUrl, isMobile } from '@/utils';
|
||||
|
||||
defineOptions({
|
||||
name: 'ArtistDetail'
|
||||
@@ -218,6 +449,15 @@ const message = useMessage();
|
||||
const artistId = computed(() => Number(route.params.id));
|
||||
const activeTab = ref('songs');
|
||||
|
||||
const scrollbarRef = ref<any>(null);
|
||||
|
||||
// Tab configuration
|
||||
const tabs = computed(() => [
|
||||
{ value: 'songs', label: t('artist.hotSongs') },
|
||||
{ value: 'albums', label: t('artist.albums') },
|
||||
{ value: 'about', label: t('artist.description') }
|
||||
]);
|
||||
|
||||
// 歌手信息
|
||||
const artistInfo = ref<IArtist>();
|
||||
const songs = ref<any[]>([]);
|
||||
@@ -263,10 +503,34 @@ const isCompactLayout = ref(
|
||||
isMobile.value ? false : localStorage.getItem('musicListLayout') === 'compact'
|
||||
);
|
||||
|
||||
// 导航到专辑详情
|
||||
const handleAlbumClick = async (album: any) => {
|
||||
try {
|
||||
navigateToMusicList(router, {
|
||||
id: album.id,
|
||||
type: 'album',
|
||||
name: album.name,
|
||||
listInfo: {
|
||||
...album,
|
||||
coverImgUrl: album.picUrl
|
||||
},
|
||||
canRemove: false
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to navigate to album:', error);
|
||||
message.error(t('common.loadFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
// 加载歌手信息
|
||||
const loadArtistInfo = async () => {
|
||||
if (!artistId.value) return;
|
||||
|
||||
// 滚动到顶部
|
||||
nextTick(() => {
|
||||
scrollbarRef.value?.scrollTo(0, 0);
|
||||
});
|
||||
|
||||
// 简化缓存检查
|
||||
const cacheKey = getCacheKey(artistId.value);
|
||||
if (artistDataCache.has(cacheKey)) {
|
||||
@@ -606,6 +870,11 @@ onActivated(() => {
|
||||
if (route.name === 'artistDetail') {
|
||||
const currentId = route.params.id as string;
|
||||
|
||||
// 滚动到顶部
|
||||
nextTick(() => {
|
||||
scrollbarRef.value?.scrollTo(0, 0);
|
||||
});
|
||||
|
||||
// 首次加载或ID变化时加载数据
|
||||
if (!previousId.value || previousId.value !== currentId) {
|
||||
console.log('ID已变化,加载新数据');
|
||||
@@ -646,10 +915,7 @@ onUnmounted(() => {
|
||||
}
|
||||
});
|
||||
|
||||
// 定义在script setup部分
|
||||
const songListRef = ref(null);
|
||||
|
||||
// 格式化歌曲(使用在虚拟列表中)
|
||||
// 格式化歌曲(使用在列表中)
|
||||
const formatSong = (item: any) => {
|
||||
if (!item) {
|
||||
return null;
|
||||
@@ -659,168 +925,150 @@ const formatSong = (item: any) => {
|
||||
picUrl: item.al?.picUrl || item.picUrl
|
||||
};
|
||||
};
|
||||
|
||||
// 处理虚拟列表滚动
|
||||
const handleVirtualScroll = (e: any) => {
|
||||
if (!e || !e.target) return;
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = e.target;
|
||||
const threshold = 200;
|
||||
|
||||
if (
|
||||
scrollHeight - scrollTop - clientHeight < threshold &&
|
||||
!songLoading.value &&
|
||||
songPage.value.hasMore &&
|
||||
!searchKeyword.value // 搜索状态下不触发加载更多
|
||||
) {
|
||||
loadSongs();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.artist-page {
|
||||
@apply min-h-screen w-full bg-light dark:bg-dark pb-24;
|
||||
/* Artist Detail Page Styles */
|
||||
.artist-detail-page {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav-header {
|
||||
@apply flex items-center px-4 py-3 sticky top-0 bg-light dark:bg-dark z-10;
|
||||
/* Hero Section */
|
||||
.hero-section {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
i {
|
||||
@apply text-xl mr-4 cursor-pointer;
|
||||
}
|
||||
/* Action Bar Sticky Behavior */
|
||||
.action-bar {
|
||||
transition:
|
||||
background-color 0.3s,
|
||||
box-shadow 0.3s;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
@apply text-base font-medium truncate;
|
||||
}
|
||||
/* Tab Indicator Animation */
|
||||
.tab-item {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.tab-item > div {
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.tab-indicator-enter-active,
|
||||
.tab-indicator-leave-active {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.tab-indicator-enter-from,
|
||||
.tab-indicator-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* Search Slide Animation */
|
||||
.search-slide-enter-active,
|
||||
.search-slide-leave-active {
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
|
||||
.search-slide-enter-from,
|
||||
.search-slide-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
max-height: 0;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.search-slide-enter-to,
|
||||
.search-slide-leave-from {
|
||||
max-height: 60px;
|
||||
}
|
||||
|
||||
/* Virtual Song List */
|
||||
.virtual-song-list {
|
||||
@apply w-full;
|
||||
}
|
||||
|
||||
.song-list {
|
||||
@apply w-full;
|
||||
}
|
||||
|
||||
/* CSS-based virtualization for performance */
|
||||
.song-item-container {
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: 0 72px; /* 预估高度,防止布局抖动 */
|
||||
}
|
||||
|
||||
/* Compact layout - smaller item height */
|
||||
.song-list.compact-mode .song-item-container {
|
||||
contain-intrinsic-size: 0 52px;
|
||||
}
|
||||
|
||||
/* Album Card Animation */
|
||||
.album-card {
|
||||
animation: fadeInUp 0.4s ease backwards;
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
.artist-header {
|
||||
@apply flex flex-col md:flex-row gap-4 md:gap-6 px-4 pb-4;
|
||||
|
||||
.artist-cover {
|
||||
@apply flex justify-center md:justify-start;
|
||||
|
||||
.artist-avatar {
|
||||
@apply w-40 h-40 md:w-48 md:h-48 rounded-2xl object-cover;
|
||||
}
|
||||
}
|
||||
|
||||
.artist-info {
|
||||
@apply flex-1;
|
||||
|
||||
.artist-name {
|
||||
@apply text-2xl md:text-4xl font-bold mb-2 text-center md:text-left;
|
||||
}
|
||||
|
||||
.artist-alias {
|
||||
@apply text-gray-500 dark:text-gray-400 mb-2 text-center md:text-left;
|
||||
}
|
||||
|
||||
.artist-desc {
|
||||
@apply text-sm text-gray-600 dark:text-gray-300 line-clamp-3 text-center md:text-left;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content-tabs {
|
||||
@apply px-4;
|
||||
|
||||
:deep(.n-tabs-nav) {
|
||||
@apply sticky top-0 bg-light dark:bg-dark z-10;
|
||||
}
|
||||
}
|
||||
|
||||
.albums-grid {
|
||||
@apply grid gap-6 grid-cols-2 sm:grid-cols-3 md:grid-cols-5 lg:grid-cols-6 pb-40;
|
||||
}
|
||||
|
||||
.loading-more {
|
||||
@apply text-center py-4 text-gray-500 dark:text-gray-400;
|
||||
}
|
||||
|
||||
.load-more-trigger {
|
||||
@apply h-4 w-full;
|
||||
}
|
||||
|
||||
.artist-description {
|
||||
.description-content {
|
||||
@apply text-sm leading-relaxed whitespace-pre-wrap;
|
||||
}
|
||||
}
|
||||
|
||||
// 添加歌曲工具栏样式
|
||||
.songs-toolbar {
|
||||
@apply flex items-center justify-between mb-4;
|
||||
|
||||
.toolbar-left,
|
||||
.toolbar-right {
|
||||
@apply flex items-center gap-2;
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索框样式
|
||||
.search-container {
|
||||
@apply max-w-md transition-all duration-300 ease-in-out;
|
||||
|
||||
&.search-expanded {
|
||||
@apply w-52;
|
||||
}
|
||||
|
||||
.search-button {
|
||||
@apply w-8 h-8 rounded-full flex items-center justify-center cursor-pointer hover:bg-light-300 dark:hover:bg-dark-300 transition-colors text-gray-500 dark:text-gray-400 hover:text-green-500;
|
||||
|
||||
.icon {
|
||||
@apply text-lg;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.n-input) {
|
||||
@apply bg-light-200 dark:bg-dark-200;
|
||||
}
|
||||
}
|
||||
|
||||
// 操作按钮样式
|
||||
.layout-toggle .toggle-button,
|
||||
.action-button {
|
||||
@apply w-8 h-8 rounded-full flex items-center justify-center cursor-pointer hover:bg-light-300 dark:hover:bg-dark-300 transition-colors text-gray-500 dark:text-gray-400;
|
||||
|
||||
.icon {
|
||||
@apply text-lg;
|
||||
}
|
||||
|
||||
&.hover-green:hover {
|
||||
.icon {
|
||||
@apply text-green-500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索无结果样式
|
||||
.no-result {
|
||||
@apply text-center py-8 text-gray-500 dark:text-gray-400;
|
||||
}
|
||||
|
||||
// 虚拟列表样式
|
||||
.song-virtual-list {
|
||||
:deep(.n-virtual-list__scroll) {
|
||||
scrollbar-width: thin;
|
||||
&::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
@apply bg-gray-400 dark:bg-gray-600 rounded;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.double-item {
|
||||
@apply mb-2 bg-light-100 bg-opacity-30 dark:bg-dark-100 dark:bg-opacity-20 rounded-3xl;
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.mobile {
|
||||
.songs-toolbar {
|
||||
@apply mb-0;
|
||||
/* Loading Spinner */
|
||||
.loading-spinner {
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
/* Hover Effects */
|
||||
.album-cover {
|
||||
transition: box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.album-card:hover .album-cover {
|
||||
@apply shadow-2xl shadow-primary/10;
|
||||
}
|
||||
|
||||
/* Mobile Optimizations */
|
||||
@media (max-width: 768px) {
|
||||
.hero-section {
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.action-bar {
|
||||
@apply py-2;
|
||||
}
|
||||
|
||||
.tab-list {
|
||||
@apply w-full;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
@apply flex-1 text-center;
|
||||
}
|
||||
}
|
||||
|
||||
/* Focus states for accessibility */
|
||||
button:focus-visible {
|
||||
@apply outline-none ring-2 ring-primary ring-offset-2 ring-offset-white dark:ring-offset-neutral-900;
|
||||
}
|
||||
|
||||
input:focus-visible {
|
||||
@apply outline-none ring-2 ring-primary/50;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,577 +0,0 @@
|
||||
<template>
|
||||
<div class="bilibili-player-page">
|
||||
<n-scrollbar class="content-scrollbar">
|
||||
<div class="content-wrapper">
|
||||
<div v-if="isLoading" class="loading-wrapper">
|
||||
<n-spin size="large" />
|
||||
<p>{{ t('bilibili.player.loading') }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="errorMessage" class="error-wrapper">
|
||||
<i class="ri-error-warning-line text-4xl text-red-500"></i>
|
||||
<p>{{ errorMessage }}</p>
|
||||
<n-button type="primary" @click="loadVideoSource">{{
|
||||
t('bilibili.player.retry')
|
||||
}}</n-button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="videoDetail" class="bilibili-info-wrapper" :class="mainContentAnimation">
|
||||
<div class="bilibili-cover">
|
||||
<n-image
|
||||
:src="getBilibiliProxyUrl(videoDetail.pic)"
|
||||
class="cover-image"
|
||||
preview-disabled
|
||||
/>
|
||||
<!-- 悬浮的播放按钮 -->
|
||||
<div class="play-overlay">
|
||||
<div class="play-icon-bg" @click="playCurrentAudio">
|
||||
<i class="ri-play-fill"></i>
|
||||
</div>
|
||||
<!-- 固定在右下角的大型播放按钮 -->
|
||||
<n-button
|
||||
type="primary"
|
||||
size="large"
|
||||
class="corner-play-button"
|
||||
:loading="partLoading"
|
||||
@click="playCurrentAudio"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="ri-play-fill"></i>
|
||||
</template>
|
||||
{{ t('bilibili.player.playNow') }}
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="video-info">
|
||||
<div
|
||||
class="title"
|
||||
v-html="videoDetail?.title || t('bilibili.player.loadingTitle')"
|
||||
></div>
|
||||
<div class="author">
|
||||
<i class="ri-user-line mr-1"></i>
|
||||
<span>{{ videoDetail.owner?.name }}</span>
|
||||
</div>
|
||||
<div class="stats">
|
||||
<span
|
||||
><i class="ri-play-line mr-1"></i>{{ formatNumber(videoDetail.stat?.view) }}</span
|
||||
>
|
||||
<span
|
||||
><i class="ri-chat-1-line mr-1"></i
|
||||
>{{ formatNumber(videoDetail.stat?.danmaku) }}</span
|
||||
>
|
||||
<span
|
||||
><i class="ri-thumb-up-line mr-1"></i
|
||||
>{{ formatNumber(videoDetail.stat?.like) }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>{{ videoDetail.desc }}</p>
|
||||
</div>
|
||||
<div class="duration">
|
||||
<p>
|
||||
{{
|
||||
t('bilibili.player.totalDuration', {
|
||||
duration: formatTotalDuration(videoDetail.duration)
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="videoDetail?.pages && videoDetail.pages.length > 1"
|
||||
class="video-parts"
|
||||
:class="partsListAnimation"
|
||||
>
|
||||
<div class="parts-title">
|
||||
{{ t('bilibili.player.partsList', { count: videoDetail.pages.length }) }}
|
||||
<n-spin v-if="partLoading" size="small" class="ml-2" />
|
||||
</div>
|
||||
<div class="parts-list">
|
||||
<n-button
|
||||
v-for="page in videoDetail.pages"
|
||||
:key="page.cid"
|
||||
:type="isCurrentPlayingPage(page) ? 'primary' : 'default'"
|
||||
:disabled="partLoading"
|
||||
size="small"
|
||||
class="part-item"
|
||||
@click="switchPage(page)"
|
||||
>
|
||||
{{ page.part }}
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部留白 -->
|
||||
<div class="pb-20"></div>
|
||||
</div>
|
||||
</n-scrollbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useMessage } from 'naive-ui';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import {
|
||||
createSongFromBilibiliVideo as createBilibiliSong,
|
||||
getBilibiliPlayUrl,
|
||||
getBilibiliProxyUrl,
|
||||
getBilibiliVideoDetail
|
||||
} from '@/api/bilibili';
|
||||
import { usePlayerStore } from '@/store/modules/player';
|
||||
import type { IBilibiliPage, IBilibiliVideoDetail } from '@/types/bilibili';
|
||||
import type { SongResult } from '@/types/music';
|
||||
import { setAnimationClass } from '@/utils';
|
||||
|
||||
defineOptions({
|
||||
name: 'BilibiliPlayer'
|
||||
});
|
||||
|
||||
// 使用路由获取参数
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const message = useMessage();
|
||||
const playerStore = usePlayerStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
// 从路由参数获取bvid
|
||||
const bvid = computed(() => route.params.bvid as string);
|
||||
|
||||
const isLoading = ref(true); // 初始加载状态
|
||||
const partLoading = ref(false); // 分P加载状态,仅影响分P选择
|
||||
const errorMessage = ref('');
|
||||
const videoDetail = ref<IBilibiliVideoDetail | null>(null);
|
||||
const currentPage = ref<IBilibiliPage | null>(null);
|
||||
const audioList = ref<SongResult[]>([]);
|
||||
|
||||
// 只在初始加载时应用动画
|
||||
const initialLoadDone = ref(false);
|
||||
const mainContentAnimation = computed(() => {
|
||||
if (!initialLoadDone.value) {
|
||||
return setAnimationClass('animate__fadeInDown');
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
const partsListAnimation = computed(() => {
|
||||
if (!initialLoadDone.value) {
|
||||
return setAnimationClass('animate__fadeInUp');
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
// 监听bvid变化
|
||||
watch(
|
||||
() => bvid.value,
|
||||
async (newBvid) => {
|
||||
if (newBvid) {
|
||||
// 新的视频ID,重置初始加载状态
|
||||
initialLoadDone.value = false;
|
||||
await loadVideoDetail(newBvid);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 组件挂载时加载数据
|
||||
onMounted(async () => {
|
||||
if (bvid.value) {
|
||||
await loadVideoDetail(bvid.value);
|
||||
} else {
|
||||
message.error(t('bilibili.player.errors.invalidVideoId'));
|
||||
router.back();
|
||||
}
|
||||
});
|
||||
|
||||
const loadVideoDetail = async (bvid: string) => {
|
||||
if (!bvid) return;
|
||||
|
||||
isLoading.value = true;
|
||||
errorMessage.value = '';
|
||||
audioList.value = [];
|
||||
|
||||
try {
|
||||
console.log('加载B站视频详情:', bvid);
|
||||
const res = await getBilibiliVideoDetail(bvid);
|
||||
console.log('B站视频详情数据:', res.data);
|
||||
|
||||
// 确保响应式数据更新
|
||||
videoDetail.value = JSON.parse(JSON.stringify(res.data));
|
||||
|
||||
// 默认加载第一个分P
|
||||
if (videoDetail.value?.pages && videoDetail.value.pages.length > 0) {
|
||||
console.log('视频有多个分P,共', videoDetail.value.pages.length, '个');
|
||||
const [firstPage] = videoDetail.value.pages;
|
||||
currentPage.value = firstPage;
|
||||
await loadVideoSource();
|
||||
} else {
|
||||
console.log('视频无分P或分P数据为空');
|
||||
errorMessage.value = t('bilibili.player.errors.loadPartInfoFailed');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取视频详情失败', error);
|
||||
errorMessage.value = t('bilibili.player.errors.loadVideoDetailFailed');
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
// 标记初始加载完成
|
||||
initialLoadDone.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const loadVideoSource = async () => {
|
||||
if (!bvid.value || !currentPage.value?.cid) {
|
||||
console.error('缺少必要参数:', { bvid: bvid.value, cid: currentPage.value?.cid });
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
errorMessage.value = '';
|
||||
|
||||
try {
|
||||
console.log('加载音频源:', bvid.value, currentPage.value.cid);
|
||||
|
||||
// 将当前视频转换为音频格式加入播放列表
|
||||
const tempAudio = createSongFromBilibiliVideo(); // 创建一个临时对象,还没有URL
|
||||
|
||||
// 加载当前分P的音频URL
|
||||
const currentAudio = await loadSongUrl(currentPage.value, tempAudio);
|
||||
|
||||
// 将所有分P添加到播放列表
|
||||
if (videoDetail.value?.pages) {
|
||||
audioList.value = videoDetail.value.pages.map((page, index) => {
|
||||
// 第一个分P直接使用已获取的音频URL
|
||||
if (index === 0 && currentPage.value?.cid === page.cid) {
|
||||
return currentAudio;
|
||||
}
|
||||
|
||||
// 其他分P创建占位对象,稍后按需加载 - 使用公用方法
|
||||
return createBilibiliSong(videoDetail.value!, page, bvid.value);
|
||||
});
|
||||
console.log('已生成音频列表,共', audioList.value.length, '首');
|
||||
|
||||
// 预加载下一集
|
||||
if (audioList.value.length > 1) {
|
||||
const nextIndex = 1; // 默认加载第二个分P
|
||||
const nextPage = videoDetail.value.pages[nextIndex];
|
||||
const nextAudio = audioList.value[nextIndex];
|
||||
loadSongUrl(nextPage, nextAudio).catch((e) => console.warn('预加载下一个分P失败:', e));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取音频播放地址失败', error);
|
||||
errorMessage.value = t('bilibili.player.errors.loadAudioUrlFailed');
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const createSongFromBilibiliVideo = (): SongResult => {
|
||||
if (!videoDetail.value || !currentPage.value) {
|
||||
throw new Error('视频详情未加载');
|
||||
}
|
||||
|
||||
// 使用公用方法创建SongResult
|
||||
return createBilibiliSong(videoDetail.value, currentPage.value, bvid.value);
|
||||
};
|
||||
|
||||
const loadSongUrl = async (
|
||||
page: IBilibiliPage,
|
||||
songItem: SongResult,
|
||||
forceRefresh: boolean = false
|
||||
) => {
|
||||
if (songItem.playMusicUrl && !forceRefresh) return songItem; // 如果已有URL且不强制刷新则直接返回
|
||||
|
||||
try {
|
||||
console.log(`加载分P音频URL: ${page.part}, cid: ${page.cid}`);
|
||||
const res = await getBilibiliPlayUrl(bvid.value, page.cid);
|
||||
const playUrlData = res.data;
|
||||
let url = '';
|
||||
|
||||
// 尝试获取音频URL
|
||||
if (playUrlData.dash && playUrlData.dash.audio && playUrlData.dash.audio.length > 0) {
|
||||
url = playUrlData.dash.audio[0].baseUrl;
|
||||
console.log('获取到dash音频URL:', url);
|
||||
} else if (playUrlData.durl && playUrlData.durl.length > 0) {
|
||||
url = playUrlData.durl[0].url;
|
||||
console.log('获取到durl音频URL:', url);
|
||||
} else {
|
||||
throw new Error('未找到可用的音频地址');
|
||||
}
|
||||
|
||||
// 设置代理URL
|
||||
songItem.playMusicUrl = getBilibiliProxyUrl(url);
|
||||
return songItem;
|
||||
} catch (error) {
|
||||
console.error(`加载分P音频URL失败: ${page.part}`, error);
|
||||
return songItem;
|
||||
}
|
||||
};
|
||||
|
||||
const switchPage = async (page: IBilibiliPage) => {
|
||||
if (partLoading.value || currentPage.value?.cid === page.cid) return;
|
||||
|
||||
console.log('切换到分P:', page.part);
|
||||
// 立即更新UI选中状态
|
||||
currentPage.value = page;
|
||||
|
||||
// 查找对应的音频项
|
||||
const audioItem = audioList.value.find((item) => item.bilibiliData?.cid === page.cid);
|
||||
|
||||
if (audioItem) {
|
||||
// 设置局部加载状态
|
||||
try {
|
||||
partLoading.value = true;
|
||||
// 每次切换分P都强制重新加载音频URL,以解决之前的URL可能失效的问题
|
||||
await loadSongUrl(page, audioItem, true);
|
||||
// 切换后自动播放
|
||||
playCurrentAudio();
|
||||
} catch (error) {
|
||||
console.error('切换分P时加载音频URL失败:', error);
|
||||
message.error(t('bilibili.player.errors.switchPartFailed'));
|
||||
} finally {
|
||||
partLoading.value = false;
|
||||
}
|
||||
} else {
|
||||
console.error('未找到对应的音频项');
|
||||
message.error(t('bilibili.player.errors.switchPartFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const playCurrentAudio = async () => {
|
||||
if (audioList.value.length === 0) {
|
||||
console.error('音频列表为空');
|
||||
errorMessage.value = t('bilibili.player.errors.audioListEmpty');
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取当前分P的音频
|
||||
const currentIndex = audioList.value.findIndex(
|
||||
(item) => item.bilibiliData?.cid === currentPage.value?.cid
|
||||
);
|
||||
|
||||
if (currentIndex === -1) {
|
||||
console.error('未找到当前分P的音频');
|
||||
errorMessage.value = t('bilibili.player.errors.currentPartNotFound');
|
||||
return;
|
||||
}
|
||||
|
||||
const currentAudio = audioList.value[currentIndex];
|
||||
console.log('准备播放当前选中的分P:', currentAudio.name);
|
||||
|
||||
try {
|
||||
// 每次播放前都强制重新加载当前分P的音频URL(解决可能的URL失效问题)
|
||||
partLoading.value = true;
|
||||
await loadSongUrl(currentPage.value!, currentAudio, true);
|
||||
|
||||
if (!currentAudio.playMusicUrl) {
|
||||
throw new Error('获取音频URL失败');
|
||||
}
|
||||
|
||||
// 预加载下一个分P的音频URL(如果有)
|
||||
const nextIndex = (currentIndex + 1) % audioList.value.length;
|
||||
if (nextIndex !== currentIndex) {
|
||||
const nextAudio = audioList.value[nextIndex];
|
||||
const nextPage = videoDetail.value!.pages.find((p) => p.cid === nextAudio.bilibiliData?.cid);
|
||||
|
||||
if (nextPage) {
|
||||
console.log('预加载下一个分P:', nextPage.part);
|
||||
loadSongUrl(nextPage, nextAudio).catch((e) => console.warn('预加载下一个分P失败:', e));
|
||||
}
|
||||
}
|
||||
|
||||
// 将B站音频列表设置为播放列表
|
||||
playerStore.setPlayList(audioList.value);
|
||||
|
||||
// 播放当前选中的分P
|
||||
console.log('播放当前选中的分P:', currentAudio.name, '音频URL:', currentAudio.playMusicUrl);
|
||||
playerStore.setPlay(currentAudio);
|
||||
|
||||
// 播放后通知用户已开始播放
|
||||
message.success(t('bilibili.player.playStarted'));
|
||||
} catch (error) {
|
||||
console.error('播放音频失败:', error);
|
||||
errorMessage.value = error instanceof Error ? error.message : '播放失败,请重试';
|
||||
} finally {
|
||||
partLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化总时长
|
||||
*/
|
||||
const formatTotalDuration = (seconds?: number) => {
|
||||
if (!seconds) return '00:00:00';
|
||||
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
|
||||
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化数字显示
|
||||
*/
|
||||
const formatNumber = (num?: number) => {
|
||||
if (!num) return '0';
|
||||
if (num >= 10000) {
|
||||
return `${(num / 10000).toFixed(1)}万`;
|
||||
}
|
||||
return num.toString();
|
||||
};
|
||||
|
||||
// 判断是否是当前正在播放的分P
|
||||
const isCurrentPlayingPage = (page: IBilibiliPage) => {
|
||||
// 只根据播放器状态判断,不再使用UI选中状态
|
||||
const currentPlayingMusic = playerStore.playMusic as any;
|
||||
if (
|
||||
currentPlayingMusic &&
|
||||
typeof currentPlayingMusic === 'object' &&
|
||||
currentPlayingMusic.bilibiliData
|
||||
) {
|
||||
// 比较当前播放的音频的cid与此分P的cid
|
||||
return (
|
||||
currentPlayingMusic.bilibiliData.cid === page.cid &&
|
||||
currentPlayingMusic.bilibiliData.bvid === bvid.value
|
||||
);
|
||||
}
|
||||
|
||||
// 如果没有正在播放的音乐,则使用UI选择状态
|
||||
return currentPage.value?.cid === page.cid;
|
||||
};
|
||||
|
||||
// 监听播放器状态变化,保持分P列表选中状态同步
|
||||
watch(
|
||||
() => playerStore.playMusic,
|
||||
(newMusic: any) => {
|
||||
if (
|
||||
newMusic &&
|
||||
typeof newMusic === 'object' &&
|
||||
newMusic.bilibiliData &&
|
||||
newMusic.bilibiliData.bvid === bvid.value
|
||||
) {
|
||||
// 查找对应的分P
|
||||
const playingPage = videoDetail.value?.pages?.find(
|
||||
(p) => p.cid === newMusic.bilibiliData.cid
|
||||
);
|
||||
|
||||
// 无条件更新UI状态以确保UI状态与播放状态一致
|
||||
if (playingPage) {
|
||||
currentPage.value = playingPage;
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.bilibili-player-page {
|
||||
@apply h-full flex flex-col;
|
||||
|
||||
.content-scrollbar {
|
||||
@apply flex-1 overflow-hidden;
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
@apply flex flex-col p-4;
|
||||
}
|
||||
}
|
||||
|
||||
.bilibili-info-wrapper {
|
||||
@apply flex flex-col md:flex-row gap-4 w-full;
|
||||
|
||||
.bilibili-cover {
|
||||
@apply relative w-full md:w-1/3 aspect-video rounded-lg overflow-hidden;
|
||||
|
||||
.cover-image {
|
||||
@apply w-full h-full object-cover;
|
||||
}
|
||||
|
||||
.play-overlay {
|
||||
@apply absolute inset-0;
|
||||
|
||||
.play-icon-bg {
|
||||
@apply absolute inset-0 flex items-center justify-center bg-black/40 text-white opacity-0 hover:opacity-100 transition-opacity cursor-pointer;
|
||||
|
||||
i {
|
||||
@apply text-4xl;
|
||||
}
|
||||
}
|
||||
|
||||
.corner-play-button {
|
||||
@apply absolute right-3 bottom-3 shadow-lg flex items-center gap-1 px-4 py-1 text-sm transition-all duration-200;
|
||||
|
||||
&:hover {
|
||||
@apply transform scale-110;
|
||||
}
|
||||
|
||||
i {
|
||||
@apply text-xl;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.loading-wrapper,
|
||||
.error-wrapper {
|
||||
@apply w-full flex flex-col items-center justify-center py-16 rounded-lg bg-gray-100 dark:bg-gray-800;
|
||||
aspect-ratio: 16/9;
|
||||
|
||||
p {
|
||||
@apply mt-4 text-gray-600 dark:text-gray-400;
|
||||
}
|
||||
}
|
||||
|
||||
.error-wrapper {
|
||||
button {
|
||||
@apply mt-4;
|
||||
}
|
||||
}
|
||||
|
||||
.video-info {
|
||||
@apply flex-1 p-4 rounded-lg bg-gray-100 dark:bg-gray-800;
|
||||
|
||||
.title {
|
||||
@apply text-lg font-medium mb-4 text-gray-900 dark:text-white;
|
||||
}
|
||||
|
||||
.author {
|
||||
@apply flex items-center text-sm mb-2;
|
||||
}
|
||||
|
||||
.stats {
|
||||
@apply flex gap-4 text-xs text-gray-500 dark:text-gray-400 mb-3;
|
||||
}
|
||||
|
||||
.description {
|
||||
@apply text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap mb-3;
|
||||
max-height: 100px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.duration {
|
||||
@apply text-sm text-gray-600 dark:text-gray-400;
|
||||
}
|
||||
}
|
||||
|
||||
.video-parts {
|
||||
@apply mt-4;
|
||||
|
||||
.parts-title {
|
||||
@apply text-sm font-medium mb-2 flex items-center;
|
||||
}
|
||||
|
||||
.parts-list {
|
||||
@apply flex flex-wrap gap-2 pb-4;
|
||||
|
||||
.part-item {
|
||||
@apply text-xs mb-2;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -263,6 +263,8 @@ watch(
|
||||
(newLock: boolean) => {
|
||||
if (newLock) {
|
||||
isHovering.value = false;
|
||||
// 锁定时自动关闭主题色面板
|
||||
showThemeColorPanel.value = false;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,54 +1,120 @@
|
||||
<template>
|
||||
<div class="mv-list">
|
||||
<div class="play-list-type">
|
||||
<n-scrollbar x-scrollable>
|
||||
<div class="categories-wrapper">
|
||||
<span
|
||||
v-for="(category, index) in categories"
|
||||
:key="category.value"
|
||||
class="play-list-type-item"
|
||||
:class="[
|
||||
setAnimationClass('animate__bounceIn'),
|
||||
{ active: selectedCategory === category.value }
|
||||
]"
|
||||
:style="getAnimationDelay(index)"
|
||||
@click="selectedCategory = category.value"
|
||||
>
|
||||
{{ category.label }}
|
||||
</span>
|
||||
</div>
|
||||
</n-scrollbar>
|
||||
</div>
|
||||
<n-scrollbar :size="100" @scroll="handleScroll">
|
||||
<div
|
||||
v-loading="initLoading"
|
||||
class="mv-list-content"
|
||||
:class="setAnimationClass('animate__bounceInLeft')"
|
||||
>
|
||||
<div
|
||||
v-for="(item, index) in mvList"
|
||||
:key="item.id"
|
||||
class="mv-item"
|
||||
:class="setAnimationClass('animate__bounceIn')"
|
||||
:style="getAnimationDelay(index)"
|
||||
>
|
||||
<div class="mv-item-img" @click="handleShowMv(item, index)">
|
||||
<n-image
|
||||
class="mv-item-img-img"
|
||||
:src="getImgUrl(item.cover, '320y180')"
|
||||
lazy
|
||||
preview-disabled
|
||||
/>
|
||||
<div class="top">
|
||||
<div class="play-count">{{ formatNumber(item.playCount) }}</div>
|
||||
<i class="iconfont icon-videofill"></i>
|
||||
</div>
|
||||
<div class="mv-list-page h-full w-full bg-white dark:bg-black transition-colors duration-500">
|
||||
<n-scrollbar class="h-full" @scroll="handleScroll">
|
||||
<div class="mv-content w-full pb-32 pt-6 px-4 sm:px-6 lg:px-8 lg:pl-0">
|
||||
<!-- Hero Section -->
|
||||
<div class="mb-8 flex flex-col md:flex-row md:items-end justify-between gap-6">
|
||||
<div>
|
||||
<h1
|
||||
class="text-3xl md:text-4xl font-bold tracking-tight text-neutral-900 dark:text-white mb-2"
|
||||
>
|
||||
MV
|
||||
</h1>
|
||||
<p class="text-neutral-500 dark:text-neutral-400">探索精彩视频内容</p>
|
||||
</div>
|
||||
|
||||
<!-- Category Selector (Pills) -->
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="category in categories"
|
||||
:key="category.value"
|
||||
class="px-4 py-1.5 rounded-full text-sm font-medium transition-all duration-300"
|
||||
:class="[
|
||||
selectedCategory === category.value
|
||||
? 'bg-primary text-white shadow-lg shadow-primary/25 scale-105'
|
||||
: 'bg-neutral-100 dark:bg-neutral-900 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-200 dark:hover:bg-neutral-800'
|
||||
]"
|
||||
@click="selectedCategory = category.value"
|
||||
>
|
||||
{{ category.label }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="mv-item-title">{{ item.name }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingMore" class="loading-more">加载中...</div>
|
||||
<div v-if="!hasMore && !initLoading" class="no-more">没有更多了</div>
|
||||
<!-- MV Grid Container -->
|
||||
<div class="mv-grid-container">
|
||||
<!-- Loading State -->
|
||||
<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 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>
|
||||
|
||||
<!-- Content State -->
|
||||
<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">
|
||||
加载更多中...
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="!hasMore && !initLoading" class="text-center">
|
||||
<span
|
||||
class="text-xs text-neutral-400 font-medium tracking-widest uppercase opacity-50"
|
||||
>
|
||||
— 已加载全部内容 —
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</n-scrollbar>
|
||||
|
||||
@@ -70,7 +136,7 @@ import MvPlayer from '@/components/MvPlayer.vue';
|
||||
import { audioService } from '@/services/audioService';
|
||||
import { usePlayerStore } from '@/store/modules/player';
|
||||
import { IMvItem } from '@/types/mv';
|
||||
import { formatNumber, getImgUrl, setAnimationClass, setAnimationDelay } from '@/utils';
|
||||
import { calculateAnimationDelay, formatNumber, getImgUrl } from '@/utils';
|
||||
|
||||
defineOptions({
|
||||
name: 'Mv'
|
||||
@@ -83,7 +149,7 @@ const initLoading = ref(false);
|
||||
const loadingMore = ref(false);
|
||||
const currentIndex = ref(0);
|
||||
const offset = ref(0);
|
||||
const limit = ref(42);
|
||||
const limit = ref(40); // 调整为40,方便4列布局 (10行)
|
||||
const hasMore = ref(true);
|
||||
|
||||
const categories = [
|
||||
@@ -105,11 +171,6 @@ watch(selectedCategory, async () => {
|
||||
await loadMvList();
|
||||
});
|
||||
|
||||
const getAnimationDelay = (index: number) => {
|
||||
const currentPageIndex = index % limit.value;
|
||||
return setAnimationDelay(currentPageIndex, 30);
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await loadMvList();
|
||||
});
|
||||
@@ -187,7 +248,7 @@ const loadMvList = async () => {
|
||||
const handleScroll = (e: Event) => {
|
||||
const target = e.target as Element;
|
||||
const { scrollTop, clientHeight, scrollHeight } = target;
|
||||
const threshold = 100;
|
||||
const threshold = 150;
|
||||
|
||||
if (scrollHeight - (scrollTop + clientHeight) < threshold) {
|
||||
loadMvList();
|
||||
@@ -198,107 +259,30 @@ const isPrevDisabled = computed(() => currentIndex.value === 0);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.mv-list {
|
||||
@apply h-full flex-1 flex flex-col overflow-hidden;
|
||||
.mv-list-page {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
&-title {
|
||||
@apply text-xl font-bold pb-2;
|
||||
@apply text-gray-900 dark:text-white;
|
||||
.animate-item {
|
||||
animation: fadeInUp 0.6s cubic-bezier(0.16, 1, 0.3, 1) backwards;
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(24px);
|
||||
}
|
||||
|
||||
// 添加歌单分类样式
|
||||
.play-list-type {
|
||||
.title {
|
||||
@apply text-lg font-bold mb-2;
|
||||
@apply text-gray-900 dark:text-white;
|
||||
}
|
||||
|
||||
.categories-wrapper {
|
||||
@apply flex items-center py-2;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&-item {
|
||||
@apply py-2 px-3 mr-3 inline-block rounded-xl cursor-pointer transition-all duration-300;
|
||||
@apply bg-light dark:bg-black text-gray-900 dark:text-white;
|
||||
@apply border border-gray-200 dark:border-gray-700;
|
||||
|
||||
&:hover {
|
||||
@apply bg-green-50 dark:bg-green-900;
|
||||
}
|
||||
|
||||
&.active {
|
||||
@apply bg-green-500 border-green-500 text-white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-content {
|
||||
@apply grid gap-4 pb-28 mt-2 pr-4;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
}
|
||||
|
||||
.mv-item {
|
||||
@apply p-2 rounded-lg;
|
||||
@apply bg-light dark:bg-black;
|
||||
@apply border border-gray-200 dark:border-gray-700;
|
||||
|
||||
&-img {
|
||||
@apply rounded-lg overflow-hidden relative;
|
||||
aspect-ratio: 16/9;
|
||||
line-height: 0;
|
||||
|
||||
&:hover img {
|
||||
@apply hover:scale-110 transition-all duration-300 ease-in-out object-top;
|
||||
}
|
||||
|
||||
&-img {
|
||||
@apply w-full h-full object-cover rounded-lg overflow-hidden;
|
||||
}
|
||||
|
||||
.top {
|
||||
@apply absolute w-full h-full top-0 left-0 flex justify-center items-center transition-all duration-300 ease-in-out cursor-pointer;
|
||||
@apply bg-black bg-opacity-60;
|
||||
opacity: 0;
|
||||
|
||||
i {
|
||||
@apply text-4xl text-white;
|
||||
}
|
||||
|
||||
.play-count {
|
||||
@apply absolute top-2 right-2 text-sm;
|
||||
@apply text-white text-opacity-90;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-title {
|
||||
@apply mt-2 text-sm line-clamp-1;
|
||||
@apply text-gray-900 dark:text-white;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-more {
|
||||
@apply text-center py-4 col-span-full;
|
||||
@apply text-gray-500 dark:text-gray-400;
|
||||
}
|
||||
|
||||
.no-more {
|
||||
@apply text-center py-4 col-span-full;
|
||||
@apply text-gray-500 dark:text-gray-400;
|
||||
}
|
||||
|
||||
.mobile {
|
||||
.mv-list-content {
|
||||
@apply pl-4 pr-4;
|
||||
}
|
||||
.categories-wrapper {
|
||||
@apply pl-4;
|
||||
.mv-card {
|
||||
&:hover {
|
||||
.play-icon {
|
||||
@apply opacity-100 scale-100;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,214 +1,319 @@
|
||||
<template>
|
||||
<div class="import-playlist-page">
|
||||
<div class="import-header" :class="setAnimationClass('animate__fadeInLeft')">
|
||||
<div class="import-header-left">
|
||||
<h2>{{ t('comp.playlist.import.title') }}</h2>
|
||||
<div class="import-desc">{{ t('comp.playlist.import.description') }}</div>
|
||||
<div
|
||||
class="h-full w-full bg-gray-50 dark:bg-black transition-colors duration-500 overflow-hidden flex flex-col relative"
|
||||
>
|
||||
<!-- 背景装饰 -->
|
||||
<div
|
||||
class="absolute top-0 left-0 w-full h-64 bg-gradient-to-b from-primary/5 to-transparent pointer-events-none"
|
||||
></div>
|
||||
|
||||
<!-- 头部区域 -->
|
||||
<div class="flex-shrink-0 z-10 px-6 pt-8 pb-4 relative">
|
||||
<div class="max-w-5xl mx-auto w-full flex items-end justify-between">
|
||||
<div>
|
||||
<h2 class="text-3xl font-bold text-gray-900 dark:text-white mb-2 flex items-center gap-3">
|
||||
<div
|
||||
class="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center text-primary"
|
||||
>
|
||||
<i class="ri-import-fill text-xl"></i>
|
||||
</div>
|
||||
{{ t('comp.playlist.import.title') }}
|
||||
</h2>
|
||||
<p class="text-base text-gray-500 dark:text-gray-400 ml-13">
|
||||
{{ t('comp.playlist.import.description') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="import-content" :class="setAnimationClass('animate__fadeInUp')">
|
||||
<n-card class="import-card">
|
||||
<n-tabs type="line" animated>
|
||||
<!-- 链接导入 -->
|
||||
<n-tab-pane name="link" :tab="t('comp.playlist.import.linkTab')">
|
||||
<div class="tab-content">
|
||||
<div class="link-inputs">
|
||||
<div v-for="(link, index) in linkInputs" :key="index" class="link-row">
|
||||
<n-input
|
||||
v-model:value="link.value"
|
||||
:placeholder="t('comp.playlist.import.linkPlaceholder')"
|
||||
class="link-input"
|
||||
/>
|
||||
<n-button
|
||||
quaternary
|
||||
circle
|
||||
type="error"
|
||||
@click="removeLinkRow(index)"
|
||||
v-if="linkInputs.length > 1"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="iconfont ri-delete-bin-line"></i>
|
||||
</template>
|
||||
</n-button>
|
||||
</div>
|
||||
<div class="link-actions">
|
||||
<n-button @click="addLinkRow" secondary size="small">
|
||||
<template #icon>
|
||||
<i class="iconfont ri-add-line"></i>
|
||||
</template>
|
||||
{{ t('comp.playlist.import.addLinkButton') }}
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="link-tips">
|
||||
<p>{{ t('comp.playlist.import.linkTips') }}</p>
|
||||
<ul>
|
||||
<li>{{ t('comp.playlist.import.linkTip1') }}</li>
|
||||
<li>{{ t('comp.playlist.import.linkTip2') }}</li>
|
||||
<li>{{ t('comp.playlist.import.linkTip3') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="action-buttons">
|
||||
<n-checkbox v-model:checked="importToStarPlaylist">
|
||||
{{ t('comp.playlist.import.importToStarPlaylist') }}
|
||||
</n-checkbox>
|
||||
<n-input
|
||||
v-if="!importToStarPlaylist"
|
||||
v-model:value="playlistName"
|
||||
:placeholder="t('comp.playlist.import.playlistNamePlaceholder')"
|
||||
class="playlist-name-input"
|
||||
/>
|
||||
<n-button
|
||||
type="primary"
|
||||
:loading="importing"
|
||||
:disabled="!isLinkInputValid"
|
||||
@click="handleImportByLink"
|
||||
>
|
||||
{{ t('comp.playlist.import.importButton') }}
|
||||
</n-button>
|
||||
</div>
|
||||
<!-- 内容区域 -->
|
||||
<n-scrollbar class="flex-1">
|
||||
<div class="w-full max-w-5xl mx-auto p-6 pb-24">
|
||||
<!-- 自定义 Tab 切换 -->
|
||||
<div class="flex justify-center mb-8">
|
||||
<div
|
||||
class="bg-white dark:bg-white/5 p-1.5 rounded-2xl shadow-sm border border-gray-100 dark:border-white/10 flex gap-1 relative"
|
||||
>
|
||||
<div
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
class="relative z-10 px-6 py-2.5 rounded-xl text-sm font-medium cursor-pointer transition-all duration-300 flex items-center gap-2"
|
||||
:class="
|
||||
currentTab === tab.id
|
||||
? 'text-white'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'
|
||||
"
|
||||
@click="currentTab = tab.id"
|
||||
>
|
||||
<i :class="tab.icon"></i>
|
||||
{{ tab.label }}
|
||||
</div>
|
||||
</n-tab-pane>
|
||||
|
||||
<!-- 文字导入 -->
|
||||
<n-tab-pane name="text" :tab="t('comp.playlist.import.textTab')">
|
||||
<div class="tab-content">
|
||||
<n-input
|
||||
v-model:value="textInput"
|
||||
type="textarea"
|
||||
:placeholder="t('comp.playlist.import.textPlaceholder')"
|
||||
:rows="6"
|
||||
/>
|
||||
<div class="text-tips">
|
||||
<p>{{ t('comp.playlist.import.textTips') }}</p>
|
||||
<p class="text-format">{{ t('comp.playlist.import.textFormat') }}</p>
|
||||
</div>
|
||||
<div class="action-buttons">
|
||||
<n-checkbox v-model:checked="importToStarPlaylist">
|
||||
{{ t('comp.playlist.import.importToStarPlaylist') }}
|
||||
</n-checkbox>
|
||||
<n-input
|
||||
v-if="!importToStarPlaylist"
|
||||
v-model:value="playlistName"
|
||||
:placeholder="t('comp.playlist.import.playlistNamePlaceholder')"
|
||||
class="playlist-name-input"
|
||||
/>
|
||||
<n-button
|
||||
type="primary"
|
||||
:loading="importing"
|
||||
:disabled="!textInput.trim()"
|
||||
@click="handleImportByText"
|
||||
>
|
||||
{{ t('comp.playlist.import.importButton') }}
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</n-tab-pane>
|
||||
|
||||
<!-- 元数据导入 -->
|
||||
<n-tab-pane name="local" :tab="t('comp.playlist.import.localTab')">
|
||||
<div class="tab-content">
|
||||
<div class="metadata-inputs">
|
||||
<div v-for="(item, index) in localMetadata" :key="index" class="metadata-row">
|
||||
<n-input
|
||||
v-model:value="item.name"
|
||||
:placeholder="t('comp.playlist.import.songNamePlaceholder')"
|
||||
class="metadata-input"
|
||||
/>
|
||||
<n-input
|
||||
v-model:value="item.artist"
|
||||
:placeholder="t('comp.playlist.import.artistNamePlaceholder')"
|
||||
class="metadata-input"
|
||||
/>
|
||||
<n-input
|
||||
v-model:value="item.album"
|
||||
:placeholder="t('comp.playlist.import.albumNamePlaceholder')"
|
||||
class="metadata-input"
|
||||
/>
|
||||
<n-button
|
||||
quaternary
|
||||
circle
|
||||
type="error"
|
||||
@click="removeMetadataRow(index)"
|
||||
v-if="localMetadata.length > 1"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="iconfont ri-delete-bin-line"></i>
|
||||
</template>
|
||||
</n-button>
|
||||
</div>
|
||||
<div class="metadata-actions">
|
||||
<n-button @click="addMetadataRow" secondary size="small">
|
||||
<template #icon>
|
||||
<i class="iconfont ri-add-line"></i>
|
||||
</template>
|
||||
{{ t('comp.playlist.import.addSongButton') }}
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="local-tips">
|
||||
<p>{{ t('comp.playlist.import.localTips') }}</p>
|
||||
</div>
|
||||
<div class="action-buttons">
|
||||
<n-checkbox v-model:checked="importToStarPlaylist">
|
||||
{{ t('comp.playlist.import.importToStarPlaylist') }}
|
||||
</n-checkbox>
|
||||
<n-input
|
||||
v-if="!importToStarPlaylist"
|
||||
v-model:value="playlistName"
|
||||
:placeholder="t('comp.playlist.import.playlistNamePlaceholder')"
|
||||
class="playlist-name-input"
|
||||
/>
|
||||
<n-button
|
||||
type="primary"
|
||||
:loading="importing"
|
||||
:disabled="!isLocalMetadataValid"
|
||||
@click="handleImportByLocal"
|
||||
>
|
||||
{{ t('comp.playlist.import.importButton') }}
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</n-card>
|
||||
|
||||
<!-- 导入状态 -->
|
||||
<n-card v-if="taskId" class="import-status-card">
|
||||
<div class="status-header">
|
||||
<h3>{{ t('comp.playlist.import.importStatus') }}</h3>
|
||||
<n-button text @click="refreshStatus">
|
||||
<template #icon>
|
||||
<i class="iconfont ri-refresh-line"></i>
|
||||
</template>
|
||||
{{ t('comp.playlist.import.refresh') }}
|
||||
</n-button>
|
||||
<!-- 滑动背景 -->
|
||||
<div
|
||||
class="absolute top-1.5 bottom-1.5 bg-primary rounded-xl shadow-md transition-all duration-300 ease-out"
|
||||
:style="tabIndicatorStyle"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<n-spin :show="checkingStatus">
|
||||
<div class="status-content">
|
||||
<div class="status-item">
|
||||
<span class="status-label">{{ t('comp.playlist.import.taskId') }}:</span>
|
||||
<span class="status-value">{{ taskId }}</span>
|
||||
|
||||
<!-- 主内容卡片 -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8 items-start">
|
||||
<!-- 左侧:输入区域 -->
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<div
|
||||
class="bg-white dark:bg-neutral-900 rounded-3xl border border-gray-100 dark:border-gray-800 shadow-[0_8px_30px_rgb(0,0,0,0.04)] dark:shadow-none overflow-hidden p-1 transition-all duration-300"
|
||||
>
|
||||
<!-- 链接导入内容 -->
|
||||
<div v-if="currentTab === 'link'" class="p-6 space-y-6 animate-fade-in">
|
||||
<div class="space-y-4">
|
||||
<div v-for="(link, index) in linkInputs" :key="index" class="group relative">
|
||||
<input
|
||||
v-model="link.value"
|
||||
:placeholder="t('comp.playlist.import.linkPlaceholder')"
|
||||
class="w-full bg-gray-50 dark:bg-white/5 border-2 border-transparent focus:border-primary/50 focus:bg-white dark:focus:bg-black rounded-2xl px-5 py-4 outline-none transition-all duration-300 text-gray-900 dark:text-white placeholder-gray-400"
|
||||
/>
|
||||
<button
|
||||
v-if="linkInputs.length > 1"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 w-8 h-8 rounded-full flex items-center justify-center text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-all opacity-0 group-hover:opacity-100"
|
||||
@click="removeLinkRow(index)"
|
||||
>
|
||||
<i class="ri-delete-bin-line"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="w-full py-3 rounded-2xl border-2 border-dashed border-gray-200 dark:border-gray-700 text-gray-500 hover:text-primary hover:border-primary/50 hover:bg-primary/5 transition-all duration-300 flex items-center justify-center gap-2 font-medium"
|
||||
@click="addLinkRow"
|
||||
>
|
||||
<i class="ri-add-line text-lg"></i>
|
||||
{{ t('comp.playlist.import.addLinkButton') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文字导入内容 -->
|
||||
<div v-if="currentTab === 'text'" class="p-6 space-y-6 animate-fade-in">
|
||||
<textarea
|
||||
v-model="textInput"
|
||||
:placeholder="t('comp.playlist.import.textPlaceholder')"
|
||||
rows="12"
|
||||
class="w-full bg-gray-50 dark:bg-white/5 border-2 border-transparent focus:border-primary/50 focus:bg-white dark:focus:bg-black rounded-2xl px-5 py-4 outline-none transition-all duration-300 text-gray-900 dark:text-white placeholder-gray-400 font-mono text-sm resize-none"
|
||||
></textarea>
|
||||
<div class="flex items-center gap-2 text-xs text-gray-400 px-2">
|
||||
<i class="ri-information-line"></i>
|
||||
{{ t('comp.playlist.import.textFormat') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 元数据导入内容 -->
|
||||
<div v-if="currentTab === 'local'" class="p-6 space-y-6 animate-fade-in">
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="(item, index) in localMetadata"
|
||||
:key="index"
|
||||
class="flex gap-3 items-center group"
|
||||
>
|
||||
<div class="w-6 text-center text-xs text-gray-300 font-mono">
|
||||
{{ index + 1 }}
|
||||
</div>
|
||||
<input
|
||||
v-model="item.name"
|
||||
:placeholder="t('comp.playlist.import.songNamePlaceholder')"
|
||||
class="flex-1 bg-gray-50 dark:bg-white/5 border-transparent focus:border-primary/50 rounded-xl px-4 py-2.5 outline-none text-sm transition-all border-2"
|
||||
/>
|
||||
<input
|
||||
v-model="item.artist"
|
||||
:placeholder="t('comp.playlist.import.artistNamePlaceholder')"
|
||||
class="flex-1 bg-gray-50 dark:bg-white/5 border-transparent focus:border-primary/50 rounded-xl px-4 py-2.5 outline-none text-sm transition-all border-2"
|
||||
/>
|
||||
<input
|
||||
v-model="item.album"
|
||||
:placeholder="t('comp.playlist.import.albumNamePlaceholder')"
|
||||
class="flex-1 bg-gray-50 dark:bg-white/5 border-transparent focus:border-primary/50 rounded-xl px-4 py-2.5 outline-none text-sm transition-all border-2"
|
||||
/>
|
||||
<button
|
||||
v-if="localMetadata.length > 1"
|
||||
class="w-8 h-8 rounded-full flex items-center justify-center text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-all opacity-0 group-hover:opacity-100"
|
||||
@click="removeMetadataRow(index)"
|
||||
>
|
||||
<i class="ri-delete-bin-line"></i>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="ml-9 px-4 py-2 rounded-xl text-sm font-medium text-primary hover:bg-primary/10 transition-colors flex items-center gap-1"
|
||||
@click="addMetadataRow"
|
||||
>
|
||||
<i class="ri-add-line"></i>
|
||||
{{ t('comp.playlist.import.addSongButton') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="status-label">{{ t('comp.playlist.import.status') }}:</span>
|
||||
<span class="status-value" :class="`status-${taskStatus}`">
|
||||
{{ getStatusText(taskStatus) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="taskStatus === 'success'" class="status-item">
|
||||
<span class="status-label">{{ t('comp.playlist.import.successCount') }}:</span>
|
||||
<span class="status-value success-count">{{ successCount }}</span>
|
||||
</div>
|
||||
<div v-if="taskStatus === 'failed'" class="status-item">
|
||||
<span class="status-label">{{ t('comp.playlist.import.failReason') }}:</span>
|
||||
<span class="status-value fail-reason">{{ failReason }}</span>
|
||||
|
||||
<!-- 帮助提示 (根据 Tab 变化) -->
|
||||
<div
|
||||
class="bg-blue-50/50 dark:bg-blue-900/10 rounded-2xl p-5 border border-blue-100 dark:border-blue-900/20"
|
||||
>
|
||||
<div class="flex gap-3">
|
||||
<div class="mt-0.5 text-blue-500">
|
||||
<i class="ri-lightbulb-flash-line text-lg"></i>
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400 space-y-1">
|
||||
<div class="font-medium text-gray-900 dark:text-gray-200 mb-1">
|
||||
{{ t('comp.playlist.import.linkTips') }}
|
||||
</div>
|
||||
<ul class="list-disc list-inside opacity-80 space-y-1">
|
||||
<li v-if="currentTab === 'link'">{{ t('comp.playlist.import.linkTip1') }}</li>
|
||||
<li v-if="currentTab === 'link'">{{ t('comp.playlist.import.linkTip2') }}</li>
|
||||
<li v-if="currentTab === 'link'">{{ t('comp.playlist.import.linkTip3') }}</li>
|
||||
<li v-if="currentTab === 'text'">{{ t('comp.playlist.import.textTips') }}</li>
|
||||
<li v-if="currentTab === 'local'">{{ t('comp.playlist.import.localTips') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</n-spin>
|
||||
</n-card>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:选项与操作 -->
|
||||
<div class="space-y-6">
|
||||
<!-- 选项卡片 -->
|
||||
<div
|
||||
class="bg-white dark:bg-neutral-900 rounded-3xl border border-gray-100 dark:border-gray-800 shadow-[0_8px_30px_rgb(0,0,0,0.04)] dark:shadow-none p-6"
|
||||
>
|
||||
<h3 class="font-bold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
||||
<i class="ri-settings-4-line text-primary"></i>
|
||||
{{ t('comp.playlist.import.options') }}
|
||||
</h3>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- 导入到星标歌单开关 -->
|
||||
<div
|
||||
class="flex items-center justify-between p-4 rounded-2xl cursor-pointer transition-all border-2"
|
||||
:class="
|
||||
importToStarPlaylist
|
||||
? 'bg-primary/5 border-primary/50'
|
||||
: 'bg-gray-50 dark:bg-white/5 border-transparent hover:bg-gray-100 dark:hover:bg-white/10'
|
||||
"
|
||||
@click="importToStarPlaylist = !importToStarPlaylist"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-10 h-10 rounded-full bg-white dark:bg-white/10 flex items-center justify-center text-lg"
|
||||
:class="importToStarPlaylist ? 'text-primary' : 'text-gray-400'"
|
||||
>
|
||||
<i class="ri-heart-3-fill" v-if="importToStarPlaylist"></i>
|
||||
<i class="ri-heart-3-line" v-else></i>
|
||||
</div>
|
||||
<span class="font-medium text-sm">{{
|
||||
t('comp.playlist.import.importToStarPlaylist')
|
||||
}}</span>
|
||||
</div>
|
||||
<div
|
||||
class="w-5 h-5 rounded-full border-2 flex items-center justify-center transition-colors"
|
||||
:class="
|
||||
importToStarPlaylist
|
||||
? 'border-primary bg-primary'
|
||||
: 'border-gray-300 dark:border-gray-600'
|
||||
"
|
||||
>
|
||||
<i class="ri-check-line text-white text-xs" v-show="importToStarPlaylist"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 自定义歌单名 -->
|
||||
<div
|
||||
class="relative group"
|
||||
:class="{ 'opacity-50 pointer-events-none': importToStarPlaylist }"
|
||||
>
|
||||
<div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
|
||||
<i class="ri-play-list-line text-gray-400"></i>
|
||||
</div>
|
||||
<input
|
||||
v-model="playlistName"
|
||||
:placeholder="t('comp.playlist.import.playlistNamePlaceholder')"
|
||||
class="w-full bg-gray-50 dark:bg-white/5 border-2 border-transparent focus:border-primary/50 rounded-2xl pl-11 pr-4 py-3.5 outline-none transition-all text-sm text-gray-900 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主操作按钮 -->
|
||||
<button
|
||||
class="w-full mt-6 py-4 rounded-2xl bg-primary text-white font-bold text-lg shadow-lg shadow-primary/30 hover:shadow-primary/40 hover:-translate-y-0.5 active:translate-y-0 disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none transition-all duration-300 flex items-center justify-center gap-2"
|
||||
:disabled="isImportDisabled"
|
||||
@click="handleImport"
|
||||
>
|
||||
<i class="ri-loader-4-line animate-spin text-xl" v-if="importing"></i>
|
||||
<i class="ri-download-cloud-2-line text-xl" v-else></i>
|
||||
{{
|
||||
importing
|
||||
? t('comp.playlist.import.statusProcessing')
|
||||
: t('comp.playlist.import.importButton')
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 状态反馈 -->
|
||||
<div v-if="taskId" class="animate-fade-in-up">
|
||||
<div
|
||||
class="bg-white dark:bg-neutral-900 rounded-3xl border border-gray-100 dark:border-gray-800 shadow-[0_8px_30px_rgb(0,0,0,0.04)] dark:shadow-none p-6"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
{{ t('comp.playlist.import.importStatus') }}
|
||||
</h3>
|
||||
<button class="text-xs text-primary hover:underline" @click="refreshStatus">
|
||||
{{ t('comp.playlist.import.refresh') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="relative pt-2">
|
||||
<div class="flex items-center gap-4 mb-4">
|
||||
<div
|
||||
class="w-12 h-12 rounded-full flex items-center justify-center text-2xl transition-colors"
|
||||
:class="{
|
||||
'bg-blue-50 text-blue-500':
|
||||
taskStatus === 'processing' || taskStatus === 'pending',
|
||||
'bg-green-50 text-green-500': taskStatus === 'success',
|
||||
'bg-red-50 text-red-500': taskStatus === 'failed'
|
||||
}"
|
||||
>
|
||||
<i
|
||||
class="ri-loader-4-line animate-spin"
|
||||
v-if="taskStatus === 'processing' || taskStatus === 'pending'"
|
||||
></i>
|
||||
<i class="ri-check-line" v-else-if="taskStatus === 'success'"></i>
|
||||
<i class="ri-close-line" v-else-if="taskStatus === 'failed'"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-bold text-lg text-gray-900 dark:text-white">
|
||||
{{ getStatusText(taskStatus) }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-400 font-mono">{{ taskId }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="taskStatus === 'success'"
|
||||
class="bg-green-50 dark:bg-green-900/10 rounded-xl p-3 text-green-700 dark:text-green-400 text-sm flex justify-between"
|
||||
>
|
||||
<span>{{ t('comp.playlist.import.successCount') }}</span>
|
||||
<span class="font-bold">{{ successCount }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="taskStatus === 'failed'"
|
||||
class="bg-red-50 dark:bg-red-900/10 rounded-xl p-3 text-red-700 dark:text-red-400 text-sm"
|
||||
>
|
||||
{{ failReason }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</n-scrollbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -218,11 +323,29 @@ import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { getImportTaskStatus, importPlaylist } from '@/api/playlist';
|
||||
import { setAnimationClass } from '@/utils';
|
||||
|
||||
const { t } = useI18n();
|
||||
const message = useMessage();
|
||||
|
||||
// Tab 配置
|
||||
const currentTab = ref('link');
|
||||
const tabs = computed(() => [
|
||||
{ id: 'link', label: t('comp.playlist.import.linkTab'), icon: 'ri-link' },
|
||||
{ id: 'text', label: t('comp.playlist.import.textTab'), icon: 'ri-text' },
|
||||
{ id: 'local', label: t('comp.playlist.import.localTab'), icon: 'ri-file-list-3-line' }
|
||||
]);
|
||||
|
||||
// 计算 Tab 指示器位置
|
||||
const tabIndicatorStyle = computed(() => {
|
||||
const index = tabs.value.findIndex((tab) => tab.id === currentTab.value);
|
||||
// 假设每个 tab 宽度大概一致,这里简单计算百分比
|
||||
// 在真实项目中可能需要获取 DOM 元素宽度
|
||||
return {
|
||||
left: `calc(${(100 / 3) * index}% + 6px)`,
|
||||
width: `calc(${100 / 3}% - 12px)`
|
||||
};
|
||||
});
|
||||
|
||||
// 表单数据
|
||||
const linkInputs = ref([{ value: '' }]);
|
||||
const textInput = ref('');
|
||||
@@ -239,11 +362,6 @@ const removeLinkRow = (index: number) => {
|
||||
linkInputs.value.splice(index, 1);
|
||||
};
|
||||
|
||||
// 验证链接是否有效
|
||||
const isLinkInputValid = computed(() => {
|
||||
return linkInputs.value.some((item) => item.value.trim() !== '');
|
||||
});
|
||||
|
||||
// 元数据相关函数
|
||||
const addMetadataRow = () => {
|
||||
localMetadata.value.push({ name: '', artist: '', album: '' });
|
||||
@@ -253,12 +371,21 @@ const removeMetadataRow = (index: number) => {
|
||||
localMetadata.value.splice(index, 1);
|
||||
};
|
||||
|
||||
// 验证元数据是否有效
|
||||
const isLocalMetadataValid = computed(() => {
|
||||
return localMetadata.value.some((item) => item.name.trim() !== '');
|
||||
// 验证逻辑
|
||||
const isLinkInputValid = computed(() => linkInputs.value.some((item) => item.value.trim() !== ''));
|
||||
const isLocalMetadataValid = computed(() =>
|
||||
localMetadata.value.some((item) => item.name.trim() !== '')
|
||||
);
|
||||
|
||||
const isImportDisabled = computed(() => {
|
||||
if (importing.value) return true;
|
||||
if (currentTab.value === 'link') return !isLinkInputValid.value;
|
||||
if (currentTab.value === 'text') return !textInput.value.trim();
|
||||
if (currentTab.value === 'local') return !isLocalMetadataValid.value;
|
||||
return true;
|
||||
});
|
||||
|
||||
// 导入状态
|
||||
// 统一导入处理
|
||||
const importing = ref(false);
|
||||
const taskId = ref('');
|
||||
const taskStatus = ref('');
|
||||
@@ -267,26 +394,24 @@ const failReason = ref('');
|
||||
const checkingStatus = ref(false);
|
||||
const statusCheckInterval = ref<number | null>(null);
|
||||
|
||||
// 处理链接导入
|
||||
const handleImportByLink = async () => {
|
||||
if (!isLinkInputValid.value) {
|
||||
message.warning(t('comp.playlist.import.emptyLinkWarning'));
|
||||
return;
|
||||
}
|
||||
const handleImport = async () => {
|
||||
if (isImportDisabled.value) return;
|
||||
|
||||
try {
|
||||
importing.value = true;
|
||||
let params: any = {};
|
||||
|
||||
// 处理链接格式
|
||||
const links = linkInputs.value
|
||||
.filter((link) => link.value.trim())
|
||||
.map((link) => link.value.trim());
|
||||
|
||||
const encodedLinks = JSON.stringify(links);
|
||||
|
||||
const params: any = {
|
||||
link: encodedLinks
|
||||
};
|
||||
if (currentTab.value === 'link') {
|
||||
const links = linkInputs.value
|
||||
.filter((link) => link.value.trim())
|
||||
.map((link) => link.value.trim());
|
||||
params.link = JSON.stringify(links);
|
||||
} else if (currentTab.value === 'text') {
|
||||
params.text = encodeURIComponent(textInput.value);
|
||||
} else if (currentTab.value === 'local') {
|
||||
const filteredData = localMetadata.value.filter((item) => item.name.trim() !== '');
|
||||
params.local = JSON.stringify(filteredData);
|
||||
}
|
||||
|
||||
if (importToStarPlaylist.value) {
|
||||
params.importStarPlaylist = true;
|
||||
@@ -311,140 +436,34 @@ const handleImportByLink = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 处理文字导入
|
||||
const handleImportByText = async () => {
|
||||
if (!textInput.value.trim()) {
|
||||
message.warning(t('comp.playlist.import.emptyTextWarning'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
importing.value = true;
|
||||
|
||||
const encodedText = encodeURIComponent(textInput.value);
|
||||
|
||||
const params: any = {
|
||||
text: encodedText
|
||||
};
|
||||
|
||||
if (importToStarPlaylist.value) {
|
||||
params.importStarPlaylist = true;
|
||||
} else if (playlistName.value) {
|
||||
params.playlistName = playlistName.value;
|
||||
}
|
||||
|
||||
const res = await importPlaylist(params);
|
||||
|
||||
if (res.data.code === 200) {
|
||||
message.success(t('comp.playlist.import.importSuccess'));
|
||||
taskId.value = res.data.data.taskId;
|
||||
startStatusCheck();
|
||||
} else {
|
||||
message.error(res.data.message || t('comp.playlist.import.importFailed'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('导入歌单失败:', error);
|
||||
message.error(t('comp.playlist.import.importFailed'));
|
||||
} finally {
|
||||
importing.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 处理元数据导入
|
||||
const handleImportByLocal = async () => {
|
||||
if (!isLocalMetadataValid.value) {
|
||||
message.warning(t('comp.playlist.import.emptyLocalWarning'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
importing.value = true;
|
||||
|
||||
// 过滤掉空的行
|
||||
const filteredData = localMetadata.value.filter((item) => item.name.trim() !== '');
|
||||
|
||||
const encodedLocal = JSON.stringify(filteredData);
|
||||
|
||||
const params: any = {
|
||||
local: encodedLocal
|
||||
};
|
||||
|
||||
if (importToStarPlaylist.value) {
|
||||
params.importStarPlaylist = true;
|
||||
} else if (playlistName.value) {
|
||||
params.playlistName = playlistName.value;
|
||||
}
|
||||
|
||||
const res = await importPlaylist(params);
|
||||
|
||||
if (res.data.code === 200) {
|
||||
message.success(t('comp.playlist.import.importSuccess'));
|
||||
taskId.value = res.data.data.taskId;
|
||||
startStatusCheck();
|
||||
} else {
|
||||
message.error(res.data.message || t('comp.playlist.import.importFailed'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('导入歌单失败:', error);
|
||||
message.error(t('comp.playlist.import.importFailed'));
|
||||
} finally {
|
||||
importing.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 开始检查任务状态
|
||||
// 任务状态检查逻辑 (复用之前的逻辑)
|
||||
const startStatusCheck = () => {
|
||||
// 清除之前的定时器
|
||||
if (statusCheckInterval.value) {
|
||||
clearInterval(statusCheckInterval.value);
|
||||
}
|
||||
|
||||
// 立即检查一次
|
||||
if (statusCheckInterval.value) clearInterval(statusCheckInterval.value);
|
||||
checkTaskStatus();
|
||||
|
||||
// 设置定时检查
|
||||
statusCheckInterval.value = window.setInterval(() => {
|
||||
checkTaskStatus();
|
||||
}, 3000); // 每3秒检查一次
|
||||
statusCheckInterval.value = window.setInterval(checkTaskStatus, 3000);
|
||||
};
|
||||
|
||||
// 检查任务状态
|
||||
const checkTaskStatus = async () => {
|
||||
if (!taskId.value) return;
|
||||
|
||||
try {
|
||||
checkingStatus.value = true;
|
||||
const res = await getImportTaskStatus(taskId.value);
|
||||
if (res.data.code === 200 && res.data.data.tasks?.length > 0) {
|
||||
const taskData = res.data.data.tasks[0];
|
||||
const statusMap: Record<string, string> = {
|
||||
PENDING: 'pending',
|
||||
PROCESSING: 'processing',
|
||||
COMPLETE: 'success',
|
||||
FAILED: 'failed'
|
||||
};
|
||||
taskStatus.value = statusMap[taskData.status] || 'pending';
|
||||
|
||||
if (res.data.code === 200) {
|
||||
// 新的API返回格式处理
|
||||
if (res.data.data.tasks && res.data.data.tasks.length > 0) {
|
||||
const taskData = res.data.data.tasks[0];
|
||||
// 将API返回的status映射到组件内部使用的taskStatus
|
||||
const statusMap: Record<string, string> = {
|
||||
PENDING: 'pending',
|
||||
PROCESSING: 'processing',
|
||||
COMPLETE: 'success',
|
||||
FAILED: 'failed'
|
||||
};
|
||||
|
||||
taskStatus.value = statusMap[taskData.status] || 'pending';
|
||||
|
||||
if (taskStatus.value === 'success') {
|
||||
successCount.value = taskData.succCount || 0;
|
||||
// 成功后停止检查
|
||||
if (statusCheckInterval.value) {
|
||||
clearInterval(statusCheckInterval.value);
|
||||
statusCheckInterval.value = null;
|
||||
}
|
||||
} else if (taskStatus.value === 'failed') {
|
||||
failReason.value = taskData.msg || t('comp.playlist.import.unknownError');
|
||||
// 失败后停止检查
|
||||
if (statusCheckInterval.value) {
|
||||
clearInterval(statusCheckInterval.value);
|
||||
statusCheckInterval.value = null;
|
||||
}
|
||||
}
|
||||
if (taskStatus.value === 'success') {
|
||||
successCount.value = taskData.succCount || 0;
|
||||
if (statusCheckInterval.value) clearInterval(statusCheckInterval.value);
|
||||
} else if (taskStatus.value === 'failed') {
|
||||
failReason.value = taskData.msg || t('comp.playlist.import.unknownError');
|
||||
if (statusCheckInterval.value) clearInterval(statusCheckInterval.value);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -454,12 +473,8 @@ const checkTaskStatus = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 手动刷新状态
|
||||
const refreshStatus = () => {
|
||||
checkTaskStatus();
|
||||
};
|
||||
const refreshStatus = () => checkTaskStatus();
|
||||
|
||||
// 获取状态文本
|
||||
const getStatusText = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
@@ -476,157 +491,40 @@ const getStatusText = (status: string) => {
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
// 如果有任务ID,开始检查状态
|
||||
if (taskId.value) {
|
||||
startStatusCheck();
|
||||
}
|
||||
if (taskId.value) startStatusCheck();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
// 清除定时器
|
||||
if (statusCheckInterval.value) {
|
||||
clearInterval(statusCheckInterval.value);
|
||||
statusCheckInterval.value = null;
|
||||
}
|
||||
if (statusCheckInterval.value) clearInterval(statusCheckInterval.value);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.import-playlist-page {
|
||||
@apply h-full overflow-auto pr-4;
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.4s ease-out;
|
||||
}
|
||||
|
||||
.import-header {
|
||||
@apply flex justify-between items-center mb-6;
|
||||
.animate-fade-in-up {
|
||||
animation: fadeInUp 0.5s cubic-bezier(0.16, 1, 0.3, 1) backwards;
|
||||
}
|
||||
|
||||
.import-header-left {
|
||||
h2 {
|
||||
@apply text-2xl font-bold text-gray-900 dark:text-white mb-2;
|
||||
}
|
||||
|
||||
.import-desc {
|
||||
@apply text-sm text-gray-500 dark:text-gray-400;
|
||||
}
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.import-content {
|
||||
@apply space-y-6;
|
||||
}
|
||||
|
||||
.import-card {
|
||||
@apply rounded-lg;
|
||||
|
||||
.tab-content {
|
||||
@apply mt-4 space-y-4;
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
.link-tips,
|
||||
.text-tips,
|
||||
.local-tips {
|
||||
@apply text-sm text-gray-500 dark:text-gray-400;
|
||||
|
||||
ul {
|
||||
@apply list-disc pl-5 mt-2;
|
||||
}
|
||||
}
|
||||
|
||||
.text-format,
|
||||
.local-format {
|
||||
@apply mt-2 font-medium;
|
||||
}
|
||||
|
||||
.code-example {
|
||||
@apply mt-2 p-3 bg-gray-100 dark:bg-gray-800 rounded text-sm overflow-auto;
|
||||
}
|
||||
|
||||
.link-inputs {
|
||||
@apply space-y-3;
|
||||
|
||||
.link-row {
|
||||
@apply flex items-center space-x-2;
|
||||
|
||||
.link-input {
|
||||
@apply flex-1;
|
||||
}
|
||||
}
|
||||
|
||||
.link-actions {
|
||||
@apply mt-3 flex justify-end;
|
||||
}
|
||||
}
|
||||
|
||||
.metadata-inputs {
|
||||
@apply space-y-3;
|
||||
|
||||
.metadata-row {
|
||||
@apply flex items-center space-x-2;
|
||||
|
||||
.metadata-input {
|
||||
@apply flex-1;
|
||||
}
|
||||
}
|
||||
|
||||
.metadata-actions {
|
||||
@apply mt-3 flex justify-end;
|
||||
}
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
@apply flex items-center space-x-4 mt-6;
|
||||
|
||||
.playlist-name-input {
|
||||
@apply max-w-xs;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.import-status-card {
|
||||
@apply rounded-lg;
|
||||
|
||||
.status-header {
|
||||
@apply flex justify-between items-center mb-4;
|
||||
|
||||
h3 {
|
||||
@apply text-lg font-medium text-gray-900 dark:text-white;
|
||||
}
|
||||
}
|
||||
|
||||
.status-content {
|
||||
@apply space-y-3;
|
||||
}
|
||||
|
||||
.status-item {
|
||||
@apply flex items-center;
|
||||
|
||||
.status-label {
|
||||
@apply text-gray-500 dark:text-gray-400 w-24;
|
||||
}
|
||||
|
||||
.status-value {
|
||||
@apply font-medium;
|
||||
}
|
||||
|
||||
.status-pending,
|
||||
.status-processing {
|
||||
@apply text-blue-500;
|
||||
}
|
||||
|
||||
.status-success {
|
||||
@apply text-green-500;
|
||||
}
|
||||
|
||||
.status-failed {
|
||||
@apply text-red-500;
|
||||
}
|
||||
|
||||
.success-count {
|
||||
@apply text-green-500;
|
||||
}
|
||||
|
||||
.fail-reason {
|
||||
@apply text-red-500;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,31 +1,93 @@
|
||||
<template>
|
||||
<div class="toplist-page">
|
||||
<n-scrollbar class="toplist-container" style="height: 100%" :size="100">
|
||||
<div v-loading="loading" class="toplist-list">
|
||||
<div
|
||||
v-for="(item, index) in topList"
|
||||
:key="item.id"
|
||||
class="toplist-item"
|
||||
:class="setAnimationClass('animate__bounceIn')"
|
||||
:style="getItemAnimationDelay(index)"
|
||||
@click.stop="openToplist(item)"
|
||||
>
|
||||
<div class="toplist-item-img">
|
||||
<n-image
|
||||
class="toplist-item-img-img"
|
||||
:src="getImgUrl(item.coverImgUrl, '300y300')"
|
||||
width="200"
|
||||
height="200"
|
||||
lazy
|
||||
preview-disabled
|
||||
/>
|
||||
<div class="top">
|
||||
<div class="play-count">{{ formatNumber(item.playCount) }}</div>
|
||||
<i class="iconfont icon-videofill"></i>
|
||||
<div class="toplist-page h-full w-full bg-white dark:bg-black transition-colors duration-500">
|
||||
<n-scrollbar class="h-full">
|
||||
<div class="toplist-content w-full pb-32 pt-6 px-4 sm:px-6 lg:px-8 lg:pl-0">
|
||||
<!-- Hero Section -->
|
||||
<div class="mb-10">
|
||||
<h1
|
||||
class="text-3xl md:text-4xl font-bold tracking-tight text-neutral-900 dark:text-white mb-2"
|
||||
>
|
||||
排行榜
|
||||
</h1>
|
||||
<p class="text-neutral-500 dark:text-neutral-400">
|
||||
最具权威的音乐榜单,发现当下最热门的音乐
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Toplist Grid -->
|
||||
<div class="toplist-grid-container">
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-6">
|
||||
<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>
|
||||
|
||||
<!-- Content State -->
|
||||
<div v-else class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-6">
|
||||
<div
|
||||
v-for="(item, index) in topList"
|
||||
:key="item.id"
|
||||
class="toplist-card group cursor-pointer animate-item"
|
||||
:style="{ animationDelay: calculateAnimationDelay(index, 0.05) }"
|
||||
@click.stop="openToplist(item)"
|
||||
>
|
||||
<!-- Cover Image -->
|
||||
<div
|
||||
class="relative aspect-square overflow-hidden rounded-2xl shadow-md group-hover:shadow-xl transition-all duration-500"
|
||||
>
|
||||
<img
|
||||
:src="getImgUrl(item.coverImgUrl, '400y400')"
|
||||
: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/20 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>
|
||||
|
||||
<!-- Update Frequency Badge -->
|
||||
<div
|
||||
class="absolute bottom-3 left-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"
|
||||
v-if="item.updateFrequency"
|
||||
>
|
||||
{{ item.updateFrequency }}
|
||||
</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.updateFrequency || '网易云音乐榜单' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="toplist-item-title">{{ item.name }}</div>
|
||||
<div class="toplist-item-desc">{{ item.updateFrequency || '' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</n-scrollbar>
|
||||
@@ -33,48 +95,35 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { getListDetail, getToplist } from '@/api/list';
|
||||
import { getToplist } from '@/api/list';
|
||||
import { navigateToMusicList } from '@/components/common/MusicListNavigator';
|
||||
import type { IListDetail } from '@/types/listDetail';
|
||||
import { formatNumber, getImgUrl, setAnimationClass, setAnimationDelay } from '@/utils';
|
||||
import { calculateAnimationDelay, formatNumber, getImgUrl } from '@/utils';
|
||||
|
||||
defineOptions({
|
||||
name: 'Toplist'
|
||||
});
|
||||
|
||||
const topList = ref<any[]>([]);
|
||||
|
||||
// 计算每个项目的动画延迟
|
||||
const getItemAnimationDelay = (index: number) => {
|
||||
return setAnimationDelay(index, 30);
|
||||
};
|
||||
|
||||
const listDetail = ref<IListDetail | null>();
|
||||
const listLoading = ref(true);
|
||||
|
||||
const router = useRouter();
|
||||
const topList = ref<any[]>([]);
|
||||
const loading = ref(false);
|
||||
|
||||
const openToplist = (item: any) => {
|
||||
listLoading.value = true;
|
||||
|
||||
getListDetail(item.id).then((res) => {
|
||||
listDetail.value = res.data;
|
||||
listLoading.value = false;
|
||||
|
||||
const openToplist = async (item: any) => {
|
||||
try {
|
||||
navigateToMusicList(router, {
|
||||
id: item.id,
|
||||
type: 'playlist',
|
||||
name: item.name,
|
||||
songList: res.data.playlist.tracks || [],
|
||||
listInfo: res.data.playlist,
|
||||
listInfo: item,
|
||||
canRemove: false
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取榜单详情失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const loading = ref(false);
|
||||
const loadToplist = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
@@ -94,75 +143,29 @@ onMounted(() => {
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.toplist-page {
|
||||
@apply relative h-full w-full;
|
||||
@apply bg-light dark:bg-black;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.toplist-container {
|
||||
@apply p-4;
|
||||
.animate-item {
|
||||
animation: fadeInUp 0.6s cubic-bezier(0.16, 1, 0.3, 1) backwards;
|
||||
}
|
||||
|
||||
.toplist-list {
|
||||
@apply grid gap-x-8 gap-y-6 pb-28 pr-4;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
}
|
||||
|
||||
.toplist-item {
|
||||
@apply flex flex-col;
|
||||
|
||||
&-img {
|
||||
@apply rounded-xl overflow-hidden relative w-full aspect-square;
|
||||
|
||||
&-img {
|
||||
@apply block w-full h-full;
|
||||
}
|
||||
|
||||
img {
|
||||
@apply absolute top-0 left-0 w-full h-full object-cover rounded-xl;
|
||||
}
|
||||
|
||||
&:hover img {
|
||||
@apply hover:scale-110 transition-all duration-300 ease-in-out;
|
||||
}
|
||||
|
||||
.top {
|
||||
@apply absolute w-full h-full top-0 left-0 flex justify-center items-center transition-all duration-300 ease-in-out cursor-pointer;
|
||||
@apply bg-black bg-opacity-50;
|
||||
opacity: 0;
|
||||
|
||||
i {
|
||||
@apply text-5xl text-white transition-all duration-500 ease-in-out opacity-0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
@apply opacity-100;
|
||||
}
|
||||
|
||||
&:hover i {
|
||||
@apply transform scale-150 opacity-100;
|
||||
}
|
||||
|
||||
.play-count {
|
||||
@apply absolute top-2 left-2 text-sm text-white;
|
||||
}
|
||||
}
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(24px);
|
||||
}
|
||||
|
||||
&-title {
|
||||
@apply mt-2 text-sm line-clamp-1 font-bold;
|
||||
@apply text-gray-900 dark:text-white;
|
||||
}
|
||||
|
||||
&-desc {
|
||||
@apply mt-1 text-xs line-clamp-1;
|
||||
@apply text-gray-500 dark:text-gray-400;
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.mobile {
|
||||
.toplist-list {
|
||||
@apply px-4 gap-4;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
.toplist-card {
|
||||
&:hover {
|
||||
.play-icon {
|
||||
@apply opacity-100 scale-100;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user