feat: 添加音乐搜索功能和设置页面
- 新增MusicSearchSong、MusicSearchPlaylist、MusicSearchAlbum和 MusicSearchResult接口定义 - 实现音乐搜索API,支持多源搜索和结果类型过滤 - 添加设置页面,支持配置启用的音乐源和默认音乐源 - 在搜索页面添加防抖搜索和加载状态 - 更新歌单详情页使用动态音乐源获取播放链接 - 添加settings图标到AppIcon组件 - 在主布局中添加设置导航项 - 创建settings store用于管理音乐源设置 - 添加对Song接口的source字段支持 - 更新路由配置添加设置页面路由
This commit is contained in:
parent
32ca1b4f74
commit
2db45f95c0
|
|
@ -6,6 +6,57 @@ export interface MusicUrl {
|
||||||
url: string
|
url: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 搜索结果-单曲 */
|
||||||
|
export interface MusicSearchSong {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 搜索结果-歌单 */
|
||||||
|
export interface MusicSearchPlaylist {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
cover: string
|
||||||
|
track_count: number
|
||||||
|
play_count: number
|
||||||
|
creator: string
|
||||||
|
description: string
|
||||||
|
source: string
|
||||||
|
link: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 搜索结果-专辑 */
|
||||||
|
export interface MusicSearchAlbum {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
cover: string
|
||||||
|
track_count: number
|
||||||
|
play_count: number
|
||||||
|
creator: string
|
||||||
|
description: string
|
||||||
|
source: string
|
||||||
|
link: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 综合搜索结果 */
|
||||||
|
export interface MusicSearchResult {
|
||||||
|
songs: MusicSearchSong[] | null
|
||||||
|
playlists: MusicSearchPlaylist[] | null
|
||||||
|
albums: MusicSearchAlbum[] | null
|
||||||
|
type: "song" | "playlist" | "album"
|
||||||
|
}
|
||||||
|
|
||||||
const MusicApi = {
|
const MusicApi = {
|
||||||
/**
|
/**
|
||||||
* 代理请求并下载封面图
|
* 代理请求并下载封面图
|
||||||
|
|
@ -59,7 +110,8 @@ const MusicApi = {
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
search: (keyword: string, type: string, sources?: Source[]) => {
|
search: (keyword: string, type: string, sources?: Source[]) => {
|
||||||
return get<any>(`/api/v1/music/search?q=${keyword}&type=${type}&sources=${sources}`)
|
const sourceParams = (sources ?? []).map((s) => `&sources=${encodeURIComponent(s)}`).join("")
|
||||||
|
return get<MusicSearchResult>(`/api/v1/music/search?q=${encodeURIComponent(keyword)}&type=${type}${sourceParams}`)
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* 串流代理与下载音频
|
* 串流代理与下载音频
|
||||||
|
|
|
||||||
|
|
@ -100,7 +100,8 @@ const PlayListApi = {
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
categoryList: (sources?: Source[]) => {
|
categoryList: (sources?: Source[]) => {
|
||||||
return get<PlayListCategoryRes[]>(`/api/v1/playlist/categories?sources=${sources}`)
|
const sourceParams = (sources ?? []).map((s) => `sources=${encodeURIComponent(s)}`).join("&")
|
||||||
|
return get<PlayListCategoryRes[]>(`/api/v1/playlist/categories${sourceParams ? `?${sourceParams}` : ""}`)
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* 获取分类歌单
|
* 获取分类歌单
|
||||||
|
|
@ -131,7 +132,8 @@ const PlayListApi = {
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
recommend: (sources?: Source[]) => {
|
recommend: (sources?: Source[]) => {
|
||||||
return get<PlayListSummary[]>(`/api/v1/playlist/recommend?sources=${sources}`)
|
const sourceParams = (sources ?? []).map((s) => `sources=${encodeURIComponent(s)}`).join("&")
|
||||||
|
return get<PlayListSummary[]>(`/api/v1/playlist/recommend${sourceParams ? `?${sourceParams}` : ""}`)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,10 @@ const paths: Record<string, string[]> = {
|
||||||
"M18 14h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2Z",
|
"M18 14h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2Z",
|
||||||
"M3 14v-3a9 9 0 0 1 18 0v3",
|
"M3 14v-3a9 9 0 0 1 18 0v3",
|
||||||
],
|
],
|
||||||
|
settings: [
|
||||||
|
"M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2Z",
|
||||||
|
"M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z",
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const isFilled = computed(() => filledNames.has(props.name));
|
const isFilled = computed(() => filledNames.has(props.name));
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,7 @@ const navItems = [
|
||||||
{ path: "/ranking", label: "排行榜", icon: "trophy" },
|
{ path: "/ranking", label: "排行榜", icon: "trophy" },
|
||||||
{ path: "/library", label: "我的音乐", icon: "music" },
|
{ path: "/library", label: "我的音乐", icon: "music" },
|
||||||
{ path: "/search", label: "搜索", icon: "search" },
|
{ path: "/search", label: "搜索", icon: "search" },
|
||||||
|
{ path: "/settings", label: "设置", icon: "settings" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
function isActive(path: string) {
|
function isActive(path: string) {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ export interface Song {
|
||||||
album: string;
|
album: string;
|
||||||
duration: number;
|
duration: number;
|
||||||
cover: string;
|
cover: string;
|
||||||
|
/** 来源平台(API 搜索结果携带,用于解析播放直链) */
|
||||||
|
source?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Playlist {
|
export interface Playlist {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import LibraryView from "@/views/LibraryView.vue";
|
||||||
import PlaylistDetailView from "@/views/PlaylistDetailView.vue";
|
import PlaylistDetailView from "@/views/PlaylistDetailView.vue";
|
||||||
import RankingView from "@/views/RankingView.vue";
|
import RankingView from "@/views/RankingView.vue";
|
||||||
import SearchView from "@/views/SearchView.vue";
|
import SearchView from "@/views/SearchView.vue";
|
||||||
|
import SettingsView from "@/views/SettingsView.vue";
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHashHistory(),
|
history: createWebHashHistory(),
|
||||||
|
|
@ -23,6 +24,7 @@ const router = createRouter({
|
||||||
props: true,
|
props: true,
|
||||||
},
|
},
|
||||||
{ path: "search", name: "search", component: SearchView },
|
{ path: "search", name: "search", component: SearchView },
|
||||||
|
{ path: "settings", name: "settings", component: SettingsView },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
import { reactive, watch } from "vue"
|
||||||
|
import Source from "@/types/global"
|
||||||
|
|
||||||
|
/** localStorage 存储键 */
|
||||||
|
const STORAGE_KEY = "ywymusic.settings"
|
||||||
|
|
||||||
|
/** 各平台中文名 */
|
||||||
|
export const SOURCE_LABELS: Record<Source, string> = {
|
||||||
|
[Source.WangYiYun]: "网易云音乐",
|
||||||
|
[Source.QQ]: "QQ 音乐",
|
||||||
|
[Source.QQ_WX]: "QQ 音乐(微信端)",
|
||||||
|
[Source.KuGou]: "酷狗音乐",
|
||||||
|
[Source.KuWo]: "酷我音乐",
|
||||||
|
[Source.MiGu]: "咪咕音乐",
|
||||||
|
[Source.QianQian]: "千千音乐",
|
||||||
|
[Source.QiShui]: "汽水音乐",
|
||||||
|
[Source.FiveSing]: "5sing",
|
||||||
|
[Source.Jamendo]: "Jamendo",
|
||||||
|
[Source.JOOX]: "JOOX",
|
||||||
|
[Source.Bilibili]: "哔哩哔哩",
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 所有可用的音乐源(按枚举声明顺序) */
|
||||||
|
export const ALL_SOURCES: Source[] = Object.values(Source) as Source[]
|
||||||
|
|
||||||
|
interface SettingsState {
|
||||||
|
/** 已启用的音乐源(勾选),用于搜索等多源请求 */
|
||||||
|
enabledSources: Source[]
|
||||||
|
/** 默认音乐源(可从全部源中选择,独立于启用源),用于歌单详情、分类浏览、播放直链等单源请求 */
|
||||||
|
defaultSource: Source
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_SETTINGS: SettingsState = {
|
||||||
|
enabledSources: [Source.KuGou],
|
||||||
|
defaultSource: Source.KuGou,
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSource(value: unknown): value is Source {
|
||||||
|
return typeof value === "string" && ALL_SOURCES.includes(value as Source)
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadSettings(): SettingsState {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (!raw) return { ...DEFAULT_SETTINGS }
|
||||||
|
const data = JSON.parse(raw) as Partial<SettingsState>
|
||||||
|
const enabled = (Array.isArray(data.enabledSources) ? data.enabledSources : [])
|
||||||
|
.filter(isSource)
|
||||||
|
.filter((source, index, arr) => arr.indexOf(source) === index)
|
||||||
|
if (enabled.length === 0) return { ...DEFAULT_SETTINGS }
|
||||||
|
const defaultSource =
|
||||||
|
isSource(data.defaultSource)
|
||||||
|
? data.defaultSource
|
||||||
|
: DEFAULT_SETTINGS.defaultSource
|
||||||
|
return { enabledSources: enabled, defaultSource }
|
||||||
|
} catch {
|
||||||
|
return { ...DEFAULT_SETTINGS }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const settingsState = reactive<SettingsState>(loadSettings())
|
||||||
|
|
||||||
|
// 变更后自动持久化
|
||||||
|
watch(
|
||||||
|
settingsState,
|
||||||
|
() => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(settingsState))
|
||||||
|
} catch {
|
||||||
|
// 存储不可用时静默忽略
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置启用的音乐源(至少保留一个)
|
||||||
|
* 默认源独立于启用源,不随勾选自动切换
|
||||||
|
*/
|
||||||
|
export function setEnabledSources(sources: Array<string | number | boolean>) {
|
||||||
|
const unique = [...new Set(sources.filter(isSource))]
|
||||||
|
if (unique.length === 0) return
|
||||||
|
settingsState.enabledSources = unique
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 设置默认音乐源(可从全部源中选择任意一个) */
|
||||||
|
export function setDefaultSource(source: string | number | boolean) {
|
||||||
|
if (isSource(source)) {
|
||||||
|
settingsState.defaultSource = source
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,8 @@ 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']
|
||||||
|
NCheckbox: typeof import('naive-ui')['NCheckbox']
|
||||||
|
NRadio: typeof import('naive-ui')['NRadio']
|
||||||
NRadioButton: typeof import('naive-ui')['NRadioButton']
|
NRadioButton: typeof import('naive-ui')['NRadioButton']
|
||||||
NRadioGroup: typeof import('naive-ui')['NRadioGroup']
|
NRadioGroup: typeof import('naive-ui')['NRadioGroup']
|
||||||
NScrollbar: typeof import('naive-ui')['NScrollbar']
|
NScrollbar: typeof import('naive-ui')['NScrollbar']
|
||||||
|
|
|
||||||
|
|
@ -105,8 +105,8 @@ import { proxyCover } from "@/utils/cover"
|
||||||
import { formatPlayCount } from "@/utils/format"
|
import { formatPlayCount } from "@/utils/format"
|
||||||
import { formatTime } from "@/utils/time"
|
import { formatTime } from "@/utils/time"
|
||||||
import PlayListApi from "@/api/playlist"
|
import PlayListApi from "@/api/playlist"
|
||||||
import Source from "@/types/global"
|
|
||||||
import { cachePlayListSummary, type PlayListCategory } from "@/api/playlist"
|
import { cachePlayListSummary, type PlayListCategory } from "@/api/playlist"
|
||||||
|
import { settingsState } from "@/stores/settings"
|
||||||
|
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
@ -140,7 +140,7 @@ function playNewSong(songIndex: number) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const list = async () => {
|
const list = async () => {
|
||||||
const res = await PlayListApi.categoryList([Source.KuGou])
|
const res = await PlayListApi.categoryList([settingsState.defaultSource])
|
||||||
categories.value = res[0].categories
|
categories.value = res[0].categories
|
||||||
if (categories.value.length > 0) {
|
if (categories.value.length > 0) {
|
||||||
activeCategory.value = categories.value[1].id
|
activeCategory.value = categories.value[1].id
|
||||||
|
|
@ -149,7 +149,7 @@ const list = async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const playList = async (categoryId: string) => {
|
const playList = async (categoryId: string) => {
|
||||||
const res = await PlayListApi.categoryPlayList(categoryId, Source.KuGou, 1, 30)
|
const res = await PlayListApi.categoryPlayList(categoryId, settingsState.defaultSource, 1, 30)
|
||||||
recommendedPlaylists.value = res.playlists.map((p) => {
|
recommendedPlaylists.value = res.playlists.map((p) => {
|
||||||
cachePlayListSummary(p)
|
cachePlayListSummary(p)
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ import SongTable from "@/components/SongTable.vue"
|
||||||
import PlayListApi, { getCachedPlayListSummary } from "@/api/playlist"
|
import PlayListApi, { getCachedPlayListSummary } from "@/api/playlist"
|
||||||
import { playlists, type Song } from "@/mocks/music"
|
import { playlists, type Song } from "@/mocks/music"
|
||||||
import { playSong } from "@/stores/player"
|
import { playSong } from "@/stores/player"
|
||||||
|
import { settingsState } from "@/stores/settings"
|
||||||
import { displayCover } from "@/utils/cover"
|
import { displayCover } from "@/utils/cover"
|
||||||
import { formatPlayCount } from "@/utils/format"
|
import { formatPlayCount } from "@/utils/format"
|
||||||
import Source from "@/types/global"
|
import Source from "@/types/global"
|
||||||
|
|
@ -76,10 +77,13 @@ const headerCover = computed(() => {
|
||||||
const cover = summary.value?.cover ?? mockPlaylist.value?.cover ?? songs.value[0]?.cover ?? ""
|
const cover = summary.value?.cover ?? mockPlaylist.value?.cover ?? songs.value[0]?.cover ?? ""
|
||||||
return cover ? displayCover(cover) : "linear-gradient(135deg, #4338ca 0%, #7c3aed 100%)"
|
return cover ? displayCover(cover) : "linear-gradient(135deg, #4338ca 0%, #7c3aed 100%)"
|
||||||
})
|
})
|
||||||
|
const playlistSource = computed<Source>(
|
||||||
|
() => (summary.value?.source as Source) ?? settingsState.defaultSource,
|
||||||
|
)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await PlayListApi.detail(Source.KuGou, props.id)
|
const res = await PlayListApi.detail(playlistSource.value, props.id)
|
||||||
songs.value = res.map((song) => ({
|
songs.value = res.map((song) => ({
|
||||||
id: song.id,
|
id: song.id,
|
||||||
title: song.name,
|
title: song.name,
|
||||||
|
|
@ -87,6 +91,7 @@ onMounted(async () => {
|
||||||
album: song.album,
|
album: song.album,
|
||||||
duration: song.duration,
|
duration: song.duration,
|
||||||
cover: displayCover(song.cover),
|
cover: displayCover(song.cover),
|
||||||
|
source: song.source,
|
||||||
}))
|
}))
|
||||||
} catch {
|
} catch {
|
||||||
// 兜底:mock 歌单(如从搜索页进入)继续展示旧数据
|
// 兜底:mock 歌单(如从搜索页进入)继续展示旧数据
|
||||||
|
|
@ -105,13 +110,14 @@ function playAll() {
|
||||||
|
|
||||||
async function playWithUrl(song: Song, queue?: Song[]) {
|
async function playWithUrl(song: Song, queue?: Song[]) {
|
||||||
try {
|
try {
|
||||||
const { url } = await MusicApi.url(String(song.id), Source.KuGou)
|
const source = (song.source ?? playlistSource.value) as Source
|
||||||
|
const { url } = await MusicApi.url(String(song.id), source)
|
||||||
playSong(song, queue, url)
|
playSong(song, queue, url)
|
||||||
} catch {
|
} catch {
|
||||||
// 拿不到直链时仍切歌,后续可尝试 MusicApi.stream 代理播放
|
// 拿不到直链时仍切歌,后续可尝试 MusicApi.stream 代理播放
|
||||||
playSong(song, queue)
|
playSong(song, queue)
|
||||||
}
|
}
|
||||||
message.success(`正在播放:${song.title}`)
|
// message.success(`正在播放:${song.title}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
function favorite() {
|
function favorite() {
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,7 @@
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<div class="search-hero">
|
<div class="search-hero">
|
||||||
<h2>搜索</h2>
|
<h2>搜索</h2>
|
||||||
<n-input
|
<n-input v-model:value="keyword" size="large" round clearable placeholder="搜索音乐、歌手、专辑" class="search-input">
|
||||||
v-model:value="keyword"
|
|
||||||
size="large"
|
|
||||||
round
|
|
||||||
clearable
|
|
||||||
placeholder="搜索音乐、歌手、专辑"
|
|
||||||
class="search-input"
|
|
||||||
>
|
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
<AppIcon name="search" :size="17" class="search-icon" />
|
<AppIcon name="search" :size="17" class="search-icon" />
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -19,15 +12,7 @@
|
||||||
<div v-if="!normalized" class="hot-area">
|
<div v-if="!normalized" class="hot-area">
|
||||||
<p class="hot-title">热门搜索</p>
|
<p class="hot-title">热门搜索</p>
|
||||||
<div class="hot-tags">
|
<div class="hot-tags">
|
||||||
<n-tag
|
<n-tag v-for="hot in hotKeywords" :key="hot" size="large" round :bordered="false" class="hot-tag" @click="useHot(hot)">
|
||||||
v-for="hot in hotKeywords"
|
|
||||||
:key="hot"
|
|
||||||
size="large"
|
|
||||||
round
|
|
||||||
:bordered="false"
|
|
||||||
class="hot-tag"
|
|
||||||
@click="useHot(hot)"
|
|
||||||
>
|
|
||||||
{{ hot }}
|
{{ hot }}
|
||||||
</n-tag>
|
</n-tag>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -37,24 +22,26 @@
|
||||||
<n-tabs v-model:value="activeTab" type="line" animated>
|
<n-tabs v-model:value="activeTab" type="line" animated>
|
||||||
<n-tab-pane name="song" :tab="`单曲 ${songResults.length}`">
|
<n-tab-pane name="song" :tab="`单曲 ${songResults.length}`">
|
||||||
<div class="result-body">
|
<div class="result-body">
|
||||||
<SongTable
|
<n-spin v-if="loading" class="result-loading" />
|
||||||
v-if="songResults.length > 0"
|
<template v-else>
|
||||||
:songs="songResults"
|
<SongTable
|
||||||
@play="(song) => playSong(song, songResults)"
|
v-if="songResults.length > 0"
|
||||||
/>
|
:songs="songResults"
|
||||||
<n-empty v-else class="empty" description="没有找到相关单曲" />
|
@play="playSearchSong"
|
||||||
|
/>
|
||||||
|
<n-empty v-else class="empty" description="没有找到相关单曲" />
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
<n-tab-pane name="playlist" :tab="`歌单 ${playlistResults.length}`">
|
<n-tab-pane name="playlist" :tab="`歌单 ${playlistResults.length}`">
|
||||||
<div class="result-body">
|
<div class="result-body">
|
||||||
<div v-if="playlistResults.length > 0" class="card-grid">
|
<n-spin v-if="loading" class="result-loading" />
|
||||||
<MusicCard
|
<template v-else>
|
||||||
v-for="playlist in playlistResults"
|
<div v-if="playlistResults.length > 0" class="card-grid">
|
||||||
:key="playlist.id"
|
<MusicCard v-for="playlist in playlistResults" :key="playlist.id" :playlist="playlist" />
|
||||||
:playlist="playlist"
|
</div>
|
||||||
/>
|
<n-empty v-else class="empty" description="没有找到相关歌单" />
|
||||||
</div>
|
</template>
|
||||||
<n-empty v-else class="empty" description="没有找到相关歌单" />
|
|
||||||
</div>
|
</div>
|
||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
</n-tabs>
|
</n-tabs>
|
||||||
|
|
@ -63,43 +50,116 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from "vue";
|
import { computed, onBeforeUnmount, ref, watch } from "vue"
|
||||||
import { NEmpty, NInput, NTabPane, NTabs, NTag, useMessage } from "naive-ui";
|
import { NEmpty, NInput, NSpin, NTabPane, NTabs, NTag, useMessage } from "naive-ui"
|
||||||
import AppIcon from "@/components/AppIcon.vue";
|
import AppIcon from "@/components/AppIcon.vue"
|
||||||
import MusicCard from "@/components/MusicCard.vue";
|
import MusicCard from "@/components/MusicCard.vue"
|
||||||
import SongTable from "@/components/SongTable.vue";
|
import SongTable from "@/components/SongTable.vue"
|
||||||
import { allSongs, hotKeywords, playlists } from "@/mocks/music";
|
import MusicApi, { type MusicSearchPlaylist, type MusicSearchSong } from "@/api/music"
|
||||||
import { playSong } from "@/stores/player";
|
import { hotKeywords, type Playlist, type Song } from "@/mocks/music"
|
||||||
|
import { playSong } from "@/stores/player"
|
||||||
|
import { settingsState } from "@/stores/settings"
|
||||||
|
import { displayCover } from "@/utils/cover"
|
||||||
|
import { formatPlayCount } from "@/utils/format"
|
||||||
|
import Source from "@/types/global"
|
||||||
|
|
||||||
const message = useMessage();
|
const message = useMessage()
|
||||||
const keyword = ref("");
|
const keyword = ref("")
|
||||||
const activeTab = ref("song");
|
const activeTab = ref("song")
|
||||||
|
|
||||||
const normalized = computed(() => keyword.value.trim().toLowerCase());
|
const normalized = computed(() => keyword.value.trim().toLowerCase())
|
||||||
|
|
||||||
const songResults = computed(() => {
|
const loading = ref(false)
|
||||||
const q = normalized.value;
|
const songResults = ref<Song[]>([])
|
||||||
if (!q) return [];
|
const playlistResults = ref<Playlist[]>([])
|
||||||
return allSongs.filter(
|
|
||||||
(song) =>
|
|
||||||
song.title.toLowerCase().includes(q) || song.artist.toLowerCase().includes(q),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const playlistResults = computed(() => {
|
let searchTimer: number | undefined
|
||||||
const q = normalized.value;
|
let requestSeq = 0
|
||||||
if (!q) return [];
|
|
||||||
return playlists.filter(
|
watch(normalized, (q) => {
|
||||||
(playlist) =>
|
if (searchTimer !== undefined) {
|
||||||
playlist.name.toLowerCase().includes(q) ||
|
window.clearTimeout(searchTimer)
|
||||||
playlist.tags.some((tag) => tag.toLowerCase().includes(q)),
|
searchTimer = undefined
|
||||||
);
|
}
|
||||||
});
|
if (!q) {
|
||||||
|
requestSeq += 1
|
||||||
|
songResults.value = []
|
||||||
|
playlistResults.value = []
|
||||||
|
loading.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
searchTimer = window.setTimeout(() => {
|
||||||
|
void doSearch(q)
|
||||||
|
}, 400)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function doSearch(q: string) {
|
||||||
|
const seq = ++requestSeq
|
||||||
|
loading.value = true
|
||||||
|
const [songRes, playlistRes] = await Promise.allSettled([
|
||||||
|
MusicApi.search(q, "song", settingsState.enabledSources),
|
||||||
|
MusicApi.search(q, "playlist", settingsState.enabledSources),
|
||||||
|
])
|
||||||
|
if (seq !== requestSeq) return
|
||||||
|
if (songRes.status === "fulfilled") {
|
||||||
|
songResults.value = (songRes.value.songs ?? []).map(toSong)
|
||||||
|
} else {
|
||||||
|
songResults.value = []
|
||||||
|
}
|
||||||
|
if (playlistRes.status === "fulfilled") {
|
||||||
|
playlistResults.value = (playlistRes.value.playlists ?? []).map(toPlaylist)
|
||||||
|
} else {
|
||||||
|
playlistResults.value = []
|
||||||
|
}
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSong(item: MusicSearchSong): Song {
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
title: item.name,
|
||||||
|
artist: item.artist,
|
||||||
|
album: item.album,
|
||||||
|
duration: item.duration,
|
||||||
|
cover: displayCover(item.cover),
|
||||||
|
source: item.source,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPlaylist(item: MusicSearchPlaylist): Playlist {
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
name: item.name,
|
||||||
|
desc: item.description,
|
||||||
|
cover: displayCover(item.cover),
|
||||||
|
tags: [],
|
||||||
|
playCount: formatPlayCount(item.play_count),
|
||||||
|
songs: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function playSearchSong(song: Song) {
|
||||||
|
const source = (song.source ?? settingsState.defaultSource) as Source
|
||||||
|
try {
|
||||||
|
const { url } = await MusicApi.url(String(song.id), source)
|
||||||
|
playSong(song, songResults.value, url)
|
||||||
|
} catch {
|
||||||
|
// 拿不到直链时仍切歌,后续可尝试 MusicApi.stream 代理播放
|
||||||
|
playSong(song, songResults.value)
|
||||||
|
}
|
||||||
|
message.success(`正在播放:${song.title}`)
|
||||||
|
}
|
||||||
|
|
||||||
function useHot(hot: string) {
|
function useHot(hot: string) {
|
||||||
keyword.value = hot;
|
keyword.value = hot
|
||||||
message.success(`搜索「${hot}」`);
|
message.success(`搜索「${hot}」`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (searchTimer !== undefined) {
|
||||||
|
window.clearTimeout(searchTimer)
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
@ -159,6 +219,12 @@ function useHot(hot: string) {
|
||||||
padding-top: 18px;
|
padding-top: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.result-loading {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 48px 0;
|
||||||
|
}
|
||||||
|
|
||||||
.card-grid {
|
.card-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,162 @@
|
||||||
|
<template>
|
||||||
|
<div class="page">
|
||||||
|
<div class="page-head">
|
||||||
|
<h2>设置</h2>
|
||||||
|
<p>配置音乐来源,之后的请求会使用你选择的平台(更改自动保存)</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="setting-card">
|
||||||
|
<div class="setting-head">
|
||||||
|
<h3>音乐源</h3>
|
||||||
|
<p>勾选后,搜索等请求会同时在所选平台查找;至少保留一个。</p>
|
||||||
|
</div>
|
||||||
|
<n-checkbox-group :value="settingsState.enabledSources" @update:value="setEnabledSources">
|
||||||
|
<div class="source-grid">
|
||||||
|
<div
|
||||||
|
v-for="source in ALL_SOURCES"
|
||||||
|
:key="source"
|
||||||
|
class="source-item"
|
||||||
|
:class="{ checked: settingsState.enabledSources.includes(source) }"
|
||||||
|
>
|
||||||
|
<n-checkbox :value="source" />
|
||||||
|
<span class="source-label">{{ SOURCE_LABELS[source] }}</span>
|
||||||
|
<span class="source-key">{{ source }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</n-checkbox-group>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="setting-card">
|
||||||
|
<div class="setting-head">
|
||||||
|
<h3>默认音乐源</h3>
|
||||||
|
<p>可从全部音乐源中任选一个作为默认,不要求已勾选;用于歌单详情、分类浏览,以及歌曲未携带平台信息时解析播放直链。</p>
|
||||||
|
</div>
|
||||||
|
<n-radio-group
|
||||||
|
:value="settingsState.defaultSource"
|
||||||
|
@update:value="setDefaultSource"
|
||||||
|
class="default-group"
|
||||||
|
>
|
||||||
|
<n-radio
|
||||||
|
v-for="source in ALL_SOURCES"
|
||||||
|
:key="source"
|
||||||
|
:value="source"
|
||||||
|
:label="SOURCE_LABELS[source]"
|
||||||
|
class="default-radio"
|
||||||
|
/>
|
||||||
|
</n-radio-group>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { NCheckboxGroup, NRadioGroup } from "naive-ui"
|
||||||
|
import { ALL_SOURCES, SOURCE_LABELS, setDefaultSource, setEnabledSources, settingsState } from "@/stores/settings"
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page {
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-head {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-head h2 {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-size: clamp(22px, 2.5vw, 26px);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-head p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--app-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-card {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding: clamp(16px, 2vw, 22px);
|
||||||
|
border: 1px solid var(--app-border);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-head {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-head h3 {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-head p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--app-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--app-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
border-color 0.15s ease,
|
||||||
|
background-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-item:hover {
|
||||||
|
border-color: rgba(109, 93, 246, 0.45);
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-item.checked {
|
||||||
|
border-color: rgba(34, 197, 94, 0.5);
|
||||||
|
background: rgba(34, 197, 94, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--app-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-key {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--app-text-muted);
|
||||||
|
font-family: ui-monospace, "Cascadia Code", Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.default-group {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.default-radio {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid var(--app-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
transition:
|
||||||
|
border-color 0.15s ease,
|
||||||
|
background-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.default-radio:hover {
|
||||||
|
border-color: rgba(109, 93, 246, 0.45);
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Loading…
Reference in New Issue