音乐增加热搜
This commit is contained in:
@@ -5,25 +5,65 @@ import { badRequest, internalError } from '@/lib/music-v2-api';
|
|||||||
|
|
||||||
export const runtime = 'nodejs';
|
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) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const source = searchParams.get('source') || 'mg';
|
const source = searchParams.get('source') || 'mg';
|
||||||
if (!isMusicSource(source)) return badRequest('不支持的音源');
|
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({
|
for (const candidate of fallbackSources) {
|
||||||
success: true,
|
try {
|
||||||
data: {
|
const payload = await lxGetJson<LxHotSearchPayload>(`/api/music/hotSearch?source=${candidate}`, 'none');
|
||||||
list: list.map(item => ({
|
list = normalizeHotSearchPayload(payload, candidate);
|
||||||
keyword: item.name,
|
if (list.length > 0) break;
|
||||||
name: item.name,
|
} catch {
|
||||||
artist: item.singer || '',
|
continue;
|
||||||
source: item.source,
|
}
|
||||||
})),
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
list,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
{
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': 'public, max-age=3600, s-maxage=3600, stale-while-revalidate=3600',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return internalError('获取热搜失败', (error as Error).message);
|
return internalError('获取热搜失败', (error as Error).message);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,22 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import { Flame, RefreshCw } from 'lucide-react';
|
||||||
import { playMusicList } from '@/lib/music/actions';
|
import { playMusicList } from '@/lib/music/actions';
|
||||||
import MusicLoadingIndicator from '@/components/music/MusicLoadingIndicator';
|
import MusicLoadingIndicator from '@/components/music/MusicLoadingIndicator';
|
||||||
import SongList from '@/components/music/SongList';
|
import SongList from '@/components/music/SongList';
|
||||||
import { mapSong, musicSources, normalizeSource } from '@/lib/music/shared';
|
import { mapSong, musicSources, normalizeSource } from '@/lib/music/shared';
|
||||||
import type { Song } from '@/lib/music/types';
|
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() {
|
export default function MusicSearchPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
@@ -16,12 +26,67 @@ export default function MusicSearchPage() {
|
|||||||
const [keyword, setKeyword] = useState(q);
|
const [keyword, setKeyword] = useState(q);
|
||||||
const [selectedSource, setSelectedSource] = useState(source);
|
const [selectedSource, setSelectedSource] = useState(source);
|
||||||
const [songs, setSongs] = useState<Song[]>([]);
|
const [songs, setSongs] = useState<Song[]>([]);
|
||||||
|
const [hotSearches, setHotSearches] = useState<HotSearchItem[]>([]);
|
||||||
|
const [hotLoading, setHotLoading] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [showSourceMenu, setShowSourceMenu] = 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(() => {
|
useEffect(() => {
|
||||||
setSelectedSource(source);
|
setSelectedSource(source);
|
||||||
setKeyword(q);
|
setKeyword(q);
|
||||||
|
void loadHotSearch();
|
||||||
|
|
||||||
if (!q) {
|
if (!q) {
|
||||||
setSongs([]);
|
setSongs([]);
|
||||||
return;
|
return;
|
||||||
@@ -144,14 +209,49 @@ export default function MusicSearchPage() {
|
|||||||
) : q ? (
|
) : q ? (
|
||||||
<SongList songs={songs} />
|
<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="rounded-3xl border border-white/10 bg-white/5 p-4 md:p-6 backdrop-blur-sm">
|
||||||
<div className="w-20 h-20 mb-6 rounded-full bg-white/5 flex items-center justify-center shadow-inner">
|
<div className="mb-5 flex items-center gap-3">
|
||||||
<svg className="h-10 w-10 text-zinc-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<Flame className="h-6 w-6 shrink-0 text-orange-500" />
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
<div className="min-w-0">
|
||||||
</svg>
|
<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>
|
||||||
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user