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:
sparksfly 2026-08-11 23:04:58 +08:00
parent 67cce05b34
commit 98083829cb
18 changed files with 758 additions and 499 deletions

View File

@ -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%;

View File

@ -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 }

View File

@ -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>

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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);

View File

@ -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;

View File

@ -1,5 +1,5 @@
export interface Song {
id: number;
id: string | number;
title: string;
artist: string;
album: string;

View File

@ -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']

20
src/utils/cover.ts Normal file
View File

@ -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;
}

4
src/utils/format.ts Normal file
View File

@ -0,0 +1,4 @@
/** 播放量格式化:大于等于 1 万显示为 x.x万 */
export function formatPlayCount(count: number): string {
return count >= 10000 ? `${(count / 10000).toFixed(1)}` : String(count);
}

View File

@ -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 });
}

View File

@ -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));

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;