refactor: 重构项目适配后端API,新增工具与类型定义
1. 重构Song类型支持string/number类型id,更新相关组件的点赞逻辑 2. 新增播放量格式化、封面处理工具函数 3. 注册naive-ui全局组件类型,调整App.vue模板位置 4. 重写axios请求拦截器与封装,自动解包响应数据 5. 新增歌单相关API封装,实现分类歌单与详情获取 6. 修复MusicCard、PlayerBar、FullscreenPlayer的封面渲染逻辑 7. 重构DiscoverView,接入真实歌单API替换mock数据 8. 完善PlaylistDetailView,支持加载真实歌单数据与加载态
This commit is contained in:
parent
67cce05b34
commit
98083829cb
28
src/App.vue
28
src/App.vue
|
|
@ -1,3 +1,17 @@
|
|||
<template>
|
||||
<div class="app-root">
|
||||
<n-config-provider :theme="darkTheme" :theme-overrides="themeOverrides">
|
||||
<n-message-provider>
|
||||
<n-notification-provider>
|
||||
<n-dialog-provider>
|
||||
<RouterView />
|
||||
</n-dialog-provider>
|
||||
</n-notification-provider>
|
||||
</n-message-provider>
|
||||
</n-config-provider>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
darkTheme,
|
||||
|
|
@ -39,20 +53,6 @@ const themeOverrides: GlobalThemeOverrides = {
|
|||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-root">
|
||||
<n-config-provider :theme="darkTheme" :theme-overrides="themeOverrides">
|
||||
<n-message-provider>
|
||||
<n-notification-provider>
|
||||
<n-dialog-provider>
|
||||
<RouterView />
|
||||
</n-dialog-provider>
|
||||
</n-notification-provider>
|
||||
</n-message-provider>
|
||||
</n-config-provider>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-root {
|
||||
height: 100%;
|
||||
|
|
|
|||
|
|
@ -1,13 +1,119 @@
|
|||
import instance from "@/utils/request"
|
||||
import Source from "@/types/global"
|
||||
import { get } from "@/utils/request";
|
||||
import Source from "@/types/global";
|
||||
|
||||
interface PlayListCategory {
|
||||
id: string
|
||||
name: string
|
||||
group: string
|
||||
source: string
|
||||
count: number
|
||||
}
|
||||
|
||||
interface PlayListCategoryRes {
|
||||
source: string
|
||||
name: string
|
||||
categories: PlayListCategory[]
|
||||
}
|
||||
|
||||
/** 歌单条目(分类歌单列表中的每一项) */
|
||||
interface PlayListSummary {
|
||||
id: string
|
||||
name: string
|
||||
cover: string
|
||||
track_count: number
|
||||
play_count: number
|
||||
creator: string
|
||||
description: string
|
||||
source: string
|
||||
link: string
|
||||
/** 平台相关的附加元数据,字段随 source 变化 */
|
||||
extra: {
|
||||
category_id: string
|
||||
global_specialid: string
|
||||
id: string
|
||||
publish_time: string
|
||||
tag_id: string
|
||||
}
|
||||
}
|
||||
|
||||
/** 分类歌单接口返回的数据结构 */
|
||||
interface CategoryPlayListRes {
|
||||
category_id: string
|
||||
source: string
|
||||
page: number
|
||||
limit: number
|
||||
playlists: PlayListSummary[]
|
||||
}
|
||||
|
||||
/** 歌单详情中的歌曲条目 */
|
||||
interface PlayListSong {
|
||||
id: string
|
||||
name: string
|
||||
artist: string
|
||||
album: string
|
||||
album_id: string
|
||||
/** 时长(秒) */
|
||||
duration: number
|
||||
/** 文件大小(字节) */
|
||||
size: number
|
||||
bitrate: number
|
||||
source: string
|
||||
/** 播放地址,可能为空字符串 */
|
||||
url: string
|
||||
/** 文件扩展名,可能为空字符串 */
|
||||
ext: string
|
||||
cover: string
|
||||
link: string
|
||||
/** 平台相关的附加元数据,字段随 source 变化 */
|
||||
extra: {
|
||||
album_id: string
|
||||
audio_id: string
|
||||
file_hash: string
|
||||
hash: string
|
||||
hq_hash: string
|
||||
mv_hash: string
|
||||
ogg_128_hash: string
|
||||
ogg_320_hash: string
|
||||
privilege: string
|
||||
res_hash: string
|
||||
sq_hash: string
|
||||
}
|
||||
}
|
||||
|
||||
/** 歌单摘要缓存:详情页通过它读取歌单标题、封面等元信息 */
|
||||
const playListSummaryCache = new Map<string, PlayListSummary>();
|
||||
|
||||
export function cachePlayListSummary(summary: PlayListSummary) {
|
||||
playListSummaryCache.set(summary.id, summary);
|
||||
}
|
||||
|
||||
export function getCachedPlayListSummary(id: string): PlayListSummary | undefined {
|
||||
return playListSummaryCache.get(id);
|
||||
}
|
||||
|
||||
const PlayListApi = {
|
||||
// 获取歌单分类
|
||||
getCategoryList: (sources?: Source[]) => {
|
||||
return instance.get<any>(`/api/v1/playlist/categories?sources=${sources}`)
|
||||
return get<PlayListCategoryRes[]>(`/api/v1/playlist/categories?sources=${sources}`);
|
||||
},
|
||||
// 获取分类歌单
|
||||
getCategoryPlayList: () => {},
|
||||
}
|
||||
getCategoryPlayList: (
|
||||
categoryId: string,
|
||||
source?: Source,
|
||||
page?: number,
|
||||
limit?: number,
|
||||
) => {
|
||||
return get<CategoryPlayListRes>(
|
||||
`/api/v1/playlist/category?source=${source}&category_id=${categoryId}&page=${page}&limit=${limit}`,
|
||||
);
|
||||
},
|
||||
// 获取歌单详情
|
||||
getPlayListDetail: (source: Source, id: string) => {
|
||||
return get<PlayListSong[]>(
|
||||
`/api/v1/playlist/detail?source=${source}&id=${id}`,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export default PlayListApi
|
||||
export default PlayListApi;
|
||||
export type { PlayListCategory, PlayListCategoryRes, PlayListSummary, CategoryPlayListRes, PlayListSong }
|
||||
|
|
|
|||
|
|
@ -1,3 +1,21 @@
|
|||
<template>
|
||||
<svg
|
||||
:width="size"
|
||||
:height="size"
|
||||
viewBox="0 0 24 24"
|
||||
:fill="isFilled ? 'currentColor' : 'none'"
|
||||
:stroke="isFilled ? 'none' : 'currentColor'"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
:role="label ? 'img' : undefined"
|
||||
:aria-label="label || undefined"
|
||||
:aria-hidden="label ? undefined : true"
|
||||
>
|
||||
<path v-for="(d, i) in paths[name]" :key="i" :d="d" />
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
|
||||
|
|
@ -71,21 +89,3 @@ const paths: Record<string, string[]> = {
|
|||
|
||||
const isFilled = computed(() => filledNames.has(props.name));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
:width="size"
|
||||
:height="size"
|
||||
viewBox="0 0 24 24"
|
||||
:fill="isFilled ? 'currentColor' : 'none'"
|
||||
:stroke="isFilled ? 'none' : 'currentColor'"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
:role="label ? 'img' : undefined"
|
||||
:aria-label="label || undefined"
|
||||
:aria-hidden="label ? undefined : true"
|
||||
>
|
||||
<path v-for="(d, i) in paths[name]" :key="i" :d="d" />
|
||||
</svg>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,80 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { NSlider } from "naive-ui";
|
||||
import { playerState } from "@/stores/player";
|
||||
import { formatTime } from "@/utils/time";
|
||||
import AppIcon from "./AppIcon.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: [];
|
||||
togglePlay: [];
|
||||
next: [];
|
||||
prev: [];
|
||||
toggleLike: [];
|
||||
}>();
|
||||
|
||||
const current = computed(() => playerState.current);
|
||||
const isLiked = ref(false);
|
||||
|
||||
// 模拟歌词数据
|
||||
const lyrics = computed(() => {
|
||||
if (!current.value) return [];
|
||||
return [
|
||||
{ time: 0, text: current.value.title },
|
||||
{ time: 10, text: "词曲:" + current.value.artist },
|
||||
{ time: 20, text: "" },
|
||||
{ time: 30, text: "夜色温柔如你眼眸" },
|
||||
{ time: 40, text: "星光洒落在指尖流转" },
|
||||
{ time: 50, text: "时光凝固在这一刻" },
|
||||
{ time: 60, text: "只想永远陪在你身边" },
|
||||
{ time: 70, text: "" },
|
||||
{ time: 80, text: "听风吹过树梢的声音" },
|
||||
{ time: 90, text: "看月光倒映在湖面" },
|
||||
{ time: 100, text: "所有美好都在此刻绽放" },
|
||||
{ time: 110, text: "愿这旋律伴你入眠" },
|
||||
{ time: 120, text: "" },
|
||||
{ time: 130, text: "la la la la~" },
|
||||
{ time: 140, text: "la la la la~" },
|
||||
{ time: 150, text: "轻轻哼唱这首歌" },
|
||||
{ time: 160, text: "让温暖填满整个夜" },
|
||||
];
|
||||
});
|
||||
|
||||
const currentLyricIndex = computed(() => {
|
||||
const time = playerState.currentTime;
|
||||
let index = 0;
|
||||
for (let i = 0; i < lyrics.value.length; i++) {
|
||||
if (lyrics.value[i].time <= time) {
|
||||
index = i;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return index;
|
||||
});
|
||||
|
||||
const lyricContainerRef = ref<HTMLElement | null>(null);
|
||||
|
||||
watch(currentLyricIndex, (newIndex) => {
|
||||
if (lyricContainerRef.value) {
|
||||
const lineHeight = 52;
|
||||
const scrollTop = Math.max(0, newIndex * lineHeight - 120);
|
||||
lyricContainerRef.value.scrollTo({
|
||||
top: scrollTop,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition name="fullscreen">
|
||||
<div v-if="show" class="fullscreen-player">
|
||||
<div class="fullscreen-bg" :style="current ? { background: current.cover } : undefined" />
|
||||
<div class="fullscreen-bg" :style="current ? { background: coverBackground(current.cover) } : undefined" />
|
||||
<div class="fullscreen-overlay" />
|
||||
|
||||
<button class="close-btn" type="button" aria-label="关闭全屏" @click="emit('close')">
|
||||
|
|
@ -87,7 +14,7 @@ watch(currentLyricIndex, (newIndex) => {
|
|||
<div
|
||||
class="cover-rotating"
|
||||
:class="{ spinning: playerState.isPlaying }"
|
||||
:style="current ? { background: current.cover } : undefined"
|
||||
:style="current ? { background: coverBackground(current.cover) } : undefined"
|
||||
>
|
||||
<div class="cover-center" />
|
||||
</div>
|
||||
|
|
@ -166,6 +93,80 @@ watch(currentLyricIndex, (newIndex) => {
|
|||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { NSlider } from "naive-ui";
|
||||
import { playerState } from "@/stores/player";
|
||||
import { coverBackground } from "@/utils/cover";
|
||||
import { formatTime } from "@/utils/time";
|
||||
import AppIcon from "./AppIcon.vue";
|
||||
|
||||
defineProps<{
|
||||
show: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: [];
|
||||
togglePlay: [];
|
||||
next: [];
|
||||
prev: [];
|
||||
toggleLike: [];
|
||||
}>();
|
||||
|
||||
const current = computed(() => playerState.current);
|
||||
const isLiked = ref(false);
|
||||
|
||||
// 模拟歌词数据
|
||||
const lyrics = computed(() => {
|
||||
if (!current.value) return [];
|
||||
return [
|
||||
{ time: 0, text: current.value.title },
|
||||
{ time: 10, text: "词曲:" + current.value.artist },
|
||||
{ time: 20, text: "" },
|
||||
{ time: 30, text: "夜色温柔如你眼眸" },
|
||||
{ time: 40, text: "星光洒落在指尖流转" },
|
||||
{ time: 50, text: "时光凝固在这一刻" },
|
||||
{ time: 60, text: "只想永远陪在你身边" },
|
||||
{ time: 70, text: "" },
|
||||
{ time: 80, text: "听风吹过树梢的声音" },
|
||||
{ time: 90, text: "看月光倒映在湖面" },
|
||||
{ time: 100, text: "所有美好都在此刻绽放" },
|
||||
{ time: 110, text: "愿这旋律伴你入眠" },
|
||||
{ time: 120, text: "" },
|
||||
{ time: 130, text: "la la la la~" },
|
||||
{ time: 140, text: "la la la la~" },
|
||||
{ time: 150, text: "轻轻哼唱这首歌" },
|
||||
{ time: 160, text: "让温暖填满整个夜" },
|
||||
];
|
||||
});
|
||||
|
||||
const currentLyricIndex = computed(() => {
|
||||
const time = playerState.currentTime;
|
||||
let index = 0;
|
||||
for (let i = 0; i < lyrics.value.length; i++) {
|
||||
if (lyrics.value[i].time <= time) {
|
||||
index = i;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return index;
|
||||
});
|
||||
|
||||
const lyricContainerRef = ref<HTMLElement | null>(null);
|
||||
|
||||
watch(currentLyricIndex, (newIndex) => {
|
||||
if (lyricContainerRef.value) {
|
||||
const lineHeight = 52;
|
||||
const scrollTop = Math.max(0, newIndex * lineHeight - 120);
|
||||
lyricContainerRef.value.scrollTo({
|
||||
top: scrollTop,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fullscreen-player {
|
||||
position: fixed;
|
||||
|
|
|
|||
|
|
@ -1,26 +1,3 @@
|
|||
<script setup lang="ts">
|
||||
import { useMessage } from "naive-ui";
|
||||
import { useRouter } from "vue-router";
|
||||
import type { Playlist } from "@/mocks/music";
|
||||
import { playSong } from "@/stores/player";
|
||||
import AppIcon from "./AppIcon.vue";
|
||||
|
||||
const props = defineProps<{ playlist: Playlist }>();
|
||||
const router = useRouter();
|
||||
const message = useMessage();
|
||||
|
||||
function openDetail() {
|
||||
router.push(`/playlist/${props.playlist.id}`);
|
||||
}
|
||||
|
||||
function play() {
|
||||
const songs = props.playlist.songs;
|
||||
if (songs.length === 0) return;
|
||||
playSong(songs[0], songs);
|
||||
message.success(`正在播放:${songs[0].title}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="music-card"
|
||||
|
|
@ -30,7 +7,7 @@ function play() {
|
|||
@click="openDetail"
|
||||
@keydown.enter="openDetail"
|
||||
>
|
||||
<div class="cover" :style="{ background: playlist.cover }">
|
||||
<div class="cover" :style="coverStyle">
|
||||
<AppIcon name="music" :size="36" class="cover-mark" />
|
||||
<button class="play-btn" type="button" aria-label="播放歌单" @click.stop="play">
|
||||
<AppIcon name="play" :size="17" />
|
||||
|
|
@ -45,6 +22,36 @@ function play() {
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { useMessage } from "naive-ui";
|
||||
import { useRouter } from "vue-router";
|
||||
import type { Playlist } from "@/mocks/music";
|
||||
import { playSong } from "@/stores/player";
|
||||
import { coverBackground } from "@/utils/cover";
|
||||
import AppIcon from "./AppIcon.vue";
|
||||
|
||||
const props = defineProps<{ playlist: Playlist }>();
|
||||
const router = useRouter();
|
||||
const message = useMessage();
|
||||
|
||||
/** 图片 URL 需要用 url() 包裹,渐变字符串则原样透传 */
|
||||
const coverStyle = computed(() => ({
|
||||
background: coverBackground(props.playlist.cover),
|
||||
}));
|
||||
|
||||
function openDetail() {
|
||||
router.push(`/playlist/${props.playlist.id}`);
|
||||
}
|
||||
|
||||
function play() {
|
||||
const songs = props.playlist.songs;
|
||||
if (songs.length === 0) return;
|
||||
playSong(songs[0], songs);
|
||||
message.success(`正在播放:${songs[0].title}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.music-card {
|
||||
cursor: pointer;
|
||||
|
|
|
|||
|
|
@ -1,127 +1,9 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, onUnmounted, ref, watch } from "vue";
|
||||
import { NPopover, NSlider, useMessage } from "naive-ui";
|
||||
import { playlists } from "@/mocks/music";
|
||||
import { playerState, playSong } from "@/stores/player";
|
||||
import { formatTime } from "@/utils/time";
|
||||
import AppIcon from "./AppIcon.vue";
|
||||
import FullscreenPlayer from "./FullscreenPlayer.vue";
|
||||
|
||||
const message = useMessage();
|
||||
|
||||
const showFullscreen = ref(false);
|
||||
|
||||
const liked = ref(new Set<number>());
|
||||
const shuffle = ref(false);
|
||||
const repeat = ref(false);
|
||||
|
||||
let timer: number | undefined;
|
||||
|
||||
const current = computed(() => playerState.current);
|
||||
|
||||
const currentIndex = computed(() =>
|
||||
playerState.queue.findIndex((song) => song.id === current.value?.id),
|
||||
);
|
||||
|
||||
const isLiked = computed(() =>
|
||||
current.value ? liked.value.has(current.value.id) : false,
|
||||
);
|
||||
|
||||
function toggleLike() {
|
||||
if (!current.value) return;
|
||||
const next = new Set(liked.value);
|
||||
if (next.has(current.value.id)) {
|
||||
next.delete(current.value.id);
|
||||
} else {
|
||||
next.add(current.value.id);
|
||||
}
|
||||
liked.value = next;
|
||||
}
|
||||
|
||||
function next() {
|
||||
if (playerState.queue.length === 0) return;
|
||||
let index = currentIndex.value;
|
||||
if (shuffle.value) {
|
||||
index = Math.floor(Math.random() * playerState.queue.length);
|
||||
} else {
|
||||
index = (index + 1) % playerState.queue.length;
|
||||
}
|
||||
playSong(playerState.queue[index]);
|
||||
}
|
||||
|
||||
function prev() {
|
||||
if (playerState.queue.length === 0) return;
|
||||
let index = currentIndex.value;
|
||||
if (shuffle.value) {
|
||||
index = Math.floor(Math.random() * playerState.queue.length);
|
||||
} else {
|
||||
index = (index - 1 + playerState.queue.length) % playerState.queue.length;
|
||||
}
|
||||
playSong(playerState.queue[index]);
|
||||
}
|
||||
|
||||
function togglePlay() {
|
||||
if (!playerState.current) {
|
||||
const songs = playlists[0].songs;
|
||||
playSong(songs[0], songs);
|
||||
message.success(`正在播放:${songs[0].title}`);
|
||||
return;
|
||||
}
|
||||
playerState.isPlaying = !playerState.isPlaying;
|
||||
}
|
||||
|
||||
function toggleMute() {
|
||||
if (playerState.volume === 0) {
|
||||
playerState.volume = 70;
|
||||
} else {
|
||||
playerState.volume = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function openFullscreen() {
|
||||
if (!playerState.current) {
|
||||
const songs = playlists[0].songs;
|
||||
playSong(songs[0], songs);
|
||||
message.success(`正在播放:${songs[0].title}`);
|
||||
}
|
||||
showFullscreen.value = true;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => playerState.isPlaying,
|
||||
(playing) => {
|
||||
if (playing) {
|
||||
timer = window.setInterval(() => {
|
||||
if (!playerState.current) return;
|
||||
playerState.currentTime += 1;
|
||||
if (playerState.currentTime >= playerState.current.duration) {
|
||||
if (repeat.value) {
|
||||
playerState.currentTime = 0;
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
} else if (timer !== undefined) {
|
||||
window.clearInterval(timer);
|
||||
timer = undefined;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer !== undefined) {
|
||||
window.clearInterval(timer);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<footer class="player-bar">
|
||||
<div class="player-left">
|
||||
<div
|
||||
class="cover-mini"
|
||||
:style="current ? { background: current.cover } : undefined"
|
||||
:style="current ? { background: coverBackground(current.cover) } : undefined"
|
||||
@click="openFullscreen"
|
||||
>
|
||||
<AppIcon name="music" :size="20" class="cover-mini-mark" />
|
||||
|
|
@ -247,6 +129,125 @@ onUnmounted(() => {
|
|||
</footer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onUnmounted, ref, watch } from "vue";
|
||||
import { NPopover, NSlider, useMessage } from "naive-ui";
|
||||
import { playlists } from "@/mocks/music";
|
||||
import { playerState, playSong } from "@/stores/player";
|
||||
import { coverBackground } from "@/utils/cover";
|
||||
import { formatTime } from "@/utils/time";
|
||||
import AppIcon from "./AppIcon.vue";
|
||||
import FullscreenPlayer from "./FullscreenPlayer.vue";
|
||||
|
||||
const message = useMessage();
|
||||
|
||||
const showFullscreen = ref(false);
|
||||
|
||||
const liked = ref(new Set<string | number>());
|
||||
const shuffle = ref(false);
|
||||
const repeat = ref(false);
|
||||
|
||||
let timer: number | undefined;
|
||||
|
||||
const current = computed(() => playerState.current);
|
||||
|
||||
const currentIndex = computed(() =>
|
||||
playerState.queue.findIndex((song) => song.id === current.value?.id),
|
||||
);
|
||||
|
||||
const isLiked = computed(() =>
|
||||
current.value ? liked.value.has(current.value.id) : false,
|
||||
);
|
||||
|
||||
function toggleLike() {
|
||||
if (!current.value) return;
|
||||
const next = new Set(liked.value);
|
||||
if (next.has(current.value.id)) {
|
||||
next.delete(current.value.id);
|
||||
} else {
|
||||
next.add(current.value.id);
|
||||
}
|
||||
liked.value = next;
|
||||
}
|
||||
|
||||
function next() {
|
||||
if (playerState.queue.length === 0) return;
|
||||
let index = currentIndex.value;
|
||||
if (shuffle.value) {
|
||||
index = Math.floor(Math.random() * playerState.queue.length);
|
||||
} else {
|
||||
index = (index + 1) % playerState.queue.length;
|
||||
}
|
||||
playSong(playerState.queue[index]);
|
||||
}
|
||||
|
||||
function prev() {
|
||||
if (playerState.queue.length === 0) return;
|
||||
let index = currentIndex.value;
|
||||
if (shuffle.value) {
|
||||
index = Math.floor(Math.random() * playerState.queue.length);
|
||||
} else {
|
||||
index = (index - 1 + playerState.queue.length) % playerState.queue.length;
|
||||
}
|
||||
playSong(playerState.queue[index]);
|
||||
}
|
||||
|
||||
function togglePlay() {
|
||||
if (!playerState.current) {
|
||||
const songs = playlists[0].songs;
|
||||
playSong(songs[0], songs);
|
||||
message.success(`正在播放:${songs[0].title}`);
|
||||
return;
|
||||
}
|
||||
playerState.isPlaying = !playerState.isPlaying;
|
||||
}
|
||||
|
||||
function toggleMute() {
|
||||
if (playerState.volume === 0) {
|
||||
playerState.volume = 70;
|
||||
} else {
|
||||
playerState.volume = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function openFullscreen() {
|
||||
if (!playerState.current) {
|
||||
const songs = playlists[0].songs;
|
||||
playSong(songs[0], songs);
|
||||
message.success(`正在播放:${songs[0].title}`);
|
||||
}
|
||||
showFullscreen.value = true;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => playerState.isPlaying,
|
||||
(playing) => {
|
||||
if (playing) {
|
||||
timer = window.setInterval(() => {
|
||||
if (!playerState.current) return;
|
||||
playerState.currentTime += 1;
|
||||
if (playerState.currentTime >= playerState.current.duration) {
|
||||
if (repeat.value) {
|
||||
playerState.currentTime = 0;
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
} else if (timer !== undefined) {
|
||||
window.clearInterval(timer);
|
||||
timer = undefined;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer !== undefined) {
|
||||
window.clearInterval(timer);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.player-bar {
|
||||
display: flex;
|
||||
|
|
|
|||
|
|
@ -1,32 +1,3 @@
|
|||
<script setup lang="ts">
|
||||
import { reactive } from "vue";
|
||||
import type { Song } from "@/mocks/music";
|
||||
import { playerState, playSong } from "@/stores/player";
|
||||
import { formatTime } from "@/utils/time";
|
||||
import AppIcon from "./AppIcon.vue";
|
||||
|
||||
const props = defineProps<{ songs: Song[] }>();
|
||||
|
||||
const liked = reactive(new Set<number>());
|
||||
|
||||
function toggleLike(id: number, event: MouseEvent) {
|
||||
event.stopPropagation();
|
||||
if (liked.has(id)) {
|
||||
liked.delete(id);
|
||||
} else {
|
||||
liked.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
function isCurrent(song: Song) {
|
||||
return playerState.current?.id === song.id;
|
||||
}
|
||||
|
||||
function play(song: Song) {
|
||||
playSong(song, props.songs);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="song-table">
|
||||
<div class="table-head">
|
||||
|
|
@ -77,6 +48,35 @@ function play(song: Song) {
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive } from "vue";
|
||||
import type { Song } from "@/mocks/music";
|
||||
import { playerState, playSong } from "@/stores/player";
|
||||
import { formatTime } from "@/utils/time";
|
||||
import AppIcon from "./AppIcon.vue";
|
||||
|
||||
const props = defineProps<{ songs: Song[] }>();
|
||||
|
||||
const liked = reactive(new Set<string | number>());
|
||||
|
||||
function toggleLike(id: string | number, event: MouseEvent) {
|
||||
event.stopPropagation();
|
||||
if (liked.has(id)) {
|
||||
liked.delete(id);
|
||||
} else {
|
||||
liked.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
function isCurrent(song: Song) {
|
||||
return playerState.current?.id === song.id;
|
||||
}
|
||||
|
||||
function play(song: Song) {
|
||||
playSong(song, props.songs);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.song-table {
|
||||
border: 1px solid var(--app-border);
|
||||
|
|
|
|||
|
|
@ -1,33 +1,3 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, watch } from "vue";
|
||||
import { RouterLink, RouterView, useRoute } from "vue-router";
|
||||
import AppIcon from "@/components/AppIcon.vue";
|
||||
import PlayerBar from "@/components/PlayerBar.vue";
|
||||
import { myPlaylists } from "@/mocks/music";
|
||||
|
||||
const route = useRoute();
|
||||
const contentRef = ref<HTMLElement | null>(null);
|
||||
|
||||
const navItems = [
|
||||
{ path: "/", label: "发现音乐", icon: "home" },
|
||||
{ path: "/ranking", label: "排行榜", icon: "trophy" },
|
||||
{ path: "/library", label: "我的音乐", icon: "music" },
|
||||
{ path: "/search", label: "搜索", icon: "search" },
|
||||
] as const;
|
||||
|
||||
function isActive(path: string) {
|
||||
if (path === "/") return route.path === "/";
|
||||
return route.path.startsWith(path);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.path,
|
||||
() => {
|
||||
contentRef.value?.scrollTo({ top: 0 });
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-shell">
|
||||
<aside class="sidebar">
|
||||
|
|
@ -77,6 +47,36 @@ watch(
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from "vue";
|
||||
import { RouterLink, RouterView, useRoute } from "vue-router";
|
||||
import AppIcon from "@/components/AppIcon.vue";
|
||||
import PlayerBar from "@/components/PlayerBar.vue";
|
||||
import { myPlaylists } from "@/mocks/music";
|
||||
|
||||
const route = useRoute();
|
||||
const contentRef = ref<HTMLElement | null>(null);
|
||||
|
||||
const navItems = [
|
||||
{ path: "/", label: "发现音乐", icon: "home" },
|
||||
{ path: "/ranking", label: "排行榜", icon: "trophy" },
|
||||
{ path: "/library", label: "我的音乐", icon: "music" },
|
||||
{ path: "/search", label: "搜索", icon: "search" },
|
||||
] as const;
|
||||
|
||||
function isActive(path: string) {
|
||||
if (path === "/") return route.path === "/";
|
||||
return route.path.startsWith(path);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.path,
|
||||
() => {
|
||||
contentRef.value?.scrollTo({ top: 0 });
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app-shell {
|
||||
display: flex;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
export interface Song {
|
||||
id: number;
|
||||
id: string | number;
|
||||
title: string;
|
||||
artist: string;
|
||||
album: string;
|
||||
|
|
|
|||
|
|
@ -14,6 +14,9 @@ declare module 'vue' {
|
|||
AppIcon: typeof import('./../components/AppIcon.vue')['default']
|
||||
FullscreenPlayer: typeof import('./../components/FullscreenPlayer.vue')['default']
|
||||
MusicCard: typeof import('./../components/MusicCard.vue')['default']
|
||||
NRadioButton: typeof import('naive-ui')['NRadioButton']
|
||||
NRadioGroup: typeof import('naive-ui')['NRadioGroup']
|
||||
NScrollbar: typeof import('naive-ui')['NScrollbar']
|
||||
PlayerBar: typeof import('./../components/PlayerBar.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
/** 判断是否为图片 URL */
|
||||
function isImageUrl(value: string): boolean {
|
||||
return /^(https?:|data:|blob:)/.test(value);
|
||||
}
|
||||
|
||||
/** 通过本地封面代理加载,避免浏览器端被平台防盗链拦截 */
|
||||
export function proxyCover(url: string): string {
|
||||
const base = (import.meta.env.VITE_BASE_API_URL as string ?? "").replace(/\/+$/, "");
|
||||
return `${base}/api/v1/music/cover?url=${encodeURIComponent(url)}`;
|
||||
}
|
||||
|
||||
/** 生成 CSS background 值:图片 URL 用 url() 包裹,渐变等字符串原样透传 */
|
||||
export function coverBackground(cover: string): string {
|
||||
return isImageUrl(cover) ? `url("${cover}")` : cover;
|
||||
}
|
||||
|
||||
/** 展示用封面:URL 先走本地代理再包 url(),非 URL(如渐变)原样返回 */
|
||||
export function displayCover(cover: string): string {
|
||||
return isImageUrl(cover) ? coverBackground(proxyCover(cover)) : cover;
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
/** 播放量格式化:大于等于 1 万显示为 x.x万 */
|
||||
export function formatPlayCount(count: number): string {
|
||||
return count >= 10000 ? `${(count / 10000).toFixed(1)}万` : String(count);
|
||||
}
|
||||
|
|
@ -34,7 +34,7 @@ instance.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
|||
|
||||
// 响应拦截器:统一处理业务码与错误提示
|
||||
instance.interceptors.response.use(
|
||||
// 拦截器已将 AxiosResponse 转换为 ApiResponse,返回值类型与 axios 定义不一致,此处显式放宽
|
||||
// 拦截器已将 AxiosResponse 解包为业务数据(成功时返回 res.data),返回值类型与 axios 定义不一致,此处显式放宽
|
||||
(response): any => {
|
||||
const res = response.data as ApiResponse;
|
||||
|
||||
|
|
@ -44,7 +44,7 @@ instance.interceptors.response.use(
|
|||
}
|
||||
|
||||
if (res.code === SUCCESS_CODE) {
|
||||
return res;
|
||||
return res.data;
|
||||
}
|
||||
|
||||
if (res.code === UNAUTHORIZED_CODE) {
|
||||
|
|
@ -65,19 +65,20 @@ instance.interceptors.response.use(
|
|||
);
|
||||
|
||||
/**
|
||||
* 通用请求,返回后端统一结构 { code, msg, data }
|
||||
* 通用请求,拦截器已解包后端统一结构 { code, msg, data },此处直接返回业务数据
|
||||
*/
|
||||
export function request<T = unknown>(
|
||||
config: AxiosRequestConfig,
|
||||
): Promise<ApiResponse<T>> {
|
||||
return instance.request<ApiResponse<T>, ApiResponse<T>>(config);
|
||||
): Promise<T> {
|
||||
// AxiosResponseResult 是条件类型,传泛型 T 时无法立即求值,此处显式断言为业务数据
|
||||
return instance.request<ApiResponse<T>, T>(config) as Promise<T>;
|
||||
}
|
||||
|
||||
export function get<T = unknown>(
|
||||
url: string,
|
||||
params?: Record<string, unknown>,
|
||||
config?: AxiosRequestConfig,
|
||||
): Promise<ApiResponse<T>> {
|
||||
): Promise<T> {
|
||||
return request<T>({ url, method: "get", params, ...config });
|
||||
}
|
||||
|
||||
|
|
@ -85,7 +86,7 @@ export function post<T = unknown>(
|
|||
url: string,
|
||||
data?: unknown,
|
||||
config?: AxiosRequestConfig,
|
||||
): Promise<ApiResponse<T>> {
|
||||
): Promise<T> {
|
||||
return request<T>({ url, method: "post", data, ...config });
|
||||
}
|
||||
|
||||
|
|
@ -93,14 +94,14 @@ export function put<T = unknown>(
|
|||
url: string,
|
||||
data?: unknown,
|
||||
config?: AxiosRequestConfig,
|
||||
): Promise<ApiResponse<T>> {
|
||||
): Promise<T> {
|
||||
return request<T>({ url, method: "put", data, ...config });
|
||||
}
|
||||
|
||||
export function del<T = unknown>(
|
||||
url: string,
|
||||
config?: AxiosRequestConfig,
|
||||
): Promise<ApiResponse<T>> {
|
||||
): Promise<T> {
|
||||
return request<T>({ url, method: "delete", ...config });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,54 +1,7 @@
|
|||
<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, recommendedPlaylists } from "@/mocks/music";
|
||||
import { playSong } from "@/stores/player";
|
||||
import { formatTime } from "@/utils/time";
|
||||
|
||||
const message = useMessage();
|
||||
const router = useRouter();
|
||||
|
||||
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}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<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 }"
|
||||
>
|
||||
<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>
|
||||
|
|
@ -57,17 +10,34 @@ function playNewSong(songIndex: number) {
|
|||
</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>
|
||||
</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"
|
||||
/>
|
||||
<MusicCard v-for="playlist in recommendedPlaylists" :key="playlist.id" :playlist="playlist" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
|
@ -130,6 +100,81 @@ function playNewSong(songIndex: number) {
|
|||
</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.getCategoryList([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.getCategoryPlayList(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;
|
||||
|
|
@ -196,6 +241,10 @@ function playNewSong(songIndex: number) {
|
|||
color: var(--app-text-muted);
|
||||
}
|
||||
|
||||
.category-scrollbar {
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(clamp(140px, 14vw, 180px), 1fr));
|
||||
|
|
|
|||
|
|
@ -1,31 +1,3 @@
|
|||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { NButton, NEmpty, NTabPane, NTabs, useMessage } from "naive-ui";
|
||||
import AppIcon from "@/components/AppIcon.vue";
|
||||
import SongTable from "@/components/SongTable.vue";
|
||||
import type { Song } from "@/mocks/music";
|
||||
import { favoriteSongs, localSongs, recentSongs } from "@/mocks/music";
|
||||
import { playSong } from "@/stores/player";
|
||||
|
||||
const message = useMessage();
|
||||
const activeTab = ref("favorite");
|
||||
|
||||
const tabs = [
|
||||
{ key: "favorite", label: "我喜欢", songs: favoriteSongs },
|
||||
{ key: "recent", label: "最近播放", songs: recentSongs },
|
||||
{ key: "local", label: "本地下载", songs: localSongs },
|
||||
];
|
||||
|
||||
function playAll(songs: Song[]) {
|
||||
if (songs.length === 0) {
|
||||
message.warning("这里还没有歌曲");
|
||||
return;
|
||||
}
|
||||
playSong(songs[0], songs);
|
||||
message.success(`正在播放:${songs[0].title}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-head">
|
||||
|
|
@ -60,6 +32,34 @@ function playAll(songs: Song[]) {
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { NButton, NEmpty, NTabPane, NTabs, useMessage } from "naive-ui";
|
||||
import AppIcon from "@/components/AppIcon.vue";
|
||||
import SongTable from "@/components/SongTable.vue";
|
||||
import type { Song } from "@/mocks/music";
|
||||
import { favoriteSongs, localSongs, recentSongs } from "@/mocks/music";
|
||||
import { playSong } from "@/stores/player";
|
||||
|
||||
const message = useMessage();
|
||||
const activeTab = ref("favorite");
|
||||
|
||||
const tabs = [
|
||||
{ key: "favorite", label: "我喜欢", songs: favoriteSongs },
|
||||
{ key: "recent", label: "最近播放", songs: recentSongs },
|
||||
{ key: "local", label: "本地下载", songs: localSongs },
|
||||
];
|
||||
|
||||
function playAll(songs: Song[]) {
|
||||
if (songs.length === 0) {
|
||||
message.warning("这里还没有歌曲");
|
||||
return;
|
||||
}
|
||||
playSong(songs[0], songs);
|
||||
message.success(`正在播放:${songs[0].title}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
max-width: 1100px;
|
||||
|
|
|
|||
|
|
@ -1,30 +1,3 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { NButton, NTag, useMessage } from "naive-ui";
|
||||
import { useRouter } from "vue-router";
|
||||
import AppIcon from "@/components/AppIcon.vue";
|
||||
import SongTable from "@/components/SongTable.vue";
|
||||
import { playlists } from "@/mocks/music";
|
||||
import { playSong } from "@/stores/player";
|
||||
|
||||
const props = defineProps<{ id: string }>();
|
||||
const message = useMessage();
|
||||
const router = useRouter();
|
||||
|
||||
const playlist = computed(
|
||||
() => playlists.find((item) => item.id === props.id) ?? playlists[0],
|
||||
);
|
||||
|
||||
function playAll() {
|
||||
playSong(playlist.value.songs[0], playlist.value.songs);
|
||||
message.success(`正在播放歌单:${playlist.value.name}`);
|
||||
}
|
||||
|
||||
function favorite() {
|
||||
message.success(`已收藏「${playlist.value.name}」`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<button class="back-btn" type="button" aria-label="返回" @click="router.back()">
|
||||
|
|
@ -32,18 +5,19 @@ function favorite() {
|
|||
</button>
|
||||
|
||||
<div class="detail-header">
|
||||
<div class="detail-cover" :style="{ background: playlist.cover }">
|
||||
<div class="detail-cover" :style="{ background: headerCover }">
|
||||
<AppIcon name="music" :size="64" class="cover-mark" />
|
||||
</div>
|
||||
<div class="detail-info">
|
||||
<h1>{{ playlist.name }}</h1>
|
||||
<h1>{{ headerName }}</h1>
|
||||
<p class="creator">
|
||||
<span class="avatar">Y</span>
|
||||
YwYMusic 官方
|
||||
{{ headerCreator }}
|
||||
</p>
|
||||
<div class="tags">
|
||||
<p v-if="headerDesc" class="desc">{{ headerDesc }}</p>
|
||||
<div v-if="tags.length" class="tags">
|
||||
<n-tag
|
||||
v-for="tag in playlist.tags"
|
||||
v-for="tag in tags"
|
||||
:key="tag"
|
||||
size="small"
|
||||
:bordered="false"
|
||||
|
|
@ -52,10 +26,10 @@ function favorite() {
|
|||
</n-tag>
|
||||
</div>
|
||||
<p class="stats">
|
||||
{{ playlist.songs.length }} 首 · {{ playlist.playCount }} 次播放
|
||||
{{ songs.length }} 首 · {{ headerPlayCount }} 次播放
|
||||
</p>
|
||||
<div class="actions">
|
||||
<n-button type="primary" round size="large" @click="playAll">
|
||||
<n-button type="primary" round size="large" :disabled="songs.length === 0" @click="playAll">
|
||||
<AppIcon name="play" :size="16" class="btn-icon" />
|
||||
播放全部
|
||||
</n-button>
|
||||
|
|
@ -68,11 +42,91 @@ function favorite() {
|
|||
</div>
|
||||
|
||||
<div class="song-section">
|
||||
<SongTable :songs="playlist.songs" />
|
||||
<n-spin v-if="loading" class="song-loading" />
|
||||
<SongTable v-else :songs="songs" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { NButton, NSpin, NTag, useMessage } from "naive-ui";
|
||||
import { useRouter } from "vue-router";
|
||||
import AppIcon from "@/components/AppIcon.vue";
|
||||
import SongTable from "@/components/SongTable.vue";
|
||||
import PlayListApi, { getCachedPlayListSummary } from "@/api/playlist";
|
||||
import { playlists, type Song } from "@/mocks/music";
|
||||
import { playSong } from "@/stores/player";
|
||||
import { displayCover } from "@/utils/cover";
|
||||
import { formatPlayCount } from "@/utils/format";
|
||||
import Source from "@/types/global";
|
||||
|
||||
const props = defineProps<{ id: string }>();
|
||||
const message = useMessage();
|
||||
const router = useRouter();
|
||||
|
||||
const songs = ref<Song[]>([]);
|
||||
const loading = ref(true);
|
||||
|
||||
const summary = computed(() => getCachedPlayListSummary(props.id));
|
||||
const mockPlaylist = computed(() =>
|
||||
playlists.find((item) => item.id === props.id),
|
||||
);
|
||||
|
||||
const headerName = computed(
|
||||
() => summary.value?.name ?? mockPlaylist.value?.name ?? "歌单详情",
|
||||
);
|
||||
const headerCreator = computed(
|
||||
() => summary.value?.creator ?? "YwYMusic 官方",
|
||||
);
|
||||
const headerDesc = computed(
|
||||
() => summary.value?.description ?? mockPlaylist.value?.desc ?? "",
|
||||
);
|
||||
const tags = computed(() => mockPlaylist.value?.tags ?? []);
|
||||
const headerPlayCount = computed(() =>
|
||||
summary.value
|
||||
? formatPlayCount(summary.value.play_count)
|
||||
: (mockPlaylist.value?.playCount ?? String(songs.value.length)),
|
||||
);
|
||||
const headerCover = computed(() => {
|
||||
const cover =
|
||||
summary.value?.cover ?? mockPlaylist.value?.cover ?? songs.value[0]?.cover ?? "";
|
||||
return cover
|
||||
? displayCover(cover)
|
||||
: "linear-gradient(135deg, #4338ca 0%, #7c3aed 100%)";
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await PlayListApi.getPlayListDetail(Source.KuGou, props.id);
|
||||
songs.value = res.map((song) => ({
|
||||
id: song.id,
|
||||
title: song.name,
|
||||
artist: song.artist,
|
||||
album: song.album,
|
||||
duration: song.duration,
|
||||
cover: displayCover(song.cover),
|
||||
}));
|
||||
} catch {
|
||||
// 兜底:mock 歌单(如从搜索页进入)继续展示旧数据
|
||||
const mock = playlists.find((item) => item.id === props.id);
|
||||
if (mock) songs.value = mock.songs;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
function playAll() {
|
||||
if (songs.value.length === 0) return;
|
||||
playSong(songs.value[0], songs.value);
|
||||
message.success(`正在播放歌单:${headerName.value}`);
|
||||
}
|
||||
|
||||
function favorite() {
|
||||
message.success(`已收藏「${headerName.value}」`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
max-width: 1100px;
|
||||
|
|
@ -158,6 +212,13 @@ function favorite() {
|
|||
font-weight: 700;
|
||||
}
|
||||
|
||||
.desc {
|
||||
margin: 0 0 12px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--app-text-secondary);
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
|
@ -184,6 +245,12 @@ function favorite() {
|
|||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.song-loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 48px 0;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.detail-header {
|
||||
flex-direction: column;
|
||||
|
|
|
|||
|
|
@ -1,21 +1,3 @@
|
|||
<script setup lang="ts">
|
||||
import { useMessage } from "naive-ui";
|
||||
import { useRouter } from "vue-router";
|
||||
import AppIcon from "@/components/AppIcon.vue";
|
||||
import { rankLists } from "@/mocks/music";
|
||||
import { playSong } from "@/stores/player";
|
||||
import { formatTime } from "@/utils/time";
|
||||
|
||||
const message = useMessage();
|
||||
const router = useRouter();
|
||||
|
||||
function playRank(rankIndex: number) {
|
||||
const rank = rankLists[rankIndex];
|
||||
playSong(rank.songs[0], rank.songs);
|
||||
message.success(`正在播放:${rank.songs[0].title}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-head">
|
||||
|
|
@ -68,6 +50,24 @@ function playRank(rankIndex: number) {
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useMessage } from "naive-ui";
|
||||
import { useRouter } from "vue-router";
|
||||
import AppIcon from "@/components/AppIcon.vue";
|
||||
import { rankLists } from "@/mocks/music";
|
||||
import { playSong } from "@/stores/player";
|
||||
import { formatTime } from "@/utils/time";
|
||||
|
||||
const message = useMessage();
|
||||
const router = useRouter();
|
||||
|
||||
function playRank(rankIndex: number) {
|
||||
const rank = rankLists[rankIndex];
|
||||
playSong(rank.songs[0], rank.songs);
|
||||
message.success(`正在播放:${rank.songs[0].title}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
max-width: 1200px;
|
||||
|
|
|
|||
|
|
@ -1,42 +1,3 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { NEmpty, NInput, NTabPane, NTabs, NTag, useMessage } from "naive-ui";
|
||||
import AppIcon from "@/components/AppIcon.vue";
|
||||
import MusicCard from "@/components/MusicCard.vue";
|
||||
import SongTable from "@/components/SongTable.vue";
|
||||
import { allSongs, hotKeywords, playlists } from "@/mocks/music";
|
||||
|
||||
const message = useMessage();
|
||||
const keyword = ref("");
|
||||
const activeTab = ref("song");
|
||||
|
||||
const normalized = computed(() => keyword.value.trim().toLowerCase());
|
||||
|
||||
const songResults = computed(() => {
|
||||
const q = normalized.value;
|
||||
if (!q) return [];
|
||||
return allSongs.filter(
|
||||
(song) =>
|
||||
song.title.toLowerCase().includes(q) || song.artist.toLowerCase().includes(q),
|
||||
);
|
||||
});
|
||||
|
||||
const playlistResults = computed(() => {
|
||||
const q = normalized.value;
|
||||
if (!q) return [];
|
||||
return playlists.filter(
|
||||
(playlist) =>
|
||||
playlist.name.toLowerCase().includes(q) ||
|
||||
playlist.tags.some((tag) => tag.toLowerCase().includes(q)),
|
||||
);
|
||||
});
|
||||
|
||||
function useHot(hot: string) {
|
||||
keyword.value = hot;
|
||||
message.success(`搜索「${hot}」`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="search-hero">
|
||||
|
|
@ -97,6 +58,45 @@ function useHot(hot: string) {
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { NEmpty, NInput, NTabPane, NTabs, NTag, useMessage } from "naive-ui";
|
||||
import AppIcon from "@/components/AppIcon.vue";
|
||||
import MusicCard from "@/components/MusicCard.vue";
|
||||
import SongTable from "@/components/SongTable.vue";
|
||||
import { allSongs, hotKeywords, playlists } from "@/mocks/music";
|
||||
|
||||
const message = useMessage();
|
||||
const keyword = ref("");
|
||||
const activeTab = ref("song");
|
||||
|
||||
const normalized = computed(() => keyword.value.trim().toLowerCase());
|
||||
|
||||
const songResults = computed(() => {
|
||||
const q = normalized.value;
|
||||
if (!q) return [];
|
||||
return allSongs.filter(
|
||||
(song) =>
|
||||
song.title.toLowerCase().includes(q) || song.artist.toLowerCase().includes(q),
|
||||
);
|
||||
});
|
||||
|
||||
const playlistResults = computed(() => {
|
||||
const q = normalized.value;
|
||||
if (!q) return [];
|
||||
return playlists.filter(
|
||||
(playlist) =>
|
||||
playlist.name.toLowerCase().includes(q) ||
|
||||
playlist.tags.some((tag) => tag.toLowerCase().includes(q)),
|
||||
);
|
||||
});
|
||||
|
||||
function useHot(hot: string) {
|
||||
keyword.value = hot;
|
||||
message.success(`搜索「${hot}」`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
max-width: 1100px;
|
||||
|
|
|
|||
Loading…
Reference in New Issue