音乐增加热搜

This commit is contained in:
mtvpls
2026-05-27 12:38:06 +08:00
parent 84e74231d4
commit acb1d4e7ec
2 changed files with 158 additions and 18 deletions
@@ -5,25 +5,65 @@ import { badRequest, internalError } from '@/lib/music-v2-api';
export const runtime = 'nodejs';
type HotSearchItem = { keyword: string; name: string; artist: string; source: string };
type LxHotSearchPayload =
| string[]
| Array<{ name?: string; keyword?: string; word?: string; singer?: string; source?: string }>
| { source?: string; list?: string[] | Array<{ name?: string; keyword?: string; word?: string; singer?: string; source?: string }> };
function normalizeHotSearchPayload(payload: LxHotSearchPayload, fallbackSource: string): HotSearchItem[] {
const payloadSource = Array.isArray(payload) ? fallbackSource : payload?.source || fallbackSource;
const rawList = Array.isArray(payload) ? payload : payload?.list;
if (!Array.isArray(rawList)) return [];
return rawList
.map((item) => {
if (typeof item === 'string') {
return { keyword: item, name: item, artist: '', source: payloadSource };
}
const keyword = item.name || item.keyword || item.word || '';
return {
keyword,
name: keyword,
artist: item.singer || '',
source: item.source || payloadSource,
};
})
.filter((item) => item.keyword);
}
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const source = searchParams.get('source') || 'mg';
if (!isMusicSource(source)) return badRequest('不支持的音源');
const list = await lxGetJson<Array<{ name: string; singer?: string; source: string }>>(`/api/music/hotSearch?source=${source}`, 'none');
const fallbackSources = [source, 'mg', 'kw', 'tx', 'wy', 'kg'].filter((item, index, arr) => arr.indexOf(item) === index);
let list: HotSearchItem[] = [];
return NextResponse.json({
success: true,
data: {
list: list.map(item => ({
keyword: item.name,
name: item.name,
artist: item.singer || '',
source: item.source,
})),
for (const candidate of fallbackSources) {
try {
const payload = await lxGetJson<LxHotSearchPayload>(`/api/music/hotSearch?source=${candidate}`, 'none');
list = normalizeHotSearchPayload(payload, candidate);
if (list.length > 0) break;
} catch {
continue;
}
}
return NextResponse.json(
{
success: true,
data: {
list,
},
},
});
{
headers: {
'Cache-Control': 'public, max-age=3600, s-maxage=3600, stale-while-revalidate=3600',
},
}
);
} catch (error) {
return internalError('获取热搜失败', (error as Error).message);
}
+107 -7
View File
@@ -2,12 +2,22 @@
import { useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Flame, RefreshCw } from 'lucide-react';
import { playMusicList } from '@/lib/music/actions';
import MusicLoadingIndicator from '@/components/music/MusicLoadingIndicator';
import SongList from '@/components/music/SongList';
import { mapSong, musicSources, normalizeSource } from '@/lib/music/shared';
import type { Song } from '@/lib/music/types';
type HotSearchItem = { keyword: string; artist?: string };
const HOT_SEARCH_CACHE_DURATION = 60 * 60 * 1000;
const HOT_SEARCH_LIMIT = 20;
function getHotSearchCacheKey(source: string) {
return `music_hot_search_${source}`;
}
export default function MusicSearchPage() {
const router = useRouter();
const searchParams = useSearchParams();
@@ -16,12 +26,67 @@ export default function MusicSearchPage() {
const [keyword, setKeyword] = useState(q);
const [selectedSource, setSelectedSource] = useState(source);
const [songs, setSongs] = useState<Song[]>([]);
const [hotSearches, setHotSearches] = useState<HotSearchItem[]>([]);
const [hotLoading, setHotLoading] = useState(false);
const [loading, setLoading] = useState(false);
const [showSourceMenu, setShowSourceMenu] = useState(false);
const loadHotSearch = async (forceRefresh = false) => {
const cacheKey = getHotSearchCacheKey(source);
let cachedData: HotSearchItem[] | null = null;
let cacheExpired = true;
if (!forceRefresh) {
try {
const cached = localStorage.getItem(cacheKey);
if (cached) {
const { data, timestamp } = JSON.parse(cached);
if (Array.isArray(data)) {
cachedData = data;
cacheExpired = Date.now() - Number(timestamp || 0) > HOT_SEARCH_CACHE_DURATION;
}
}
} catch {
cachedData = null;
}
}
if (cachedData) {
setHotSearches(cachedData);
setHotLoading(false);
} else {
setHotSearches([]);
setHotLoading(true);
}
if (!cachedData || cacheExpired || forceRefresh) {
try {
const res = await fetch(`/api/music/v2/discovery/hot-search?source=${source}`);
const data = await res.json();
if (data.success) {
const nextHotSearches = (data.data?.list || []).slice(0, HOT_SEARCH_LIMIT);
setHotSearches(nextHotSearches);
try {
localStorage.setItem(cacheKey, JSON.stringify({ data: nextHotSearches, timestamp: Date.now() }));
} catch {
// ignore cache write failure
}
} else if (!cachedData) {
setHotSearches([]);
}
} catch {
if (!cachedData) setHotSearches([]);
} finally {
setHotLoading(false);
}
}
};
useEffect(() => {
setSelectedSource(source);
setKeyword(q);
void loadHotSearch();
if (!q) {
setSongs([]);
return;
@@ -144,14 +209,49 @@ export default function MusicSearchPage() {
) : q ? (
<SongList songs={songs} />
) : (
<div className="flex flex-col items-center justify-center rounded-3xl border border-dashed border-white/10 bg-white/5 py-20 text-center backdrop-blur-sm">
<div className="w-20 h-20 mb-6 rounded-full bg-white/5 flex items-center justify-center shadow-inner">
<svg className="h-10 w-10 text-zinc-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<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">
<Flame className="h-6 w-6 shrink-0 text-orange-500" />
<div className="min-w-0">
<div className="text-xl font-bold text-white"></div>
<div className="text-sm text-zinc-500">{currentSourceLabel}</div>
</div>
</div>
{hotLoading ? (
<MusicLoadingIndicator className="py-10" />
) : hotSearches.length > 0 ? (
<div className="grid grid-cols-2 gap-2 md:grid-cols-3 lg:grid-cols-4 md:gap-3">
{hotSearches.map((item, index) => (
<button
key={`${item.keyword}-${index}`}
onClick={() => {
setKeyword(item.keyword);
router.push(`/music/search?source=${source}&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"
>
<span className={`mr-3 flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full text-xs font-bold ${index < 3 ? 'bg-gradient-to-r from-orange-400 to-red-500 text-white' : 'bg-gray-100 text-gray-500'}`}>
{index + 1}
</span>
<span className="min-w-0 flex-1 truncate text-sm font-medium text-white group-hover:text-emerald-300">
{item.keyword}
</span>
</button>
))}
</div>
) : (
<div className="py-10 text-center text-sm text-zinc-500"></div>
)}
<div className="mt-6 text-center">
<button
type="button"
onClick={() => void loadHotSearch(true)}
className="text-sm text-zinc-400 transition-colors hover:text-emerald-400"
>
<RefreshCw className="mr-1 inline-block h-4 w-4" />
</button>
</div>
<div className="text-xl font-bold text-zinc-300 mb-2"></div>
<div className="text-sm text-zinc-500 max-w-sm"></div>
</div>
)}
</div>