416 lines
9.4 KiB
Vue
416 lines
9.4 KiB
Vue
<template>
|
|
<div class="page">
|
|
<n-carousel autoplay draggable :interval="5000" class="banner-carousel">
|
|
<div v-for="banner in banners" :key="banner.title" class="banner" :style="{ background: banner.gradient }">
|
|
<div class="banner-text">
|
|
<h2>{{ banner.title }}</h2>
|
|
<p>{{ banner.sub }}</p>
|
|
</div>
|
|
<AppIcon name="music" :size="72" class="banner-mark" />
|
|
</div>
|
|
</n-carousel>
|
|
|
|
<section class="section">
|
|
<div class="section-head">
|
|
<h3>分类</h3>
|
|
<span class="section-sub">歌单分类</span>
|
|
</div>
|
|
<n-scrollbar x-scrollable class="category-scrollbar">
|
|
<n-radio-group v-model:value="activeCategory" name="playlist-category" @update:value="playList">
|
|
<n-radio-button v-for="category in categories" :key="category.id" :value="category.id" :label="category.name" />
|
|
</n-radio-group>
|
|
</n-scrollbar>
|
|
<div class="card-grid">
|
|
<MusicCard v-for="playlist in recommendedPlaylists" :key="playlist.id" :playlist="playlist" />
|
|
</div>
|
|
</section>
|
|
|
|
<section class="section">
|
|
<div class="section-head">
|
|
<h3>推荐歌单</h3>
|
|
<span class="section-sub">为你精选</span>
|
|
</div>
|
|
<!-- <div class="card-grid">
|
|
<MusicCard v-for="playlist in recommendedPlaylists" :key="playlist.id" :playlist="playlist" />
|
|
</div> -->
|
|
</section>
|
|
|
|
<section class="section">
|
|
<div class="section-head">
|
|
<h3>新歌速递</h3>
|
|
<span class="section-sub">每日 20:00 更新</span>
|
|
</div>
|
|
<div class="new-song-row">
|
|
<div
|
|
v-for="(song, i) in newSongs"
|
|
:key="song.id"
|
|
class="new-song"
|
|
tabindex="0"
|
|
role="button"
|
|
:aria-label="`播放 ${song.title}`"
|
|
@click="playNewSong(i)"
|
|
@keydown.enter="playNewSong(i)"
|
|
>
|
|
<div class="mini-cover" :style="{ background: song.cover }">
|
|
<AppIcon name="music" :size="18" class="mini-mark" />
|
|
<span class="mini-play"><AppIcon name="play" :size="11" /></span>
|
|
</div>
|
|
<p class="mini-title" :title="song.title">{{ song.title }}</p>
|
|
<p class="mini-artist">{{ song.artist }} · {{ formatTime(song.duration) }}</p>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="section">
|
|
<div class="section-head">
|
|
<h3>排行榜精选</h3>
|
|
<span class="section-sub">总有一个榜单属于你</span>
|
|
</div>
|
|
<div class="rank-preview-grid">
|
|
<div
|
|
v-for="rank in rankLists.slice(0, 3)"
|
|
:key="rank.id"
|
|
class="rank-preview"
|
|
tabindex="0"
|
|
role="button"
|
|
:aria-label="`查看榜单 ${rank.name}`"
|
|
@click="router.push(`/playlist/${rank.id}`)"
|
|
@keydown.enter="router.push(`/playlist/${rank.id}`)"
|
|
>
|
|
<div class="rank-head" :style="{ background: rank.cover }">
|
|
<span class="rank-name">{{ rank.name }}</span>
|
|
<AppIcon name="chevron-right" :size="16" />
|
|
</div>
|
|
<ol class="rank-list">
|
|
<li v-for="(song, i) in rank.songs.slice(0, 5)" :key="song.id">
|
|
<span class="rank-no" :class="{ top: i < 3 }">{{ i + 1 }}</span>
|
|
<span class="rank-title" :title="song.title">{{ song.title }}</span>
|
|
<span class="rank-artist">{{ song.artist }}</span>
|
|
</li>
|
|
</ol>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { NCarousel, useMessage } from "naive-ui"
|
|
import { useRouter } from "vue-router"
|
|
import AppIcon from "@/components/AppIcon.vue"
|
|
import MusicCard from "@/components/MusicCard.vue"
|
|
import { newSongs, rankLists, type Playlist } from "@/mocks/music"
|
|
import { playSong } from "@/stores/player"
|
|
import { proxyCover } from "@/utils/cover"
|
|
import { formatPlayCount } from "@/utils/format"
|
|
import { formatTime } from "@/utils/time"
|
|
import PlayListApi from "@/api/playlist"
|
|
import Source from "@/types/global"
|
|
import { cachePlayListSummary, type PlayListCategory } from "@/api/playlist"
|
|
|
|
const message = useMessage()
|
|
const router = useRouter()
|
|
|
|
const categories = ref<PlayListCategory[]>([])
|
|
const activeCategory = ref<string>("")
|
|
const recommendedPlaylists = ref<Playlist[]>([])
|
|
|
|
const banners = [
|
|
{
|
|
title: "本周主打",
|
|
sub: "把耳朵交给这 12 首新单曲",
|
|
gradient: "linear-gradient(120deg, #4338ca 0%, #7c3aed 55%, #ec4899 100%)",
|
|
},
|
|
{
|
|
title: "私人雷达",
|
|
sub: "根据你的口味生成的专属歌单",
|
|
gradient: "linear-gradient(120deg, #0ea5e9 0%, #22d3ee 55%, #22c55e 100%)",
|
|
},
|
|
{
|
|
title: "热歌榜",
|
|
sub: "全站播放量最高的 100 首歌",
|
|
gradient: "linear-gradient(120deg, #f59e0b 0%, #ef4444 55%, #f97316 100%)",
|
|
},
|
|
]
|
|
|
|
function playNewSong(songIndex: number) {
|
|
const song = newSongs[songIndex]
|
|
playSong(song, newSongs)
|
|
message.success(`正在播放:${song.title}`)
|
|
}
|
|
|
|
const list = async () => {
|
|
const res = await PlayListApi.categoryList([Source.KuGou])
|
|
categories.value = res[0].categories
|
|
if (categories.value.length > 0) {
|
|
activeCategory.value = categories.value[1].id
|
|
await playList(activeCategory.value)
|
|
}
|
|
}
|
|
|
|
const playList = async (categoryId: string) => {
|
|
const res = await PlayListApi.categoryPlayList(categoryId, Source.KuGou, 1, 30)
|
|
recommendedPlaylists.value = res.playlists.map((p) => {
|
|
cachePlayListSummary(p)
|
|
return {
|
|
id: p.id,
|
|
name: p.name,
|
|
desc: p.description,
|
|
cover: proxyCover(p.cover),
|
|
tags: [],
|
|
playCount: formatPlayCount(p.play_count),
|
|
songs: [],
|
|
}
|
|
})
|
|
}
|
|
|
|
onMounted(() => {
|
|
list()
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.page {
|
|
max-width: 1200px;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
.banner-carousel {
|
|
height: clamp(150px, 17vw, 200px);
|
|
border-radius: 14px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.banner {
|
|
position: relative;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
height: 100%;
|
|
padding: 0 clamp(20px, 4vw, 44px);
|
|
overflow: hidden;
|
|
}
|
|
|
|
.banner-text h2 {
|
|
margin: 0 0 8px;
|
|
font-size: clamp(22px, 3.2vw, 30px);
|
|
font-weight: 700;
|
|
color: #fff;
|
|
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.25);
|
|
}
|
|
|
|
.banner-text p {
|
|
margin: 0;
|
|
font-size: 15px;
|
|
color: rgba(255, 255, 255, 0.85);
|
|
}
|
|
|
|
.banner-mark {
|
|
position: absolute;
|
|
right: clamp(16px, 4vw, 36px);
|
|
opacity: 0.28;
|
|
color: #fff;
|
|
transform: rotate(-8deg);
|
|
}
|
|
|
|
.section {
|
|
margin-top: clamp(24px, 3vw, 34px);
|
|
}
|
|
|
|
.section-head {
|
|
display: flex;
|
|
align-items: baseline;
|
|
gap: 12px;
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.section-head h3 {
|
|
margin: 0;
|
|
font-size: clamp(17px, 1.8vw, 20px);
|
|
font-weight: 600;
|
|
}
|
|
|
|
.section-sub {
|
|
font-size: 12px;
|
|
color: var(--app-text-muted);
|
|
}
|
|
|
|
.category-scrollbar {
|
|
padding-bottom: 8px;
|
|
}
|
|
|
|
.card-grid {
|
|
display: grid;
|
|
margin-top: 20px;
|
|
grid-template-columns: repeat(auto-fill, minmax(clamp(140px, 14vw, 180px), 1fr));
|
|
gap: 20px 18px;
|
|
}
|
|
|
|
.new-song-row {
|
|
display: grid;
|
|
grid-auto-flow: column;
|
|
grid-auto-columns: clamp(150px, 16vw, 200px);
|
|
gap: 14px;
|
|
overflow-x: auto;
|
|
padding-bottom: 8px;
|
|
}
|
|
|
|
.new-song {
|
|
cursor: pointer;
|
|
min-width: 0;
|
|
}
|
|
|
|
.mini-cover {
|
|
position: relative;
|
|
aspect-ratio: 1;
|
|
border-radius: 10px;
|
|
overflow: hidden;
|
|
display: grid;
|
|
place-items: center;
|
|
}
|
|
|
|
.mini-mark {
|
|
color: rgba(255, 255, 255, 0.4);
|
|
}
|
|
|
|
.mini-play {
|
|
position: absolute;
|
|
right: 8px;
|
|
bottom: 8px;
|
|
display: grid;
|
|
place-items: center;
|
|
width: 30px;
|
|
height: 30px;
|
|
border-radius: 50%;
|
|
background: var(--app-green);
|
|
color: #052e16;
|
|
opacity: 0;
|
|
transform: translateY(5px);
|
|
transition:
|
|
opacity 0.2s ease,
|
|
transform 0.2s ease;
|
|
}
|
|
|
|
.new-song:hover .mini-play,
|
|
.new-song:focus-visible .mini-play {
|
|
opacity: 1;
|
|
transform: translateY(0);
|
|
}
|
|
|
|
.mini-title {
|
|
margin: 8px 0 2px;
|
|
font-size: 13px;
|
|
color: var(--app-text);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.mini-artist {
|
|
margin: 0;
|
|
font-size: 12px;
|
|
color: var(--app-text-muted);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.rank-preview-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, 1fr);
|
|
gap: 18px;
|
|
}
|
|
|
|
.rank-preview {
|
|
border: 1px solid var(--app-border);
|
|
border-radius: 12px;
|
|
overflow: hidden;
|
|
cursor: pointer;
|
|
transition:
|
|
transform 0.2s ease,
|
|
border-color 0.2s ease,
|
|
box-shadow 0.2s ease;
|
|
}
|
|
|
|
.rank-preview:hover,
|
|
.rank-preview:focus-visible {
|
|
transform: translateY(-2px);
|
|
border-color: rgba(109, 93, 246, 0.45);
|
|
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.3);
|
|
}
|
|
|
|
.rank-head {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
height: 52px;
|
|
padding: 0 16px;
|
|
color: rgba(255, 255, 255, 0.9);
|
|
}
|
|
|
|
.rank-name {
|
|
font-size: 16px;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.rank-list {
|
|
margin: 0;
|
|
padding: 8px 16px 12px;
|
|
list-style: none;
|
|
}
|
|
|
|
.rank-list li {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
height: 30px;
|
|
font-size: 13px;
|
|
}
|
|
|
|
.rank-no {
|
|
width: 18px;
|
|
color: var(--app-text-muted);
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
.rank-no.top {
|
|
color: var(--app-green);
|
|
font-weight: 700;
|
|
}
|
|
|
|
.rank-title {
|
|
flex: 1;
|
|
min-width: 0;
|
|
color: var(--app-text);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.rank-artist {
|
|
color: var(--app-text-muted);
|
|
font-size: 12px;
|
|
}
|
|
|
|
@media (max-width: 720px) {
|
|
.banner-mark {
|
|
width: 48px;
|
|
height: 48px;
|
|
}
|
|
|
|
.banner-text h2 {
|
|
font-size: 22px;
|
|
}
|
|
|
|
.banner-text p {
|
|
font-size: 13px;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 900px) {
|
|
.rank-preview-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
</style>
|