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
|
||||
}
|
||||
|
||||
/** 搜索结果-单曲 */
|
||||
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 = {
|
||||
/**
|
||||
* 代理请求并下载封面图
|
||||
|
|
@ -59,7 +110,8 @@ const MusicApi = {
|
|||
* @returns
|
||||
*/
|
||||
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
|
||||
*/
|
||||
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
|
||||
*/
|
||||
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",
|
||||
"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));
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ const navItems = [
|
|||
{ path: "/ranking", label: "排行榜", icon: "trophy" },
|
||||
{ path: "/library", label: "我的音乐", icon: "music" },
|
||||
{ path: "/search", label: "搜索", icon: "search" },
|
||||
{ path: "/settings", label: "设置", icon: "settings" },
|
||||
] as const;
|
||||
|
||||
function isActive(path: string) {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ export interface Song {
|
|||
album: string;
|
||||
duration: number;
|
||||
cover: string;
|
||||
/** 来源平台(API 搜索结果携带,用于解析播放直链) */
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export interface Playlist {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import LibraryView from "@/views/LibraryView.vue";
|
|||
import PlaylistDetailView from "@/views/PlaylistDetailView.vue";
|
||||
import RankingView from "@/views/RankingView.vue";
|
||||
import SearchView from "@/views/SearchView.vue";
|
||||
import SettingsView from "@/views/SettingsView.vue";
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
|
|
@ -23,6 +24,7 @@ const router = createRouter({
|
|||
props: true,
|
||||
},
|
||||
{ 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']
|
||||
FullscreenPlayer: typeof import('./../components/FullscreenPlayer.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']
|
||||
NRadioGroup: typeof import('naive-ui')['NRadioGroup']
|
||||
NScrollbar: typeof import('naive-ui')['NScrollbar']
|
||||
|
|
|
|||
|
|
@ -105,8 +105,8 @@ 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 { settingsState } from "@/stores/settings"
|
||||
|
||||
const message = useMessage()
|
||||
const router = useRouter()
|
||||
|
|
@ -140,7 +140,7 @@ function playNewSong(songIndex: number) {
|
|||
}
|
||||
|
||||
const list = async () => {
|
||||
const res = await PlayListApi.categoryList([Source.KuGou])
|
||||
const res = await PlayListApi.categoryList([settingsState.defaultSource])
|
||||
categories.value = res[0].categories
|
||||
if (categories.value.length > 0) {
|
||||
activeCategory.value = categories.value[1].id
|
||||
|
|
@ -149,7 +149,7 @@ const list = async () => {
|
|||
}
|
||||
|
||||
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) => {
|
||||
cachePlayListSummary(p)
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ 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 { settingsState } from "@/stores/settings"
|
||||
import { displayCover } from "@/utils/cover"
|
||||
import { formatPlayCount } from "@/utils/format"
|
||||
import Source from "@/types/global"
|
||||
|
|
@ -76,10 +77,13 @@ 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 playlistSource = computed<Source>(
|
||||
() => (summary.value?.source as Source) ?? settingsState.defaultSource,
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await PlayListApi.detail(Source.KuGou, props.id)
|
||||
const res = await PlayListApi.detail(playlistSource.value, props.id)
|
||||
songs.value = res.map((song) => ({
|
||||
id: song.id,
|
||||
title: song.name,
|
||||
|
|
@ -87,6 +91,7 @@ onMounted(async () => {
|
|||
album: song.album,
|
||||
duration: song.duration,
|
||||
cover: displayCover(song.cover),
|
||||
source: song.source,
|
||||
}))
|
||||
} catch {
|
||||
// 兜底:mock 歌单(如从搜索页进入)继续展示旧数据
|
||||
|
|
@ -105,13 +110,14 @@ function playAll() {
|
|||
|
||||
async function playWithUrl(song: Song, queue?: Song[]) {
|
||||
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)
|
||||
} catch {
|
||||
// 拿不到直链时仍切歌,后续可尝试 MusicApi.stream 代理播放
|
||||
playSong(song, queue)
|
||||
}
|
||||
message.success(`正在播放:${song.title}`)
|
||||
// message.success(`正在播放:${song.title}`)
|
||||
}
|
||||
|
||||
function favorite() {
|
||||
|
|
|
|||
|
|
@ -2,14 +2,7 @@
|
|||
<div class="page">
|
||||
<div class="search-hero">
|
||||
<h2>搜索</h2>
|
||||
<n-input
|
||||
v-model:value="keyword"
|
||||
size="large"
|
||||
round
|
||||
clearable
|
||||
placeholder="搜索音乐、歌手、专辑"
|
||||
class="search-input"
|
||||
>
|
||||
<n-input v-model:value="keyword" size="large" round clearable placeholder="搜索音乐、歌手、专辑" class="search-input">
|
||||
<template #prefix>
|
||||
<AppIcon name="search" :size="17" class="search-icon" />
|
||||
</template>
|
||||
|
|
@ -19,15 +12,7 @@
|
|||
<div v-if="!normalized" class="hot-area">
|
||||
<p class="hot-title">热门搜索</p>
|
||||
<div class="hot-tags">
|
||||
<n-tag
|
||||
v-for="hot in hotKeywords"
|
||||
:key="hot"
|
||||
size="large"
|
||||
round
|
||||
:bordered="false"
|
||||
class="hot-tag"
|
||||
@click="useHot(hot)"
|
||||
>
|
||||
<n-tag v-for="hot in hotKeywords" :key="hot" size="large" round :bordered="false" class="hot-tag" @click="useHot(hot)">
|
||||
{{ hot }}
|
||||
</n-tag>
|
||||
</div>
|
||||
|
|
@ -37,24 +22,26 @@
|
|||
<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"
|
||||
@play="(song) => playSong(song, songResults)"
|
||||
/>
|
||||
<n-empty v-else class="empty" description="没有找到相关单曲" />
|
||||
<n-spin v-if="loading" class="result-loading" />
|
||||
<template v-else>
|
||||
<SongTable
|
||||
v-if="songResults.length > 0"
|
||||
:songs="songResults"
|
||||
@play="playSearchSong"
|
||||
/>
|
||||
<n-empty v-else class="empty" description="没有找到相关单曲" />
|
||||
</template>
|
||||
</div>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="playlist" :tab="`歌单 ${playlistResults.length}`">
|
||||
<div class="result-body">
|
||||
<div v-if="playlistResults.length > 0" class="card-grid">
|
||||
<MusicCard
|
||||
v-for="playlist in playlistResults"
|
||||
:key="playlist.id"
|
||||
:playlist="playlist"
|
||||
/>
|
||||
</div>
|
||||
<n-empty v-else class="empty" description="没有找到相关歌单" />
|
||||
<n-spin v-if="loading" class="result-loading" />
|
||||
<template v-else>
|
||||
<div v-if="playlistResults.length > 0" class="card-grid">
|
||||
<MusicCard v-for="playlist in playlistResults" :key="playlist.id" :playlist="playlist" />
|
||||
</div>
|
||||
<n-empty v-else class="empty" description="没有找到相关歌单" />
|
||||
</template>
|
||||
</div>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
|
|
@ -63,43 +50,116 @@
|
|||
</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";
|
||||
import { playSong } from "@/stores/player";
|
||||
import { computed, onBeforeUnmount, ref, watch } from "vue"
|
||||
import { NEmpty, NInput, NSpin, 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 MusicApi, { type MusicSearchPlaylist, type MusicSearchSong } from "@/api/music"
|
||||
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 keyword = ref("");
|
||||
const activeTab = ref("song");
|
||||
const message = useMessage()
|
||||
const keyword = ref("")
|
||||
const activeTab = ref("song")
|
||||
|
||||
const normalized = computed(() => keyword.value.trim().toLowerCase());
|
||||
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 loading = ref(false)
|
||||
const songResults = ref<Song[]>([])
|
||||
const playlistResults = ref<Playlist[]>([])
|
||||
|
||||
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)),
|
||||
);
|
||||
});
|
||||
let searchTimer: number | undefined
|
||||
let requestSeq = 0
|
||||
|
||||
watch(normalized, (q) => {
|
||||
if (searchTimer !== undefined) {
|
||||
window.clearTimeout(searchTimer)
|
||||
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) {
|
||||
keyword.value = hot;
|
||||
message.success(`搜索「${hot}」`);
|
||||
keyword.value = hot
|
||||
message.success(`搜索「${hot}」`)
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (searchTimer !== undefined) {
|
||||
window.clearTimeout(searchTimer)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
@ -159,6 +219,12 @@ function useHot(hot: string) {
|
|||
padding-top: 18px;
|
||||
}
|
||||
|
||||
.result-loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 48px 0;
|
||||
}
|
||||
|
||||
.card-grid {
|
||||
display: grid;
|
||||
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