From 40052f638ab3e7b384ca85f2f428252ca50ab2cd Mon Sep 17 00:00:00 2001 From: imsyy Date: Wed, 13 May 2026 18:31:58 +0800 Subject: [PATCH 01/33] =?UTF-8?q?feat:=20=E9=A1=B6=E6=A0=8F=E6=90=9C?= =?UTF-8?q?=E7=B4=A2=E5=BC=B9=E7=AA=97=E4=B8=8E=E7=BD=91=E6=98=93=E4=BA=91?= =?UTF-8?q?=E6=90=9C=E7=B4=A2=E7=BB=93=E6=9E=9C=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components.d.ts | 13 +- electron.vite.config.ts | 2 +- src/apis/search.ts | 173 ++++++++++++++ .../layout}/NavHeader.vue | 1 + src/components/layout/NavSearch.vue | 74 ++++++ .../layout}/SideBar.vue | 0 .../layout}/SideBarLogo.vue | 0 .../layout}/WindowControls.vue | 0 src/components/list/CoverList.vue | 2 + src/components/ui/SInput.vue | 4 +- src/i18n/locales/en-US.json | 20 +- src/i18n/locales/zh-CN.json | 20 +- src/pages/Search.vue | 225 ++++++++++++++++++ src/router/index.ts | 5 + 14 files changed, 530 insertions(+), 9 deletions(-) create mode 100644 src/apis/search.ts rename src/{layouts/components => components/layout}/NavHeader.vue (99%) create mode 100644 src/components/layout/NavSearch.vue rename src/{layouts/components => components/layout}/SideBar.vue (100%) rename src/{layouts/components => components/layout}/SideBarLogo.vue (100%) rename src/{layouts/components => components/layout}/WindowControls.vue (100%) create mode 100644 src/pages/Search.vue diff --git a/components.d.ts b/components.d.ts index 3f11b156..c6f9c67a 100644 --- a/components.d.ts +++ b/components.d.ts @@ -62,7 +62,9 @@ declare module 'vue' { FontConfig: typeof import('./src/components/settings/custom/FontConfig.vue')['default'] FullPlayer: typeof import('./src/components/player/FullPlayer/index.vue')['default'] HotkeyConfig: typeof import('./src/components/settings/custom/HotkeyConfig.vue')['default'] + IconLucideArrowRight: typeof import('~icons/lucide/arrow-right')['default'] IconLucideArrowUpCircle: typeof import('~icons/lucide/arrow-up-circle')['default'] + IconLucideBan: typeof import('~icons/lucide/ban')['default'] IconLucideCheck: typeof import('~icons/lucide/check')['default'] IconLucideChevronDown: typeof import('~icons/lucide/chevron-down')['default'] IconLucideChevronLeft: typeof import('~icons/lucide/chevron-left')['default'] @@ -119,7 +121,8 @@ declare module 'vue' { LyricFormatOrderConfig: typeof import('./src/components/settings/custom/LyricFormatOrderConfig.vue')['default'] Lyrics: typeof import('./src/components/player/Lyrics/index.vue')['default'] LyricSourceOrderConfig: typeof import('./src/components/settings/custom/LyricSourceOrderConfig.vue')['default'] - NavHeader: typeof import('./src/layouts/components/NavHeader.vue')['default'] + NavHeader: typeof import('./src/components/layout/NavHeader.vue')['default'] + NavSearch: typeof import('./src/components/layout/NavSearch.vue')['default'] NumberFieldDecrement: typeof import('reka-ui')['NumberFieldDecrement'] NumberFieldIncrement: typeof import('reka-ui')['NumberFieldIncrement'] NumberFieldInput: typeof import('reka-ui')['NumberFieldInput'] @@ -150,6 +153,8 @@ declare module 'vue' { SDivider: typeof import('./src/components/ui/SDivider.vue')['default'] SDrawer: typeof import('./src/components/ui/SDrawer.vue')['default'] SDropdownMenu: typeof import('./src/components/ui/SDropdownMenu.vue')['default'] + SearchDialog: typeof import('./src/components/layout/SearchDialog.vue')['default'] + SearchOverlay: typeof import('./src/components/search/SearchOverlay.vue')['default'] SelectContent: typeof import('reka-ui')['SelectContent'] SelectIcon: typeof import('reka-ui')['SelectIcon'] SelectItem: typeof import('reka-ui')['SelectItem'] @@ -167,8 +172,8 @@ declare module 'vue' { SettingsSearch: typeof import('./src/components/settings/SettingsSearch.vue')['default'] SettingsSection: typeof import('./src/components/settings/SettingsSection.vue')['default'] SFormItem: typeof import('./src/components/ui/SFormItem.vue')['default'] - SideBar: typeof import('./src/layouts/components/SideBar.vue')['default'] - SideBarLogo: typeof import('./src/layouts/components/SideBarLogo.vue')['default'] + SideBar: typeof import('./src/components/layout/SideBar.vue')['default'] + SideBarLogo: typeof import('./src/components/layout/SideBarLogo.vue')['default'] SImg: typeof import('./src/components/ui/SImg.vue')['default'] SInput: typeof import('./src/components/ui/SInput.vue')['default'] SLoading: typeof import('./src/components/ui/SLoading.vue')['default'] @@ -198,6 +203,6 @@ declare module 'vue' { SwitchThumb: typeof import('reka-ui')['SwitchThumb'] Toolbar: typeof import('./src/components/player/Toolbar.vue')['default'] TrackInfo: typeof import('./src/components/player/TrackInfo.vue')['default'] - WindowControls: typeof import('./src/layouts/components/WindowControls.vue')['default'] + WindowControls: typeof import('./src/components/layout/WindowControls.vue')['default'] } } diff --git a/electron.vite.config.ts b/electron.vite.config.ts index ddf84d78..4d2c47a4 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -91,7 +91,7 @@ export default defineConfig({ }, }), Components({ - dirs: ["src/components", "src/layouts/components"], + dirs: ["src/components"], resolvers: [RekaResolver(), IconsResolver({ prefix: "icon", customCollections: ["sp"] })], }), ], diff --git a/src/apis/search.ts b/src/apis/search.ts new file mode 100644 index 00000000..d135e959 --- /dev/null +++ b/src/apis/search.ts @@ -0,0 +1,173 @@ +/** + * 在线平台搜索接口 + * + * 统一返回 Track / CoverItem,向页面屏蔽各平台原始结构。 + * 目前仅接入 netease,后续 qqmusic / kugou 在此扩展。 + */ + +import type { Track } from "@shared/types/player"; +import type { CoverItem } from "@/types/artist"; +import type { Platform } from "@shared/types/platform"; +import { netease } from "@/apis/netease"; + +/** 搜索结果通用结构 */ +export interface SearchResult { + items: T[]; + total: number; + hasMore: boolean; +} + +interface NeteaseSong { + id: number; + name: string; + ar: { id: number; name: string }[]; + al: { id: number; name: string; picUrl: string }; + dt: number; +} +interface NeteaseAlbum { + id: number; + name: string; + picUrl: string; + artists: { id: number; name: string }[]; + size: number; +} +interface NeteaseArtist { + id: number; + name: string; + picUrl?: string; + img1v1Url?: string; + albumSize?: number; +} +interface NeteasePlaylist { + id: number; + name: string; + coverImgUrl: string; + creator?: { nickname: string }; + trackCount: number; +} + +interface CloudSearchBody { + result?: { + songs?: NeteaseSong[]; + albums?: NeteaseAlbum[]; + artists?: NeteaseArtist[]; + playlists?: NeteasePlaylist[]; + songCount?: number; + albumCount?: number; + artistCount?: number; + playlistCount?: number; + }; +} + +/** netease 搜索 type 编码 */ +const NETEASE_TYPE = { songs: 1, albums: 10, artists: 100, playlists: 1000 } as const; + +const neteaseSongToTrack = (song: NeteaseSong): Track => ({ + id: String(song.id), + source: "online", + platform: "netease", + title: song.name, + artists: (song.ar ?? []).map((artist) => ({ id: String(artist.id), name: artist.name })), + album: song.al + ? { id: String(song.al.id), name: song.al.name, cover: song.al.picUrl } + : undefined, + duration: song.dt ?? 0, + cover: song.al?.picUrl, +}); + +const neteaseAlbumToCover = (album: NeteaseAlbum): CoverItem => ({ + id: String(album.id), + title: album.name, + cover: album.picUrl, + subtitle: (album.artists ?? []).map((artist) => artist.name).join(" / "), + trackCount: album.size ?? 0, +}); + +const neteaseArtistToCover = (artist: NeteaseArtist): CoverItem => ({ + id: String(artist.id), + title: artist.name, + cover: artist.img1v1Url ?? artist.picUrl, + subtitle: "", + trackCount: artist.albumSize ?? 0, +}); + +const neteasePlaylistToCover = (playlist: NeteasePlaylist): CoverItem => ({ + id: String(playlist.id), + title: playlist.name, + cover: playlist.coverImgUrl, + subtitle: playlist.creator?.nickname ?? "", + trackCount: playlist.trackCount ?? 0, +}); + +const callNeteaseCloudSearch = ( + type: keyof typeof NETEASE_TYPE, + keyword: string, + offset: number, + limit: number, +): Promise => + netease.cloudsearch({ + keywords: keyword, + type: NETEASE_TYPE[type], + offset, + limit, + }); + +const unsupported = (platform: Platform): never => { + throw new Error(`Search not yet supported for platform: ${platform}`); +}; + +/** 搜索单曲 */ +export const searchSongs = async ( + platform: Platform, + keyword: string, + offset: number, + limit: number, +): Promise> => { + if (platform !== "netease") return unsupported(platform); + const body = await callNeteaseCloudSearch("songs", keyword, offset, limit); + const items = (body?.result?.songs ?? []).map(neteaseSongToTrack); + const total = body?.result?.songCount ?? items.length; + return { items, total, hasMore: offset + items.length < total }; +}; + +/** 搜索专辑 */ +export const searchAlbums = async ( + platform: Platform, + keyword: string, + offset: number, + limit: number, +): Promise> => { + if (platform !== "netease") return unsupported(platform); + const body = await callNeteaseCloudSearch("albums", keyword, offset, limit); + const items = (body?.result?.albums ?? []).map(neteaseAlbumToCover); + const total = body?.result?.albumCount ?? items.length; + return { items, total, hasMore: offset + items.length < total }; +}; + +/** 搜索歌手 */ +export const searchArtists = async ( + platform: Platform, + keyword: string, + offset: number, + limit: number, +): Promise> => { + if (platform !== "netease") return unsupported(platform); + const body = await callNeteaseCloudSearch("artists", keyword, offset, limit); + const items = (body?.result?.artists ?? []).map(neteaseArtistToCover); + const total = body?.result?.artistCount ?? items.length; + return { items, total, hasMore: offset + items.length < total }; +}; + +/** 搜索歌单 */ +export const searchPlaylists = async ( + platform: Platform, + keyword: string, + offset: number, + limit: number, +): Promise> => { + if (platform !== "netease") return unsupported(platform); + const body = await callNeteaseCloudSearch("playlists", keyword, offset, limit); + const items = (body?.result?.playlists ?? []).map(neteasePlaylistToCover); + const total = body?.result?.playlistCount ?? items.length; + return { items, total, hasMore: offset + items.length < total }; +}; diff --git a/src/layouts/components/NavHeader.vue b/src/components/layout/NavHeader.vue similarity index 99% rename from src/layouts/components/NavHeader.vue rename to src/components/layout/NavHeader.vue index c4fdbaa5..5e05acad 100644 --- a/src/layouts/components/NavHeader.vue +++ b/src/components/layout/NavHeader.vue @@ -70,6 +70,7 @@ const onMenuSelect = (key: string): void => { > +
diff --git a/src/components/layout/NavSearch.vue b/src/components/layout/NavSearch.vue new file mode 100644 index 00000000..39be3cf6 --- /dev/null +++ b/src/components/layout/NavSearch.vue @@ -0,0 +1,74 @@ + + + diff --git a/src/layouts/components/SideBar.vue b/src/components/layout/SideBar.vue similarity index 100% rename from src/layouts/components/SideBar.vue rename to src/components/layout/SideBar.vue diff --git a/src/layouts/components/SideBarLogo.vue b/src/components/layout/SideBarLogo.vue similarity index 100% rename from src/layouts/components/SideBarLogo.vue rename to src/components/layout/SideBarLogo.vue diff --git a/src/layouts/components/WindowControls.vue b/src/components/layout/WindowControls.vue similarity index 100% rename from src/layouts/components/WindowControls.vue rename to src/components/layout/WindowControls.vue diff --git a/src/components/list/CoverList.vue b/src/components/list/CoverList.vue index af0362b5..9a6ad5f3 100644 --- a/src/components/list/CoverList.vue +++ b/src/components/list/CoverList.vue @@ -46,6 +46,7 @@ const actualFallback = computed(() => { const emit = defineEmits<{ click: [item: CoverItem]; + reachBottom: []; }>(); const virtualListRef = ref(null); @@ -103,6 +104,7 @@ const getRowKey = (row: Row): string => row.id; :padding-top="paddingTop" :padding-bottom="paddingBottom" height="100%" + @reach-bottom="emit('reachBottom')" >