音乐增加歌手和专辑搜索

This commit is contained in:
mtvpls
2026-05-27 16:07:45 +08:00
parent c2f07cec1b
commit 709c5854bb
4 changed files with 446 additions and 35 deletions
@@ -0,0 +1,29 @@
import { NextRequest, NextResponse } from 'next/server';
import { isMusicSource, lxGetJson, LxServerSong, 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 id = searchParams.get('id')?.trim() || '';
const source = searchParams.get('source') || 'wy';
if (!id) return badRequest('缺少专辑 ID');
if (!isMusicSource(source)) return badRequest('不支持的音源');
const payload = await lxGetJson<any>(`/api/music/albumSongs?id=${encodeURIComponent(id)}&source=${source}`, 'none');
const list = unwrapLxArray<LxServerSong>(payload);
return NextResponse.json({
success: true,
data: {
list: list.map(normalizeLxSong),
},
});
} catch (error) {
return internalError('获取专辑歌曲失败', (error as Error).message);
}
}
@@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from 'next/server';
import { isMusicSource, lxGetJson, LxServerSong, 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 id = searchParams.get('id')?.trim() || '';
const source = searchParams.get('source') || 'wy';
const order = searchParams.get('order') || 'hot';
if (!id) return badRequest('缺少歌手 ID');
if (!isMusicSource(source)) return badRequest('不支持的音源');
const payload = await lxGetJson<any>(`/api/music/artistSongs?id=${encodeURIComponent(id)}&source=${source}&order=${encodeURIComponent(order)}`, 'none');
const list = unwrapLxArray<LxServerSong>(payload);
return NextResponse.json({
success: true,
data: {
list: list.map(normalizeLxSong),
},
});
} catch (error) {
return internalError('获取歌手歌曲失败', (error as Error).message);
}
}
+22 -9
View File
@@ -10,23 +10,36 @@ export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const q = searchParams.get('q')?.trim() || '';
const source = searchParams.get('source') || 'kw';
const type = searchParams.get('type') || 'song';
const page = Number(searchParams.get('page') || '1');
const limit = Number(searchParams.get('limit') || '20');
if (!q) return badRequest('缺少搜索关键词');
if (!isMusicSource(source)) return badRequest('不支持的音源');
if (!['song', 'singer', 'album'].includes(type)) return badRequest('不支持的搜索类型');
if ((type === 'singer' || type === 'album') && source !== 'wy' && source !== 'tx') {
return badRequest('当前音源不支持歌手/专辑搜索');
}
const list = await lxGetJson<LxServerSong[]>(`/api/music/search?name=${encodeURIComponent(q)}&source=${source}&page=${page}&limit=${limit}`, 'none');
const list = await lxGetJson<any[]>(`/api/music/search?name=${encodeURIComponent(q)}&source=${source}&type=${type}&page=${page}&limit=${limit}`, 'none');
return NextResponse.json({
success: true,
data: {
list: list.map(normalizeLxSong),
page,
limit,
hasMore: Array.isArray(list) && list.length >= limit,
return NextResponse.json(
{
success: true,
data: {
list: type === 'song' ? (list as LxServerSong[]).map(normalizeLxSong) : list,
type,
page,
limit,
hasMore: Array.isArray(list) && list.length >= limit,
},
},
});
{
headers: {
'Cache-Control': 'public, max-age=3600, s-maxage=3600, stale-while-revalidate=3600',
},
}
);
} catch (error) {
return internalError('搜索歌曲失败', (error as Error).message);
}
+365 -26
View File
@@ -1,6 +1,6 @@
'use client';
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Flame, RefreshCw } from 'lucide-react';
import { playMusicList } from '@/lib/music/actions';
@@ -10,26 +10,140 @@ import { mapSong, musicSources, normalizeSource } from '@/lib/music/shared';
import type { Song } from '@/lib/music/types';
type HotSearchItem = { keyword: string; artist?: string };
type SearchType = 'song' | 'singer' | 'album';
type SingerResult = {
id: string | number;
mid?: string;
name: string;
picUrl?: string;
alias?: string[];
albumSize?: number;
source?: string;
};
type AlbumResult = {
id: string | number;
mid?: string;
name: string;
picUrl?: string;
artistName?: string;
size?: number;
publishTime?: string | number;
source?: string;
};
const HOT_SEARCH_CACHE_DURATION = 60 * 60 * 1000;
const HOT_SEARCH_LIMIT = 20;
const searchTypeOptions: Array<{ key: SearchType; label: string }> = [
{ key: 'song', label: '歌曲' },
{ key: 'singer', label: '歌手' },
{ key: 'album', label: '专辑' },
];
function getHotSearchCacheKey(source: string) {
return `music_hot_search_${source}`;
}
function formatPublishTime(value?: string | number) {
if (!value) return '';
const date = typeof value === 'number' ? new Date(value) : new Date(value);
if (Number.isNaN(date.getTime())) return String(value);
return date.toLocaleDateString();
}
function SingerGrid({ singers, onOpen }: { singers: SingerResult[]; onOpen: (singer: SingerResult) => void }) {
if (singers.length === 0) return <div className="py-16 text-center text-sm text-zinc-500"></div>;
return (
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7">
{singers.map((singer, index) => (
<div
key={`${singer.source || 'wy'}-${singer.id}-${index}`}
onClick={() => onOpen(singer)}
className="group flex cursor-pointer flex-col items-center rounded-2xl border border-transparent p-2 transition-all hover:border-emerald-500/30 hover:bg-white/5 hover:shadow-md md:p-4"
>
<div className="mb-3 h-20 w-20 overflow-hidden rounded-full bg-white/10 shadow-sm sm:h-24 sm:w-24 md:h-32 md:w-32">
{singer.picUrl ? (
<img
src={singer.picUrl}
alt={singer.name}
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-110"
referrerPolicy="no-referrer"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-2xl text-zinc-500"></div>
)}
</div>
<div className="w-full truncate text-center text-sm font-bold text-white" title={singer.name}>{singer.name}</div>
{singer.alias?.[0] && (
<div className="mt-1 w-full truncate text-center text-[10px] text-zinc-500">{singer.alias[0]}</div>
)}
<div className="mt-2 text-[10px] text-zinc-500">{singer.albumSize || 0} </div>
</div>
))}
</div>
);
}
function AlbumGrid({ albums, onOpen }: { albums: AlbumResult[]; onOpen: (album: AlbumResult) => void }) {
if (albums.length === 0) return <div className="py-16 text-center text-sm text-zinc-500"></div>;
return (
<div className="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{albums.map((album, index) => (
<div
key={`${album.source || 'wy'}-${album.id}-${index}`}
onClick={() => onOpen(album)}
className="group flex cursor-pointer flex-col rounded-2xl border border-transparent p-3 transition-all hover:border-emerald-500/20 hover:bg-white/5 hover:shadow-lg"
>
<div className="mb-3 aspect-square overflow-hidden rounded-xl bg-white/10 shadow-md">
{album.picUrl ? (
<img
src={album.picUrl}
alt={album.name}
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
referrerPolicy="no-referrer"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-3xl text-zinc-500"></div>
)}
</div>
<div className="line-clamp-2 h-10 text-sm font-bold leading-5 text-white" title={album.name}>{album.name}</div>
<div className="mt-2 flex items-center justify-between gap-2 text-[10px] text-zinc-500">
<span className="truncate">{album.artistName || '未知歌手'}</span>
<span className="shrink-0">{formatPublishTime(album.publishTime)}</span>
</div>
</div>
))}
</div>
);
}
export default function MusicSearchPage() {
const router = useRouter();
const searchParams = useSearchParams();
const source = normalizeSource(searchParams.get('source'));
const q = searchParams.get('q') || '';
const searchType = (['song', 'singer', 'album'].includes(searchParams.get('type') || '')
? searchParams.get('type')
: 'song') as SearchType;
const [keyword, setKeyword] = useState(q);
const [selectedSource, setSelectedSource] = useState(source);
const [selectedType, setSelectedType] = useState<SearchType>(searchType);
const [songs, setSongs] = useState<Song[]>([]);
const [singers, setSingers] = useState<SingerResult[]>([]);
const [albums, setAlbums] = useState<AlbumResult[]>([]);
const [hotSearches, setHotSearches] = useState<HotSearchItem[]>([]);
const [hotLoading, setHotLoading] = useState(false);
const [loading, setLoading] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(false);
const [showSourceMenu, setShowSourceMenu] = useState(false);
const [showTypeMenu, setShowTypeMenu] = useState(false);
const [detailTitle, setDetailTitle] = useState('');
const loadingMoreRef = useRef(false);
const loadMoreRef = useRef<HTMLDivElement | null>(null);
const userScrolledRef = useRef(false);
const loadHotSearch = async (forceRefresh = false) => {
const cacheKey = getHotSearchCacheKey(source);
@@ -82,41 +196,208 @@ export default function MusicSearchPage() {
}
};
const loadSearchPage = useCallback(async (pageNum: number, append = false, signal?: AbortSignal) => {
if (!q) return;
if (append) {
if (loadingMoreRef.current) return;
loadingMoreRef.current = true;
setLoadingMore(true);
} else {
setLoading(true);
}
try {
const res = await fetch(`/api/music/v2/search?source=${source}&q=${encodeURIComponent(q)}&type=${searchType}&page=${pageNum}&limit=20`, { signal });
const data = await res.json();
const list = data.data?.list || [];
const nextHasMore = Boolean(data.data?.hasMore);
if (searchType === 'singer') {
setSingers((prev) => append ? [...prev, ...list] : list);
if (!append) {
setAlbums([]);
setSongs([]);
}
} else if (searchType === 'album') {
setAlbums((prev) => append ? [...prev, ...list] : list);
if (!append) {
setSingers([]);
setSongs([]);
}
} else {
const nextSongs = list.map(mapSong);
setSongs((prev) => append ? [...prev, ...nextSongs] : nextSongs);
if (!append) {
setSingers([]);
setAlbums([]);
}
}
setPage(pageNum);
setHasMore(nextHasMore);
} catch (error: any) {
if (error?.name !== 'AbortError') {
if (!append) {
setSongs([]);
setSingers([]);
setAlbums([]);
setHasMore(false);
}
}
} finally {
if (append) {
loadingMoreRef.current = false;
setLoadingMore(false);
} else {
setLoading(false);
}
}
}, [source, q, searchType]);
useEffect(() => {
setSelectedSource(source);
setSelectedType(searchType);
setKeyword(q);
void loadHotSearch();
if (!q) {
setSongs([]);
setSingers([]);
setAlbums([]);
setDetailTitle('');
setPage(1);
setHasMore(false);
return;
}
const controller = new AbortController();
setLoading(true);
fetch(`/api/music/v2/search?source=${source}&q=${encodeURIComponent(q)}&page=1&limit=20`, { signal: controller.signal })
.then((res) => res.json())
.then((data) => setSongs((data.data?.list || []).map(mapSong)))
.catch((error) => {
if (error?.name !== 'AbortError') setSongs([]);
})
.finally(() => setLoading(false));
setDetailTitle('');
setPage(1);
void loadSearchPage(1, false, controller.signal);
return () => controller.abort();
}, [source, q]);
}, [source, q, searchType, loadSearchPage]);
useEffect(() => {
userScrolledRef.current = false;
}, [source, q, searchType]);
useEffect(() => {
if (!q || detailTitle || !hasMore) return;
const target = loadMoreRef.current;
if (!target) return;
const markUserScrolled = () => {
userScrolledRef.current = true;
};
const observer = new IntersectionObserver(
(entries) => {
const entry = entries[0];
if (!entry?.isIntersecting) return;
if (!userScrolledRef.current) return;
if (loading || loadingMore || loadingMoreRef.current) return;
void loadSearchPage(page + 1, true);
},
{ root: null, rootMargin: '0px 0px 80px 0px', threshold: 0.1 }
);
window.addEventListener('wheel', markUserScrolled, { passive: true });
window.addEventListener('touchmove', markUserScrolled, { passive: true });
window.addEventListener('scroll', markUserScrolled, { passive: true });
observer.observe(target);
return () => {
observer.disconnect();
window.removeEventListener('wheel', markUserScrolled);
window.removeEventListener('touchmove', markUserScrolled);
window.removeEventListener('scroll', markUserScrolled);
};
}, [q, detailTitle, hasMore, loading, loadingMore, page, loadSearchPage]);
const submit = () => {
const next = keyword.trim();
if (next) router.push(`/music/search?source=${source}&q=${encodeURIComponent(next)}`);
if (next) router.push(`/music/search?source=${selectedSource}&type=${selectedType}&q=${encodeURIComponent(next)}`);
};
const changeSource = (nextSource: string) => {
const normalizedSource = normalizeSource(nextSource);
let normalizedSource = normalizeSource(nextSource);
if ((selectedType === 'singer' || selectedType === 'album') && normalizedSource !== 'wy' && normalizedSource !== 'tx') {
normalizedSource = 'wy';
}
const next = keyword.trim() || q;
setSelectedSource(normalizedSource);
setShowSourceMenu(false);
router.push(`/music/search?source=${normalizedSource}${next ? `&q=${encodeURIComponent(next)}` : ''}`);
router.push(`/music/search?source=${normalizedSource}&type=${selectedType}${next ? `&q=${encodeURIComponent(next)}` : ''}`);
};
const changeType = (nextType: SearchType) => {
let nextSource = selectedSource;
if ((nextType === 'singer' || nextType === 'album') && nextSource !== 'wy' && nextSource !== 'tx') {
nextSource = 'wy';
setSelectedSource(nextSource);
}
const next = keyword.trim() || q;
setSelectedType(nextType);
setShowTypeMenu(false);
if (next) {
setLoading(true);
if (nextType === 'song') {
setSingers([]);
setAlbums([]);
} else if (nextType === 'singer') {
setSongs([]);
setAlbums([]);
} else {
setSongs([]);
setSingers([]);
}
}
router.push(`/music/search?source=${nextSource}&type=${nextType}${next ? `&q=${encodeURIComponent(next)}` : ''}`);
};
const openSinger = async (singer: SingerResult) => {
setDetailTitle(`${singer.name} - 热门歌曲`);
setSingers([]);
setAlbums([]);
setSongs([]);
setHasMore(false);
setLoading(true);
try {
const itemSource = normalizeSource(singer.source || selectedSource);
const res = await fetch(`/api/music/v2/discovery/artist-songs?source=${itemSource}&id=${encodeURIComponent(String(singer.id))}`);
const data = await res.json();
const nextSongs = (data.data?.list || []).map(mapSong);
setSongs(nextSongs);
} catch {
setSongs([]);
} finally {
setLoading(false);
}
};
const openAlbum = async (album: AlbumResult) => {
setDetailTitle(album.name);
setSingers([]);
setAlbums([]);
setSongs([]);
setHasMore(false);
setLoading(true);
try {
const itemSource = normalizeSource(album.source || selectedSource);
const res = await fetch(`/api/music/v2/discovery/album-songs?source=${itemSource}&id=${encodeURIComponent(String(album.id))}`);
const data = await res.json();
const nextSongs = (data.data?.list || []).map(mapSong);
setSongs(nextSongs);
} catch {
setSongs([]);
} finally {
setLoading(false);
}
};
const currentSourceLabel = musicSources.find((item) => item.key === selectedSource)?.label || '音源';
const currentTypeLabel = searchTypeOptions.find((item) => item.key === selectedType)?.label || '歌曲';
const resultCount = selectedType === 'song' ? songs.length : selectedType === 'singer' ? singers.length : albums.length;
const resultUnit = selectedType === 'song' ? '首结果' : selectedType === 'singer' ? '个歌手' : '张专辑';
return (
<div className="animate-in fade-in duration-500 relative z-10">
@@ -182,32 +463,88 @@ export default function MusicSearchPage() {
</>
)}
</div>
<div className="w-px h-5 bg-[rgba(0,0,0,0.1)] dark:bg-[rgba(255,255,255,0.1)] mx-1 shrink-0" />
<div className="relative h-full flex items-center pr-1 shrink-0">
<button
type="button"
onClick={() => setShowTypeMenu((open) => !open)}
className="flex h-[32px] items-center gap-1.5 px-2.5 rounded-[6px] hover:bg-[rgba(0,0,0,0.06)] dark:hover:bg-[rgba(255,255,255,0.08)] transition-all"
aria-haspopup="listbox"
aria-expanded={showTypeMenu}
>
<span className="text-[13px] font-medium text-black dark:text-white">{currentTypeLabel}</span>
<svg className="w-3.5 h-3.5 opacity-50 text-black dark:text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l4-4 4 4m0 6l-4 4-4-4" />
</svg>
</button>
{showTypeMenu && (
<>
<button
type="button"
className="fixed inset-0 z-40 cursor-default"
onClick={() => setShowTypeMenu(false)}
aria-label="关闭类型菜单"
/>
<div className="absolute right-0 top-[calc(100%+6px)] z-50 w-24 p-1.5 overflow-hidden rounded-xl bg-[rgba(255,255,255,0.85)] dark:bg-[rgba(35,35,35,0.85)] backdrop-blur-2xl shadow-[0_8px_30px_rgba(0,0,0,0.12)] border border-[rgba(0,0,0,0.1)] dark:border-[rgba(255,255,255,0.15)] animate-in fade-in zoom-in-95 duration-100 origin-top-right" role="listbox">
{searchTypeOptions.map((item) => {
const active = selectedType === item.key;
return (
<button
key={item.key}
onClick={() => changeType(item.key)}
className="group flex w-full items-center justify-between rounded-md px-2 py-1.5 text-left text-[13px] text-black dark:text-white hover:bg-green-500 hover:text-white transition-none"
role="option"
aria-selected={active}
>
<span className="whitespace-nowrap">{item.label}</span>
{active && (
<svg className="h-3.5 w-3.5 text-black dark:text-white group-hover:text-white shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
)}
</button>
);
})}
</div>
</>
)}
</div>
</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-4 mb-8 border-b border-white/10 pb-4">
<div className="flex items-center gap-4 min-w-0">
<h2 className="text-2xl font-black text-white tracking-tight truncate max-w-md">
{q ? `搜索: ${q}` : '发现音乐'}
{detailTitle || (q ? `搜索: ${q}` : '发现音乐')}
</h2>
{songs.length > 0 && (
{resultCount > 0 && (
<span className="px-2.5 py-1 text-xs font-bold bg-white/10 text-green-500 rounded-full border border-green-500/20 shrink-0">
{songs.length}
{resultCount} {resultUnit}
</span>
)}
</div>
<button
onClick={() => playMusicList(songs, q ? `搜索: ${q}` : '搜索结果')}
disabled={songs.length === 0}
className="flex items-center gap-2 px-4 py-2 bg-white/10 hover:bg-white/20 disabled:bg-white/5 disabled:opacity-50 disabled:cursor-not-allowed rounded-xl text-sm font-medium text-white transition-colors shrink-0"
>
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z" /></svg>
</button>
{(selectedType === 'song' || detailTitle) && (
<button
onClick={() => playMusicList(songs, q ? `搜索: ${q}` : '搜索结果')}
disabled={songs.length === 0}
className="flex items-center gap-2 px-4 py-2 bg-white/10 hover:bg-white/20 disabled:bg-white/5 disabled:opacity-50 disabled:cursor-not-allowed rounded-xl text-sm font-medium text-white transition-colors shrink-0"
>
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z" /></svg>
</button>
)}
</div>
{loading ? (
<MusicLoadingIndicator className="py-16" />
) : q ? (
<SongList songs={songs} />
selectedType === 'singer' ? (
detailTitle ? <SongList songs={songs} /> : <SingerGrid singers={singers} onOpen={openSinger} />
) : selectedType === 'album' ? (
detailTitle ? <SongList songs={songs} /> : <AlbumGrid albums={albums} onOpen={openAlbum} />
) : (
<SongList songs={songs} />
)
) : (
<div className="rounded-3xl border border-white/10 bg-white/5 p-4 md:p-6 backdrop-blur-sm">
<div className="mb-5 flex items-center gap-3">
@@ -226,7 +563,7 @@ export default function MusicSearchPage() {
key={`${item.keyword}-${index}`}
onClick={() => {
setKeyword(item.keyword);
router.push(`/music/search?source=${source}&q=${encodeURIComponent(item.keyword)}`);
router.push(`/music/search?source=${source}&type=${selectedType}&q=${encodeURIComponent(item.keyword)}`);
}}
className="group flex h-14 items-center overflow-hidden rounded-lg border border-white/10 bg-white/5 px-2.5 py-3 text-left shadow-sm transition-all hover:-translate-y-0.5 hover:border-emerald-400 hover:bg-emerald-500/10 hover:shadow-md"
>
@@ -254,6 +591,8 @@ export default function MusicSearchPage() {
</div>
</div>
)}
{loadingMore && <MusicLoadingIndicator className="py-6" />}
{q && !detailTitle && hasMore && <div ref={loadMoreRef} className="h-1" />}
</div>
);
}