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">
|
<script setup lang="ts">
|
||||||
import {
|
import {
|
||||||
darkTheme,
|
darkTheme,
|
||||||
|
|
@ -39,20 +53,6 @@ const themeOverrides: GlobalThemeOverrides = {
|
||||||
};
|
};
|
||||||
</script>
|
</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>
|
<style scoped>
|
||||||
.app-root {
|
.app-root {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,119 @@
|
||||||
import instance from "@/utils/request"
|
import { get } from "@/utils/request";
|
||||||
import Source from "@/types/global"
|
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 = {
|
const PlayListApi = {
|
||||||
// 获取歌单分类
|
// 获取歌单分类
|
||||||
getCategoryList: (sources?: Source[]) => {
|
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">
|
<script setup lang="ts">
|
||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
|
|
||||||
|
|
@ -71,21 +89,3 @@ const paths: Record<string, string[]> = {
|
||||||
|
|
||||||
const isFilled = computed(() => filledNames.has(props.name));
|
const isFilled = computed(() => filledNames.has(props.name));
|
||||||
</script>
|
</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>
|
<template>
|
||||||
<Transition name="fullscreen">
|
<Transition name="fullscreen">
|
||||||
<div v-if="show" class="fullscreen-player">
|
<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" />
|
<div class="fullscreen-overlay" />
|
||||||
|
|
||||||
<button class="close-btn" type="button" aria-label="关闭全屏" @click="emit('close')">
|
<button class="close-btn" type="button" aria-label="关闭全屏" @click="emit('close')">
|
||||||
|
|
@ -87,7 +14,7 @@ watch(currentLyricIndex, (newIndex) => {
|
||||||
<div
|
<div
|
||||||
class="cover-rotating"
|
class="cover-rotating"
|
||||||
:class="{ spinning: playerState.isPlaying }"
|
:class="{ spinning: playerState.isPlaying }"
|
||||||
:style="current ? { background: current.cover } : undefined"
|
:style="current ? { background: coverBackground(current.cover) } : undefined"
|
||||||
>
|
>
|
||||||
<div class="cover-center" />
|
<div class="cover-center" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -166,6 +93,80 @@ watch(currentLyricIndex, (newIndex) => {
|
||||||
</Transition>
|
</Transition>
|
||||||
</template>
|
</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>
|
<style scoped>
|
||||||
.fullscreen-player {
|
.fullscreen-player {
|
||||||
position: fixed;
|
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>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="music-card"
|
class="music-card"
|
||||||
|
|
@ -30,7 +7,7 @@ function play() {
|
||||||
@click="openDetail"
|
@click="openDetail"
|
||||||
@keydown.enter="openDetail"
|
@keydown.enter="openDetail"
|
||||||
>
|
>
|
||||||
<div class="cover" :style="{ background: playlist.cover }">
|
<div class="cover" :style="coverStyle">
|
||||||
<AppIcon name="music" :size="36" class="cover-mark" />
|
<AppIcon name="music" :size="36" class="cover-mark" />
|
||||||
<button class="play-btn" type="button" aria-label="播放歌单" @click.stop="play">
|
<button class="play-btn" type="button" aria-label="播放歌单" @click.stop="play">
|
||||||
<AppIcon name="play" :size="17" />
|
<AppIcon name="play" :size="17" />
|
||||||
|
|
@ -45,6 +22,36 @@ function play() {
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
<style scoped>
|
||||||
.music-card {
|
.music-card {
|
||||||
cursor: pointer;
|
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>
|
<template>
|
||||||
<footer class="player-bar">
|
<footer class="player-bar">
|
||||||
<div class="player-left">
|
<div class="player-left">
|
||||||
<div
|
<div
|
||||||
class="cover-mini"
|
class="cover-mini"
|
||||||
:style="current ? { background: current.cover } : undefined"
|
:style="current ? { background: coverBackground(current.cover) } : undefined"
|
||||||
@click="openFullscreen"
|
@click="openFullscreen"
|
||||||
>
|
>
|
||||||
<AppIcon name="music" :size="20" class="cover-mini-mark" />
|
<AppIcon name="music" :size="20" class="cover-mini-mark" />
|
||||||
|
|
@ -247,6 +129,125 @@ onUnmounted(() => {
|
||||||
</footer>
|
</footer>
|
||||||
</template>
|
</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>
|
<style scoped>
|
||||||
.player-bar {
|
.player-bar {
|
||||||
display: flex;
|
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>
|
<template>
|
||||||
<div class="song-table">
|
<div class="song-table">
|
||||||
<div class="table-head">
|
<div class="table-head">
|
||||||
|
|
@ -77,6 +48,35 @@ function play(song: Song) {
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
<style scoped>
|
||||||
.song-table {
|
.song-table {
|
||||||
border: 1px solid var(--app-border);
|
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>
|
<template>
|
||||||
<div class="app-shell">
|
<div class="app-shell">
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
|
|
@ -77,6 +47,36 @@ watch(
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
<style scoped>
|
||||||
.app-shell {
|
.app-shell {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
export interface Song {
|
export interface Song {
|
||||||
id: number;
|
id: string | number;
|
||||||
title: string;
|
title: string;
|
||||||
artist: string;
|
artist: string;
|
||||||
album: string;
|
album: string;
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,9 @@ declare module 'vue' {
|
||||||
AppIcon: typeof import('./../components/AppIcon.vue')['default']
|
AppIcon: typeof import('./../components/AppIcon.vue')['default']
|
||||||
FullscreenPlayer: typeof import('./../components/FullscreenPlayer.vue')['default']
|
FullscreenPlayer: typeof import('./../components/FullscreenPlayer.vue')['default']
|
||||||
MusicCard: typeof import('./../components/MusicCard.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']
|
PlayerBar: typeof import('./../components/PlayerBar.vue')['default']
|
||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
RouterView: typeof import('vue-router')['RouterView']
|
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(
|
instance.interceptors.response.use(
|
||||||
// 拦截器已将 AxiosResponse 转换为 ApiResponse,返回值类型与 axios 定义不一致,此处显式放宽
|
// 拦截器已将 AxiosResponse 解包为业务数据(成功时返回 res.data),返回值类型与 axios 定义不一致,此处显式放宽
|
||||||
(response): any => {
|
(response): any => {
|
||||||
const res = response.data as ApiResponse;
|
const res = response.data as ApiResponse;
|
||||||
|
|
||||||
|
|
@ -44,7 +44,7 @@ instance.interceptors.response.use(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (res.code === SUCCESS_CODE) {
|
if (res.code === SUCCESS_CODE) {
|
||||||
return res;
|
return res.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (res.code === UNAUTHORIZED_CODE) {
|
if (res.code === UNAUTHORIZED_CODE) {
|
||||||
|
|
@ -65,19 +65,20 @@ instance.interceptors.response.use(
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 通用请求,返回后端统一结构 { code, msg, data }
|
* 通用请求,拦截器已解包后端统一结构 { code, msg, data },此处直接返回业务数据
|
||||||
*/
|
*/
|
||||||
export function request<T = unknown>(
|
export function request<T = unknown>(
|
||||||
config: AxiosRequestConfig,
|
config: AxiosRequestConfig,
|
||||||
): Promise<ApiResponse<T>> {
|
): Promise<T> {
|
||||||
return instance.request<ApiResponse<T>, ApiResponse<T>>(config);
|
// AxiosResponseResult 是条件类型,传泛型 T 时无法立即求值,此处显式断言为业务数据
|
||||||
|
return instance.request<ApiResponse<T>, T>(config) as Promise<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function get<T = unknown>(
|
export function get<T = unknown>(
|
||||||
url: string,
|
url: string,
|
||||||
params?: Record<string, unknown>,
|
params?: Record<string, unknown>,
|
||||||
config?: AxiosRequestConfig,
|
config?: AxiosRequestConfig,
|
||||||
): Promise<ApiResponse<T>> {
|
): Promise<T> {
|
||||||
return request<T>({ url, method: "get", params, ...config });
|
return request<T>({ url, method: "get", params, ...config });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -85,7 +86,7 @@ export function post<T = unknown>(
|
||||||
url: string,
|
url: string,
|
||||||
data?: unknown,
|
data?: unknown,
|
||||||
config?: AxiosRequestConfig,
|
config?: AxiosRequestConfig,
|
||||||
): Promise<ApiResponse<T>> {
|
): Promise<T> {
|
||||||
return request<T>({ url, method: "post", data, ...config });
|
return request<T>({ url, method: "post", data, ...config });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -93,14 +94,14 @@ export function put<T = unknown>(
|
||||||
url: string,
|
url: string,
|
||||||
data?: unknown,
|
data?: unknown,
|
||||||
config?: AxiosRequestConfig,
|
config?: AxiosRequestConfig,
|
||||||
): Promise<ApiResponse<T>> {
|
): Promise<T> {
|
||||||
return request<T>({ url, method: "put", data, ...config });
|
return request<T>({ url, method: "put", data, ...config });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function del<T = unknown>(
|
export function del<T = unknown>(
|
||||||
url: string,
|
url: string,
|
||||||
config?: AxiosRequestConfig,
|
config?: AxiosRequestConfig,
|
||||||
): Promise<ApiResponse<T>> {
|
): Promise<T> {
|
||||||
return request<T>({ url, method: "delete", ...config });
|
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>
|
<template>
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<n-carousel
|
<n-carousel autoplay draggable :interval="5000" class="banner-carousel">
|
||||||
autoplay
|
<div v-for="banner in banners" :key="banner.title" class="banner" :style="{ background: banner.gradient }">
|
||||||
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">
|
<div class="banner-text">
|
||||||
<h2>{{ banner.title }}</h2>
|
<h2>{{ banner.title }}</h2>
|
||||||
<p>{{ banner.sub }}</p>
|
<p>{{ banner.sub }}</p>
|
||||||
|
|
@ -57,17 +10,34 @@ function playNewSong(songIndex: number) {
|
||||||
</div>
|
</div>
|
||||||
</n-carousel>
|
</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">
|
<section class="section">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<h3>推荐歌单</h3>
|
<h3>推荐歌单</h3>
|
||||||
<span class="section-sub">为你精选</span>
|
<span class="section-sub">为你精选</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-grid">
|
<div class="card-grid">
|
||||||
<MusicCard
|
<MusicCard v-for="playlist in recommendedPlaylists" :key="playlist.id" :playlist="playlist" />
|
||||||
v-for="playlist in recommendedPlaylists"
|
|
||||||
:key="playlist.id"
|
|
||||||
:playlist="playlist"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
@ -130,6 +100,81 @@ function playNewSong(songIndex: number) {
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
<style scoped>
|
||||||
.page {
|
.page {
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
|
|
@ -196,6 +241,10 @@ function playNewSong(songIndex: number) {
|
||||||
color: var(--app-text-muted);
|
color: var(--app-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.category-scrollbar {
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.card-grid {
|
.card-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(clamp(140px, 14vw, 180px), 1fr));
|
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>
|
<template>
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
|
|
@ -60,6 +32,34 @@ function playAll(songs: Song[]) {
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
<style scoped>
|
||||||
.page {
|
.page {
|
||||||
max-width: 1100px;
|
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>
|
<template>
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<button class="back-btn" type="button" aria-label="返回" @click="router.back()">
|
<button class="back-btn" type="button" aria-label="返回" @click="router.back()">
|
||||||
|
|
@ -32,18 +5,19 @@ function favorite() {
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="detail-header">
|
<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" />
|
<AppIcon name="music" :size="64" class="cover-mark" />
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-info">
|
<div class="detail-info">
|
||||||
<h1>{{ playlist.name }}</h1>
|
<h1>{{ headerName }}</h1>
|
||||||
<p class="creator">
|
<p class="creator">
|
||||||
<span class="avatar">Y</span>
|
<span class="avatar">Y</span>
|
||||||
YwYMusic 官方
|
{{ headerCreator }}
|
||||||
</p>
|
</p>
|
||||||
<div class="tags">
|
<p v-if="headerDesc" class="desc">{{ headerDesc }}</p>
|
||||||
|
<div v-if="tags.length" class="tags">
|
||||||
<n-tag
|
<n-tag
|
||||||
v-for="tag in playlist.tags"
|
v-for="tag in tags"
|
||||||
:key="tag"
|
:key="tag"
|
||||||
size="small"
|
size="small"
|
||||||
:bordered="false"
|
:bordered="false"
|
||||||
|
|
@ -52,10 +26,10 @@ function favorite() {
|
||||||
</n-tag>
|
</n-tag>
|
||||||
</div>
|
</div>
|
||||||
<p class="stats">
|
<p class="stats">
|
||||||
{{ playlist.songs.length }} 首 · {{ playlist.playCount }} 次播放
|
{{ songs.length }} 首 · {{ headerPlayCount }} 次播放
|
||||||
</p>
|
</p>
|
||||||
<div class="actions">
|
<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" />
|
<AppIcon name="play" :size="16" class="btn-icon" />
|
||||||
播放全部
|
播放全部
|
||||||
</n-button>
|
</n-button>
|
||||||
|
|
@ -68,11 +42,91 @@ function favorite() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="song-section">
|
<div class="song-section">
|
||||||
<SongTable :songs="playlist.songs" />
|
<n-spin v-if="loading" class="song-loading" />
|
||||||
|
<SongTable v-else :songs="songs" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
<style scoped>
|
||||||
.page {
|
.page {
|
||||||
max-width: 1100px;
|
max-width: 1100px;
|
||||||
|
|
@ -158,6 +212,13 @@ function favorite() {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.desc {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--app-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
.tags {
|
.tags {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|
@ -184,6 +245,12 @@ function favorite() {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.song-loading {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 48px 0;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.detail-header {
|
.detail-header {
|
||||||
flex-direction: column;
|
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>
|
<template>
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
|
|
@ -68,6 +50,24 @@ function playRank(rankIndex: number) {
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
<style scoped>
|
||||||
.page {
|
.page {
|
||||||
max-width: 1200px;
|
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>
|
<template>
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<div class="search-hero">
|
<div class="search-hero">
|
||||||
|
|
@ -97,6 +58,45 @@ function useHot(hot: string) {
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
<style scoped>
|
||||||
.page {
|
.page {
|
||||||
max-width: 1100px;
|
max-width: 1100px;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue