feat(api): 新增专辑和音乐API模块
新增AlbumApi模块,包含获取专辑详情功能 新增MusicApi模块,包含封面代理、音频探测、歌词获取、搜索、流媒体代理、音源切换等功能 完善PlayListApi模块的JSDoc注释和方法重命名 feat(player): 实现基于HTML5 Audio的播放器功能 使用HTML5 Audio元素替换原有的定时器机制 实现播放/暂停、进度控制、音量同步、时间更新等功能 添加播放结束自动下一首逻辑 优化播放器状态管理 refactor(song-table): 优化组件事件通信机制 移除props中的songs传递 改用emit事件方式通知父组件播放操作 提升组件复用性和解耦效果 feat(discover-view): 重构发现页面功能结构 整合歌单分类和推荐歌单布局 优化分类加载和展示逻辑 调整UI组件结构和样式 修复推荐歌单显示问题 feat(library-view): 优化个人库播放体验 集成歌曲表格的播放事件处理 实现点击播放和队列管理功能 feat(playlist-detail): 实现歌单详情播放功能 集成音乐API获取真实播放链接 实现播放链接获取失败的降级处理 优化歌单详情数据加载逻辑 添加播放全部功能 feat(search-view): 完善搜索结果播放功能 集成歌曲搜索结果的播放处理 实现搜索歌曲的队列播放功能
This commit is contained in:
parent
98083829cb
commit
32ca1b4f74
|
|
@ -0,0 +1,17 @@
|
|||
import { get } from "@/utils/request"
|
||||
import Source from "@/types/global"
|
||||
|
||||
const AlbumApi = {
|
||||
/**
|
||||
* 获取专辑详情
|
||||
* @description 传入源平台的专辑 ID,返回专辑内歌曲列表。
|
||||
* @param id 专辑 ID
|
||||
* @param source 音乐来源平台
|
||||
* @returns
|
||||
*/
|
||||
detail: (id: string, source: Source) => {
|
||||
return get<any>(`/api/v1/album/detail?id=${id}&source=${source}`)
|
||||
},
|
||||
}
|
||||
|
||||
export default AlbumApi
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
import { get } from "@/utils/request"
|
||||
import Source from "@/types/global"
|
||||
|
||||
/** 音乐直链 */
|
||||
export interface MusicUrl {
|
||||
url: string
|
||||
}
|
||||
|
||||
const MusicApi = {
|
||||
/**
|
||||
* 代理请求并下载封面图
|
||||
* @description 发送带伪造标头的请求拉取远端封面大图,避开网易云、QQ 音乐的图片防盗链 403 问题。
|
||||
* @param url 封面图原始 URL (需经过 urlencode)
|
||||
* @param name 歌曲名(用于生成下载文件名)
|
||||
* @param artist 歌手名(用于生成下载文件名)
|
||||
* @returns
|
||||
*/
|
||||
cover: (url: string, name?: string, artist?: string) => {
|
||||
return get<any>(`/api/v1/music/cover?url=${url}&name=${name}&artist=${artist}`)
|
||||
},
|
||||
/**
|
||||
* 探测音频大小与码率
|
||||
* @description 快速探测音频直链的可访问性,并根据 Content-Range 推算文件大小及大概码率。
|
||||
* @param id 音乐 ID
|
||||
* @param source 音乐来源平台
|
||||
* @param duration 音乐时长(秒),提供可精确预估码率(kbps)
|
||||
*/
|
||||
inspect: (id: string, source: Source, duration?: string) => {
|
||||
return get<any>(`/api/v1/music/inspect?id=${id}&source=${source}&duration=${duration}`)
|
||||
},
|
||||
/**
|
||||
* 获取 JSON 格式歌词
|
||||
* @description 抓取对应歌曲的完整 LRC 歌词文本,以 JSON 格式返回。
|
||||
* @param id 音乐 ID
|
||||
* @param source 音乐来源平台
|
||||
* @returns
|
||||
*/
|
||||
lyric: (id: string, source: Source) => {
|
||||
return get<any>(`/api/v1/music/lyric?id=${id}&source=${source}`)
|
||||
},
|
||||
/**
|
||||
* 下载 LRC 歌词文件
|
||||
* @description 作为附件直接下载 .lrc 后缀的歌词文件到本地。
|
||||
* @param id 音乐 ID
|
||||
* @param source 音乐来源平台
|
||||
* @param name 音乐名称 (生成保存文件名)
|
||||
* @param artist 歌手名称 (生成保存文件名)
|
||||
* @returns
|
||||
*/
|
||||
lyricFile: (id: string, source: Source, name?: string, artist?: string) => {
|
||||
return get<any>(`/api/v1/music/lyric/file?id=${id}&source=${source}&name=${name}&artist=${artist}`)
|
||||
},
|
||||
/**
|
||||
* 综合搜索与链接解析
|
||||
* @description 兼容多源并发搜索以及链接智能解析,自动返回单曲、歌单或专辑数组。支持直接输入关键词或粘贴音乐平台的分享链接。
|
||||
* @param keyword 关键词
|
||||
* @param type 搜索类型: song (单曲)、playlist (歌单) 或 album (专辑)
|
||||
* @param sources 来源列表
|
||||
* @returns
|
||||
*/
|
||||
search: (keyword: string, type: string, sources?: Source[]) => {
|
||||
return get<any>(`/api/v1/music/search?q=${keyword}&type=${type}&sources=${sources}`)
|
||||
},
|
||||
/**
|
||||
* 串流代理与下载音频
|
||||
* @description 包含完整的各平台流代理逻辑(解决跨域防盗链),并特殊支持 Soda(汽水音乐) 加密流数据的后端解密。
|
||||
* @param id 音乐 ID
|
||||
* @param source 音乐来源平台
|
||||
* @param name 音乐名称 (生成保存文件名)
|
||||
* @param artist 歌手名称 (生成保存文件名)
|
||||
* @returns
|
||||
*/
|
||||
stream: (id: string, source: Source, name?: string, artist?: string) => {
|
||||
return get<any>(`/api/v1/music/stream?id=${id}&source=${source}&name=${name}&artist=${artist}`)
|
||||
},
|
||||
/**
|
||||
* 智能切换可用的平替音源
|
||||
* @description 当某一平台的歌曲灰掉(无版权)时,智能寻源切换到其他存在该歌曲的可用平台。
|
||||
* @param name 歌曲名称
|
||||
* @param source 当前损坏的音源(将跳过此源搜索)
|
||||
* @param artist 歌手名称
|
||||
* @param target 指定目标尝试的音源,为空则遍历主流平台搜索
|
||||
* @param duration 原音频时长(秒),提供此时长可极大提高匹配准确度
|
||||
* @returns
|
||||
*/
|
||||
switch: (name: string, source: Source, artist?: string, target?: string, duration?: string) => {
|
||||
return get<any>(`/api/v1/music/switch?name=${name}&source=${source}&artist=${artist}&target=${target}&duration=${duration}`)
|
||||
},
|
||||
/**
|
||||
* 获取音频裸直链
|
||||
* @description 获取解析到的原始音频播放链接。注:部分平台需要客户端带上特定的防盗链 header。
|
||||
* @param id 音乐 ID
|
||||
* @param source 音乐来源平台
|
||||
* @returns
|
||||
*/
|
||||
url: (id: string, source: Source) => {
|
||||
return get<MusicUrl>(`/api/v1/music/url?id=${id}&source=${source}`)
|
||||
},
|
||||
}
|
||||
|
||||
export default MusicApi
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { get } from "@/utils/request";
|
||||
import Source from "@/types/global";
|
||||
import { get } from "@/utils/request"
|
||||
import Source from "@/types/global"
|
||||
|
||||
/** 歌单分类 */
|
||||
interface PlayListCategory {
|
||||
id: string
|
||||
name: string
|
||||
|
|
@ -9,6 +10,7 @@ interface PlayListCategory {
|
|||
count: number
|
||||
}
|
||||
|
||||
/** 分类歌单接口返回的数据结构 */
|
||||
interface PlayListCategoryRes {
|
||||
source: string
|
||||
name: string
|
||||
|
|
@ -81,39 +83,57 @@ interface PlayListSong {
|
|||
}
|
||||
|
||||
/** 歌单摘要缓存:详情页通过它读取歌单标题、封面等元信息 */
|
||||
const playListSummaryCache = new Map<string, PlayListSummary>();
|
||||
const playListSummaryCache = new Map<string, PlayListSummary>()
|
||||
|
||||
export function cachePlayListSummary(summary: PlayListSummary) {
|
||||
playListSummaryCache.set(summary.id, summary);
|
||||
playListSummaryCache.set(summary.id, summary)
|
||||
}
|
||||
|
||||
export function getCachedPlayListSummary(id: string): PlayListSummary | undefined {
|
||||
return playListSummaryCache.get(id);
|
||||
return playListSummaryCache.get(id)
|
||||
}
|
||||
|
||||
const PlayListApi = {
|
||||
// 获取歌单分类
|
||||
getCategoryList: (sources?: Source[]) => {
|
||||
return get<PlayListCategoryRes[]>(`/api/v1/playlist/categories?sources=${sources}`);
|
||||
/**
|
||||
* 获取歌单分类
|
||||
* @param sources 音乐来源平台列表
|
||||
* @returns
|
||||
*/
|
||||
categoryList: (sources?: Source[]) => {
|
||||
return get<PlayListCategoryRes[]>(`/api/v1/playlist/categories?sources=${sources}`)
|
||||
},
|
||||
// 获取分类歌单
|
||||
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}`,
|
||||
);
|
||||
/**
|
||||
* 获取分类歌单
|
||||
* @description 按平台和分类 ID 分页获取歌单。
|
||||
* @param categoryId 分类 ID
|
||||
* @param source 音乐来源平台
|
||||
* @param page 页码
|
||||
* @param limit 每页数量
|
||||
* @returns
|
||||
*/
|
||||
categoryPlayList: (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}`,
|
||||
);
|
||||
/**
|
||||
* 获取歌单详情
|
||||
* @param source 歌单所属平台
|
||||
* @param id 歌单的内部 ID
|
||||
* @returns
|
||||
*/
|
||||
detail: (source: Source, id: string) => {
|
||||
return get<PlayListSong[]>(`/api/v1/playlist/detail?source=${source}&id=${id}`)
|
||||
},
|
||||
};
|
||||
|
||||
export default PlayListApi;
|
||||
/**
|
||||
* 获取每日推荐热门歌单
|
||||
* @description 异步并发调用所勾选平台的接口,聚合返回他们各自首页推荐的当红歌单数据。
|
||||
* @param sources 要获取的推荐平台列表 (留空则使用默认配置)
|
||||
* @returns
|
||||
*/
|
||||
recommend: (sources?: Source[]) => {
|
||||
return get<PlayListSummary[]>(`/api/v1/playlist/recommend?sources=${sources}`)
|
||||
},
|
||||
}
|
||||
|
||||
export default PlayListApi
|
||||
export type { PlayListCategory, PlayListCategoryRes, PlayListSummary, CategoryPlayListRes, PlayListSong }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,49 @@
|
|||
import { get, post } from "@/utils/request"
|
||||
import Source from "@/types/global"
|
||||
|
||||
const SystemApi = {
|
||||
/**
|
||||
* 获取当前系统加载的 Cookies
|
||||
* @description 读取并在 JSON 格式下返回当前系统已配置的各平台 Cookies。
|
||||
* @returns
|
||||
*/
|
||||
cookies: () => {
|
||||
return get<any>(`/api/v1/system/cookies`)
|
||||
},
|
||||
/**
|
||||
* 设置系统 Cookies
|
||||
* @description 接收 JSON 格式的平台 cookie 键值对,覆盖并保存到系统,实时生效。
|
||||
* @returns
|
||||
*/
|
||||
setCookies: (data: Record<string, string>) => {
|
||||
return post<any>(`/api/v1/system/cookies`, data)
|
||||
},
|
||||
/**
|
||||
* 获取支持扫码登录的平台
|
||||
* @description 返回当前 API 支持创建二维码登录会话的平台列表。
|
||||
* @returns
|
||||
*/
|
||||
qrLoginSource: () => {
|
||||
return get<any>(`/api/v1/system/qr_login/sources`)
|
||||
},
|
||||
/**
|
||||
* 轮询扫码登录状态
|
||||
* @description 使用创建扫码登录会话返回的 key 轮询登录状态;成功时自动写入 cookies.json。
|
||||
* @param source 扫码登录平台
|
||||
* @returns
|
||||
*/
|
||||
loginStatus: (source: Source) => {
|
||||
return get<any>(`/api/v1/system/qr_login/${source}`)
|
||||
},
|
||||
/**
|
||||
* 创建扫码登录会话
|
||||
* @description 为指定平台创建扫码登录会话,返回二维码 URL、二维码图片地址或平台登录 key。
|
||||
* @param source 扫码登录平台
|
||||
* @returns
|
||||
*/
|
||||
qrLogin: (source: Source) => {
|
||||
return post<any>(`/api/v1/system/qr_login/${source}`)
|
||||
},
|
||||
}
|
||||
|
||||
export default SystemApi
|
||||
|
|
@ -1,5 +1,13 @@
|
|||
<template>
|
||||
<footer class="player-bar">
|
||||
<audio
|
||||
ref="audioRef"
|
||||
class="audio-hidden"
|
||||
preload="auto"
|
||||
@timeupdate="handleTimeUpdate"
|
||||
@ended="handleEnded"
|
||||
/>
|
||||
|
||||
<div class="player-left">
|
||||
<div
|
||||
class="cover-mini"
|
||||
|
|
@ -147,7 +155,7 @@ const liked = ref(new Set<string | number>());
|
|||
const shuffle = ref(false);
|
||||
const repeat = ref(false);
|
||||
|
||||
let timer: number | undefined;
|
||||
const audioRef = ref<HTMLAudioElement | null>(null);
|
||||
|
||||
const current = computed(() => playerState.current);
|
||||
|
||||
|
|
@ -219,31 +227,78 @@ function openFullscreen() {
|
|||
showFullscreen.value = true;
|
||||
}
|
||||
|
||||
function handleTimeUpdate() {
|
||||
if (audioRef.value) {
|
||||
playerState.currentTime = audioRef.value.currentTime;
|
||||
}
|
||||
}
|
||||
|
||||
function handleEnded() {
|
||||
next();
|
||||
}
|
||||
|
||||
// 切换歌曲时加载新的播放直链
|
||||
watch(
|
||||
() => playerState.src,
|
||||
(src) => {
|
||||
const audio = audioRef.value;
|
||||
if (!audio) return;
|
||||
if (src) {
|
||||
audio.src = src;
|
||||
audio.load();
|
||||
if (playerState.isPlaying) {
|
||||
audio.play().catch(() => {});
|
||||
}
|
||||
} else {
|
||||
audio.removeAttribute("src");
|
||||
audio.load();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// 播放/暂停
|
||||
watch(
|
||||
() => playerState.isPlaying,
|
||||
(playing) => {
|
||||
const audio = audioRef.value;
|
||||
if (!audio || !playerState.src) return;
|
||||
if (playing) {
|
||||
timer = window.setInterval(() => {
|
||||
if (!playerState.current) return;
|
||||
playerState.currentTime += 1;
|
||||
if (playerState.currentTime >= playerState.current.duration) {
|
||||
if (repeat.value) {
|
||||
playerState.currentTime = 0;
|
||||
audio.play().catch(() => {});
|
||||
} else {
|
||||
next();
|
||||
audio.pause();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// 音量同步
|
||||
watch(
|
||||
() => playerState.volume,
|
||||
(vol) => {
|
||||
if (audioRef.value) {
|
||||
audioRef.value.volume = vol / 100;
|
||||
}
|
||||
}, 1000);
|
||||
} else if (timer !== undefined) {
|
||||
window.clearInterval(timer);
|
||||
timer = undefined;
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// 进度跳转(拖动进度条时同步到 audio)
|
||||
watch(
|
||||
() => playerState.currentTime,
|
||||
(time) => {
|
||||
const audio = audioRef.value;
|
||||
if (!audio || !Number.isFinite(audio.duration)) return;
|
||||
if (Math.abs(audio.currentTime - time) > 0.8) {
|
||||
audio.currentTime = time;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer !== undefined) {
|
||||
window.clearInterval(timer);
|
||||
const audio = audioRef.value;
|
||||
if (audio) {
|
||||
audio.pause();
|
||||
audio.removeAttribute("src");
|
||||
audio.load();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
|
@ -261,6 +316,10 @@ onUnmounted(() => {
|
|||
border-top: 1px solid var(--app-border);
|
||||
}
|
||||
|
||||
.audio-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.player-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
|
|||
|
|
@ -51,11 +51,12 @@
|
|||
<script setup lang="ts">
|
||||
import { reactive } from "vue";
|
||||
import type { Song } from "@/mocks/music";
|
||||
import { playerState, playSong } from "@/stores/player";
|
||||
import { playerState } from "@/stores/player";
|
||||
import { formatTime } from "@/utils/time";
|
||||
import AppIcon from "./AppIcon.vue";
|
||||
|
||||
const props = defineProps<{ songs: Song[] }>();
|
||||
defineProps<{ songs: Song[] }>();
|
||||
const emit = defineEmits<{ play: [song: Song] }>();
|
||||
|
||||
const liked = reactive(new Set<string | number>());
|
||||
|
||||
|
|
@ -73,7 +74,7 @@ function isCurrent(song: Song) {
|
|||
}
|
||||
|
||||
function play(song: Song) {
|
||||
playSong(song, props.songs);
|
||||
emit("play", song);
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ interface PlayerState {
|
|||
isPlaying: boolean;
|
||||
currentTime: number;
|
||||
volume: number;
|
||||
/** 当前歌曲的播放直链 */
|
||||
src: string;
|
||||
}
|
||||
|
||||
/** 全局播放器状态(轻量版,后续可平滑迁移到 Pinia) */
|
||||
|
|
@ -16,11 +18,13 @@ export const playerState = reactive<PlayerState>({
|
|||
isPlaying: false,
|
||||
currentTime: 0,
|
||||
volume: 70,
|
||||
src: "",
|
||||
});
|
||||
|
||||
export function playSong(song: Song, queue?: Song[]) {
|
||||
export function playSong(song: Song, queue?: Song[], src = "") {
|
||||
playerState.current = song;
|
||||
playerState.currentTime = 0;
|
||||
playerState.src = src;
|
||||
if (queue && queue.length > 0) {
|
||||
playerState.queue = [...queue];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,19 +16,13 @@
|
|||
<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 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">
|
||||
|
|
@ -36,9 +30,9 @@
|
|||
<h3>推荐歌单</h3>
|
||||
<span class="section-sub">为你精选</span>
|
||||
</div>
|
||||
<div class="card-grid">
|
||||
<!-- <div class="card-grid">
|
||||
<MusicCard v-for="playlist in recommendedPlaylists" :key="playlist.id" :playlist="playlist" />
|
||||
</div>
|
||||
</div> -->
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
|
|
@ -101,25 +95,25 @@
|
|||
</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";
|
||||
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 message = useMessage()
|
||||
const router = useRouter()
|
||||
|
||||
const categories = ref<PlayListCategory[]>([]);
|
||||
const categories = ref<PlayListCategory[]>([])
|
||||
const activeCategory = ref<string>("")
|
||||
const recommendedPlaylists = ref<Playlist[]>([]);
|
||||
const recommendedPlaylists = ref<Playlist[]>([])
|
||||
|
||||
const banners = [
|
||||
{
|
||||
|
|
@ -137,27 +131,27 @@ const banners = [
|
|||
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 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;
|
||||
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);
|
||||
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);
|
||||
const res = await PlayListApi.categoryPlayList(categoryId, Source.KuGou, 1, 30)
|
||||
recommendedPlaylists.value = res.playlists.map((p) => {
|
||||
cachePlayListSummary(p);
|
||||
cachePlayListSummary(p)
|
||||
return {
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
|
|
@ -166,13 +160,13 @@ const playList = async (categoryId: string) => {
|
|||
tags: [],
|
||||
playCount: formatPlayCount(p.play_count),
|
||||
songs: [],
|
||||
};
|
||||
});
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
list();
|
||||
});
|
||||
list()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
@ -247,6 +241,7 @@ onMounted(() => {
|
|||
|
||||
.card-grid {
|
||||
display: grid;
|
||||
margin-top: 20px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(clamp(140px, 14vw, 180px), 1fr));
|
||||
gap: 20px 18px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,11 @@
|
|||
播放全部
|
||||
</n-button>
|
||||
</div>
|
||||
<SongTable v-if="tab.songs.length > 0" :songs="tab.songs" />
|
||||
<SongTable
|
||||
v-if="tab.songs.length > 0"
|
||||
:songs="tab.songs"
|
||||
@play="(song) => playSong(song, tab.songs)"
|
||||
/>
|
||||
<n-empty
|
||||
v-else
|
||||
class="empty"
|
||||
|
|
|
|||
|
|
@ -16,18 +16,11 @@
|
|||
</p>
|
||||
<p v-if="headerDesc" class="desc">{{ headerDesc }}</p>
|
||||
<div v-if="tags.length" class="tags">
|
||||
<n-tag
|
||||
v-for="tag in tags"
|
||||
:key="tag"
|
||||
size="small"
|
||||
:bordered="false"
|
||||
>
|
||||
<n-tag v-for="tag in tags" :key="tag" size="small" :bordered="false">
|
||||
{{ tag }}
|
||||
</n-tag>
|
||||
</div>
|
||||
<p class="stats">
|
||||
{{ songs.length }} 首 · {{ headerPlayCount }} 次播放
|
||||
</p>
|
||||
<p class="stats">{{ songs.length }} 首 · {{ headerPlayCount }} 次播放</p>
|
||||
<div class="actions">
|
||||
<n-button type="primary" round size="large" :disabled="songs.length === 0" @click="playAll">
|
||||
<AppIcon name="play" :size="16" class="btn-icon" />
|
||||
|
|
@ -43,62 +36,50 @@
|
|||
|
||||
<div class="song-section">
|
||||
<n-spin v-if="loading" class="song-loading" />
|
||||
<SongTable v-else :songs="songs" />
|
||||
<SongTable v-else :songs="songs" @play="playWithUrl" />
|
||||
</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";
|
||||
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"
|
||||
import MusicApi from "@/api/music"
|
||||
|
||||
const props = defineProps<{ id: string }>();
|
||||
const message = useMessage();
|
||||
const router = useRouter();
|
||||
const props = defineProps<{ id: string }>()
|
||||
const message = useMessage()
|
||||
const router = useRouter()
|
||||
|
||||
const songs = ref<Song[]>([]);
|
||||
const loading = ref(true);
|
||||
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 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 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)),
|
||||
);
|
||||
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%)";
|
||||
});
|
||||
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);
|
||||
const res = await PlayListApi.detail(Source.KuGou, props.id)
|
||||
songs.value = res.map((song) => ({
|
||||
id: song.id,
|
||||
title: song.name,
|
||||
|
|
@ -106,24 +87,35 @@ onMounted(async () => {
|
|||
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;
|
||||
const mock = playlists.find((item) => item.id === props.id)
|
||||
if (mock) songs.value = mock.songs
|
||||
} finally {
|
||||
loading.value = false;
|
||||
loading.value = false
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
function playAll() {
|
||||
if (songs.value.length === 0) return;
|
||||
playSong(songs.value[0], songs.value);
|
||||
message.success(`正在播放歌单:${headerName.value}`);
|
||||
if (songs.value.length === 0) return
|
||||
playWithUrl(songs.value[0], songs.value)
|
||||
message.success(`正在播放歌单:${headerName.value}`)
|
||||
}
|
||||
|
||||
async function playWithUrl(song: Song, queue?: Song[]) {
|
||||
try {
|
||||
const { url } = await MusicApi.url(String(song.id), Source.KuGou)
|
||||
playSong(song, queue, url)
|
||||
} catch {
|
||||
// 拿不到直链时仍切歌,后续可尝试 MusicApi.stream 代理播放
|
||||
playSong(song, queue)
|
||||
}
|
||||
message.success(`正在播放:${song.title}`)
|
||||
}
|
||||
|
||||
function favorite() {
|
||||
message.success(`已收藏「${headerName.value}」`);
|
||||
message.success(`已收藏「${headerName.value}」`)
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -37,7 +37,11 @@
|
|||
<n-tabs v-model:value="activeTab" type="line" animated>
|
||||
<n-tab-pane name="song" :tab="`单曲 ${songResults.length}`">
|
||||
<div class="result-body">
|
||||
<SongTable v-if="songResults.length > 0" :songs="songResults" />
|
||||
<SongTable
|
||||
v-if="songResults.length > 0"
|
||||
:songs="songResults"
|
||||
@play="(song) => playSong(song, songResults)"
|
||||
/>
|
||||
<n-empty v-else class="empty" description="没有找到相关单曲" />
|
||||
</div>
|
||||
</n-tab-pane>
|
||||
|
|
@ -65,6 +69,7 @@ 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";
|
||||
import { playSong } from "@/stores/player";
|
||||
|
||||
const message = useMessage();
|
||||
const keyword = ref("");
|
||||
|
|
|
|||
Loading…
Reference in New Issue