diff --git a/src/api/music.ts b/src/api/music.ts index 85a24f0..7ff1967 100644 --- a/src/api/music.ts +++ b/src/api/music.ts @@ -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(`/api/v1/music/search?q=${keyword}&type=${type}&sources=${sources}`) + const sourceParams = (sources ?? []).map((s) => `&sources=${encodeURIComponent(s)}`).join("") + return get(`/api/v1/music/search?q=${encodeURIComponent(keyword)}&type=${type}${sourceParams}`) }, /** * 串流代理与下载音频 diff --git a/src/api/playlist.ts b/src/api/playlist.ts index 0ad5f25..a4d771e 100644 --- a/src/api/playlist.ts +++ b/src/api/playlist.ts @@ -100,7 +100,8 @@ const PlayListApi = { * @returns */ categoryList: (sources?: Source[]) => { - return get(`/api/v1/playlist/categories?sources=${sources}`) + const sourceParams = (sources ?? []).map((s) => `sources=${encodeURIComponent(s)}`).join("&") + return get(`/api/v1/playlist/categories${sourceParams ? `?${sourceParams}` : ""}`) }, /** * 获取分类歌单 @@ -131,7 +132,8 @@ const PlayListApi = { * @returns */ recommend: (sources?: Source[]) => { - return get(`/api/v1/playlist/recommend?sources=${sources}`) + const sourceParams = (sources ?? []).map((s) => `sources=${encodeURIComponent(s)}`).join("&") + return get(`/api/v1/playlist/recommend${sourceParams ? `?${sourceParams}` : ""}`) }, } diff --git a/src/components/AppIcon.vue b/src/components/AppIcon.vue index 0447f8f..b619f15 100644 --- a/src/components/AppIcon.vue +++ b/src/components/AppIcon.vue @@ -85,6 +85,10 @@ const paths: Record = { "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)); diff --git a/src/layouts/MainLayout.vue b/src/layouts/MainLayout.vue index 53aaeab..5e3a824 100644 --- a/src/layouts/MainLayout.vue +++ b/src/layouts/MainLayout.vue @@ -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) { diff --git a/src/mocks/music.ts b/src/mocks/music.ts index 76ae7b2..1544fcb 100644 --- a/src/mocks/music.ts +++ b/src/mocks/music.ts @@ -5,6 +5,8 @@ export interface Song { album: string; duration: number; cover: string; + /** 来源平台(API 搜索结果携带,用于解析播放直链) */ + source?: string; } export interface Playlist { diff --git a/src/router/index.ts b/src/router/index.ts index 6bfacf7..9f314e0 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -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 }, ], }, ], diff --git a/src/stores/settings.ts b/src/stores/settings.ts new file mode 100644 index 0000000..60f85b6 --- /dev/null +++ b/src/stores/settings.ts @@ -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.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 + 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(loadSettings()) + +// 变更后自动持久化 +watch( + settingsState, + () => { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(settingsState)) + } catch { + // 存储不可用时静默忽略 + } + }, + { deep: true }, +) + +/** + * 设置启用的音乐源(至少保留一个) + * 默认源独立于启用源,不随勾选自动切换 + */ +export function setEnabledSources(sources: Array) { + 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 + } +} diff --git a/src/types/components.d.ts b/src/types/components.d.ts index 0c336e0..af040b4 100644 --- a/src/types/components.d.ts +++ b/src/types/components.d.ts @@ -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'] diff --git a/src/views/DiscoverView.vue b/src/views/DiscoverView.vue index 064577f..294b596 100644 --- a/src/views/DiscoverView.vue +++ b/src/views/DiscoverView.vue @@ -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 { diff --git a/src/views/PlaylistDetailView.vue b/src/views/PlaylistDetailView.vue index 559191c..dcad5f1 100644 --- a/src/views/PlaylistDetailView.vue +++ b/src/views/PlaylistDetailView.vue @@ -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( + () => (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() { diff --git a/src/views/SearchView.vue b/src/views/SearchView.vue index ddc8883..6f52a12 100644 --- a/src/views/SearchView.vue +++ b/src/views/SearchView.vue @@ -2,14 +2,7 @@

搜索

- + @@ -19,15 +12,7 @@

热门搜索

- + {{ hot }}
@@ -37,24 +22,26 @@
- - + +
-
- -
- + +
@@ -63,43 +50,116 @@