diff --git a/src/app/api/music/v2/discovery/songlist-detail/route.ts b/src/app/api/music/v2/discovery/songlist-detail/route.ts new file mode 100644 index 0000000..33aaf6b --- /dev/null +++ b/src/app/api/music/v2/discovery/songlist-detail/route.ts @@ -0,0 +1,34 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import { isMusicSource, lxGetJson, normalizeLxSong, unwrapLxArray } from '@/lib/music-v2'; +import { badRequest, internalError } from '@/lib/music-v2-api'; + +export const runtime = 'nodejs'; + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const source = searchParams.get('source') || 'wy'; + const id = searchParams.get('id') || ''; + const page = Number(searchParams.get('page') || '1'); + + if (!isMusicSource(source)) return badRequest('不支持的音源'); + if (!id) return badRequest('缺少歌单 ID'); + + const payload = await lxGetJson(`/api/music/songList/detail?source=${source}&id=${encodeURIComponent(id)}&page=${page}`, 'none'); + const list = unwrapLxArray(payload); + + return NextResponse.json({ + success: true, + data: { + info: payload?.info || payload?.data?.info || {}, + list: list.map(normalizeLxSong), + page: payload?.page ?? page, + total: payload?.total ?? list.length, + limit: payload?.limit ?? list.length, + }, + }); + } catch (error) { + return internalError('获取歌单详情失败', (error as Error).message); + } +} diff --git a/src/app/api/music/v2/discovery/songlist-tags/route.ts b/src/app/api/music/v2/discovery/songlist-tags/route.ts new file mode 100644 index 0000000..87a059d --- /dev/null +++ b/src/app/api/music/v2/discovery/songlist-tags/route.ts @@ -0,0 +1,27 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import { isMusicSource, lxGetJson } from '@/lib/music-v2'; +import { badRequest, internalError } from '@/lib/music-v2-api'; + +export const runtime = 'nodejs'; + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const source = searchParams.get('source') || 'wy'; + if (!isMusicSource(source)) return badRequest('不支持的音源'); + + const payload = await lxGetJson(`/api/music/songList/tags?source=${source}`, 'none'); + + return NextResponse.json({ + success: true, + data: { + groups: payload?.tags || [], + hotTags: payload?.hotTag || [], + sortList: payload?.sortList || [], + }, + }); + } catch (error) { + return internalError('获取歌单标签失败', (error as Error).message); + } +} diff --git a/src/app/api/music/v2/discovery/songlists/route.ts b/src/app/api/music/v2/discovery/songlists/route.ts new file mode 100644 index 0000000..bef024f --- /dev/null +++ b/src/app/api/music/v2/discovery/songlists/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import { isMusicSource, lxGetJson, unwrapLxArray } from '@/lib/music-v2'; +import { badRequest, internalError } from '@/lib/music-v2-api'; + +export const runtime = 'nodejs'; + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const source = searchParams.get('source') || 'wy'; + const tagId = searchParams.get('tagId') || ''; + const sortId = searchParams.get('sortId') || 'hot'; + const page = Number(searchParams.get('page') || '1'); + + if (!isMusicSource(source)) return badRequest('不支持的音源'); + + const payload = await lxGetJson(`/api/music/songList/list?source=${source}&tagId=${encodeURIComponent(tagId)}&sortId=${encodeURIComponent(sortId)}&page=${page}`, 'none'); + const list = unwrapLxArray(payload); + + return NextResponse.json({ + success: true, + data: { + source, + page, + tagId, + sortId, + total: payload?.total ?? payload?.data?.total ?? list.length, + limit: payload?.limit ?? payload?.data?.limit ?? list.length, + list: list.map((item) => ({ + id: item.id || item.songlistId || item.listId || '', + name: item.name || item.title || '未命名歌单', + pic: item.img || item.cover || item.pic || item.coverImgUrl, + source: item.source || source, + author: item.author || item.creator?.nickname || item.uname || '', + desc: item.desc || item.description || '', + play_count: item.play_count || item.playCount || item.listencnt || item.visitnum || '', + total: item.total || item.trackCount || item.songCount || 0, + updateFrequency: item.updateFrequency || item.update_frequency || item.time || '', + })), + }, + }); + } catch (error) { + return internalError('获取歌单失败', (error as Error).message); + } +} diff --git a/src/app/music/MusicClient.tsx b/src/app/music/MusicClient.tsx index 86d5847..605db54 100644 --- a/src/app/music/MusicClient.tsx +++ b/src/app/music/MusicClient.tsx @@ -1831,14 +1831,14 @@ export default function MusicClient({ children: _children }: { children?: React.
-
+
diff --git a/src/app/music/songlists/[source]/[playlistId]/page.tsx b/src/app/music/songlists/[source]/[playlistId]/page.tsx new file mode 100644 index 0000000..212711e --- /dev/null +++ b/src/app/music/songlists/[source]/[playlistId]/page.tsx @@ -0,0 +1,52 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useParams, useSearchParams } from 'next/navigation'; +import { playMusicList } from '@/lib/music/actions'; +import MusicLoadingIndicator from '@/components/music/MusicLoadingIndicator'; +import SongList from '@/components/music/SongList'; +import { mapSong, normalizeSource } from '@/lib/music/shared'; +import type { Song } from '@/lib/music/types'; + +export default function MusicSongListDetailPage() { + const params = useParams<{ source: string; playlistId: string }>(); + const searchParams = useSearchParams(); + const source = normalizeSource(params.source); + const playlistId = decodeURIComponent(params.playlistId); + const title = searchParams.get('name') || '歌单详情'; + const [songs, setSongs] = useState([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + setLoading(true); + fetch(`/api/music/v2/discovery/songlist-detail?source=${source}&id=${encodeURIComponent(playlistId)}`) + .then((res) => res.json()) + .then((data) => { + if (data.success) setSongs((data.data?.list || []).map(mapSong)); + else setSongs([]); + }) + .catch(() => setSongs([])) + .finally(() => setLoading(false)); + }, [source, playlistId]); + + return loading ? ( + + ) : ( +
+
+
+
{title}
+
推荐歌单详情
+
+ +
+ +
+ ); +} diff --git a/src/app/music/songlists/page.tsx b/src/app/music/songlists/page.tsx new file mode 100644 index 0000000..1c0d6e4 --- /dev/null +++ b/src/app/music/songlists/page.tsx @@ -0,0 +1,403 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import MusicLoadingIndicator from '@/components/music/MusicLoadingIndicator'; +import { musicSources, normalizeSource } from '@/lib/music/shared'; + +interface SongListItem { + id: string; + name: string; + pic?: string; + source: string; + author?: string; + desc?: string; + play_count?: string | number; + total?: number; + updateFrequency?: string; +} + +interface SongListTag { + id: string; + name: string; +} + +interface SongListGroup { + name: string; + list: SongListTag[]; +} + +const sortOptions = [ + { id: 'hot', label: '最热' }, + { id: 'new', label: '最新' }, +]; + +const SONGLIST_CACHE_TTL = 60 * 60 * 1000; + +function readCache(key: string): T | null { + try { + const raw = localStorage.getItem(key); + if (!raw) return null; + const cached = JSON.parse(raw); + if (Date.now() - Number(cached.timestamp || 0) > SONGLIST_CACHE_TTL) return null; + return cached.data as T; + } catch { + return null; + } +} + +function writeCache(key: string, data: unknown) { + try { + localStorage.setItem(key, JSON.stringify({ data, timestamp: Date.now() })); + } catch { + // ignore cache write failure + } +} + +export default function MusicSongListsPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const source = normalizeSource(searchParams.get('source')); + const tagId = searchParams.get('tagId') || ''; + const sortId = searchParams.get('sortId') || 'hot'; + const page = Number(searchParams.get('page') || '1'); + + const [showSourceMenu, setShowSourceMenu] = useState(false); + const [showTagMenu, setShowTagMenu] = useState(false); + const [groups, setGroups] = useState([]); + const [hotTags, setHotTags] = useState([]); + const [songLists, setSongLists] = useState([]); + const [loadingTags, setLoadingTags] = useState(false); + const [loadingList, setLoadingList] = useState(false); + const [total, setTotal] = useState(0); + const [activeTagLabel, setActiveTagLabel] = useState(tagId); + const [activeSource, setActiveSource] = useState(source); + const [activeSortId, setActiveSortId] = useState(sortId); + + const currentSourceLabel = musicSources.find((item) => item.key === activeSource)?.label || '音源'; + + const updateQuery = (next: Record) => { + const params = new URLSearchParams(searchParams.toString()); + Object.entries(next).forEach(([key, value]) => { + if (value === undefined || value === '') params.delete(key); + else params.set(key, String(value)); + }); + if ('source' in next) { + setGroups([]); + setHotTags([]); + setSongLists([]); + setLoadingTags(true); + setLoadingList(true); + } else if ('tagId' in next || 'sortId' in next || 'page' in next) { + setSongLists([]); + setLoadingList(true); + } + router.push(`/music/songlists?${params.toString()}`); + }; + + useEffect(() => { + setActiveTagLabel(tagId); + }, [tagId]); + + useEffect(() => { + setActiveSource(source); + }, [source]); + + useEffect(() => { + setActiveSortId(sortId); + }, [sortId]); + + useEffect(() => { + const cacheKey = `music_songlist_tags_${source}`; + const cached = readCache<{ groups: SongListGroup[]; hotTags: SongListTag[] }>(cacheKey); + setLoadingTags(true); + if (cached) { + setGroups(cached.groups || []); + setHotTags(cached.hotTags || []); + } + + fetch(`/api/music/v2/discovery/songlist-tags?source=${source}`) + .then((res) => res.json()) + .then((data) => { + if (data.success) { + const next = { + groups: data.data?.groups || [], + hotTags: data.data?.hotTags || [], + }; + setGroups(next.groups); + setHotTags(next.hotTags); + writeCache(cacheKey, next); + } else if (!cached) { + setGroups([]); + setHotTags([]); + } + }) + .catch(() => { + if (!cached) { + setGroups([]); + setHotTags([]); + } + }) + .finally(() => setLoadingTags(false)); + }, [source]); + + useEffect(() => { + const cacheKey = `music_songlists_${source}_${tagId}_${sortId}_${page}`; + const cached = readCache<{ list: SongListItem[]; total: number }>(cacheKey); + setLoadingList(true); + if (cached) { + setSongLists(cached.list || []); + setTotal(cached.total || 0); + } + + fetch(`/api/music/v2/discovery/songlists?source=${source}&tagId=${encodeURIComponent(tagId)}&sortId=${encodeURIComponent(sortId)}&page=${page}`) + .then((res) => res.json()) + .then((data) => { + if (data.success) { + const next = { + list: data.data?.list || [], + total: data.data?.total || 0, + }; + setSongLists(next.list); + setTotal(next.total); + writeCache(cacheKey, next); + } else if (!cached) { + setSongLists([]); + setTotal(0); + } + }) + .catch(() => { + if (!cached) { + setSongLists([]); + setTotal(0); + } + }) + .finally(() => setLoadingList(false)); + }, [source, tagId, sortId, page]); + + const openDetail = (item: SongListItem) => { + router.push(`/music/songlists/${item.source}/${encodeURIComponent(item.id)}?name=${encodeURIComponent(item.name)}`); + }; + + const sortButtonClass = (active: boolean) => + `px-4 py-2 rounded-lg text-sm font-medium transition-all whitespace-nowrap flex-shrink-0 ${active ? 'bg-green-500 text-white' : 'bg-white/10 text-black dark:text-white hover:bg-white/20 dark:hover:bg-white/15'}`; + + const flatTags = hotTags.length > 0 ? hotTags : groups.flatMap((group) => group.list || []); + const selectedTagLabel = activeTagLabel || '分类'; + + return ( +
+
+

推荐歌单

+ +
+ + + {showSourceMenu && ( + <> + + ); + })} +
+ + )} +
+
+ +
+
+ {sortOptions.map((item) => ( + + ))} +
+
+ + + {showTagMenu && ( + <> + + ))} +
+
+ )} + + {groups.length > 0 && groups.map((group) => ( +
+
{group.name}
+
+ {group.list.map((tag) => ( + + ))} +
+
+ ))} +
+ + )} +
+
+ + {loadingList ? ( + + ) : songLists.length > 0 ? ( +
+ {songLists.map((item) => ( + + ))} +
+ ) : ( +
+
暂无歌单
+
当前音源暂无推荐歌单数据
+
+ )} + + {total > 0 && ( +
+ + 第 {page} 页 + +
+ )} + + ); +} diff --git a/src/components/music/MusicSidebarDrawer.tsx b/src/components/music/MusicSidebarDrawer.tsx index ac4bd31..c6ad6de 100644 --- a/src/components/music/MusicSidebarDrawer.tsx +++ b/src/components/music/MusicSidebarDrawer.tsx @@ -10,6 +10,7 @@ interface MusicSidebarDrawerProps { const musicNavItems = [ { key: 'rankings', label: '排行榜', href: '/music/rankings', icon: 'M3 4h18M8 8h13M3 12h18M8 16h13M3 20h18' }, + { key: 'songlists', label: '推荐歌单', href: '/music/songlists', icon: 'M4 6h16M4 12h10M4 18h14' }, { key: 'search', label: '搜索', icon: 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z' }, { key: 'my-playlists', label: '我的歌单', href: '/music/my-playlists', icon: 'M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z' }, ]; @@ -29,7 +30,7 @@ export default function MusicSidebarDrawer({ }; return ( -
+