音乐增加歌手和专辑搜索
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user