音乐增加推荐歌单
This commit is contained in:
@@ -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<any>(`/api/music/songList/detail?source=${source}&id=${encodeURIComponent(id)}&page=${page}`, 'none');
|
||||||
|
const list = unwrapLxArray<any>(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<any>(`/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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<any>(`/api/music/songList/list?source=${source}&tagId=${encodeURIComponent(tagId)}&sortId=${encodeURIComponent(sortId)}&page=${page}`, 'none');
|
||||||
|
const list = unwrapLxArray<any>(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1831,14 +1831,14 @@ export default function MusicClient({ children: _children }: { children?: React.
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowSidebarDrawer(true)}
|
onClick={() => setShowSidebarDrawer(true)}
|
||||||
className="w-8 h-8 rounded-full flex items-center justify-center bg-white/10 hover:bg-white/20 text-white transition-colors"
|
className="p-0 text-white transition-colors hover:text-green-400"
|
||||||
title="打开菜单"
|
title="打开菜单"
|
||||||
>
|
>
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6h16M4 12h16M4 18h16" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<div className="w-8 h-8 rounded-full flex items-center justify-center bg-white/10 text-green-500">
|
<div className="flex items-center justify-center text-green-500">
|
||||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
<path d="M18 3a1 1 0 00-1.196-.98l-10 2A1 1 0 006 5v9.114A4.369 4.369 0 005 14c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V7.82l8-1.6v5.894A4.37 4.37 0 0015 12c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V3z" />
|
<path d="M18 3a1 1 0 00-1.196-.98l-10 2A1 1 0 006 5v9.114A4.369 4.369 0 005 14c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V7.82l8-1.6v5.894A4.37 4.37 0 0015 12c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V3z" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
@@ -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<Song[]>([]);
|
||||||
|
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 ? (
|
||||||
|
<MusicLoadingIndicator className="py-8" />
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<div className="mb-6 flex items-center justify-between gap-3 border-b border-white/10 pb-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-2xl font-black text-white tracking-tight truncate">{title}</div>
|
||||||
|
<div className="mt-1 text-sm text-zinc-500">推荐歌单详情</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => playMusicList(songs, title)}
|
||||||
|
disabled={songs.length === 0}
|
||||||
|
className="rounded-xl bg-green-600 px-4 py-2 text-sm text-white disabled:opacity-50"
|
||||||
|
>
|
||||||
|
播放全部
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<SongList songs={songs} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<T>(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<SongListGroup[]>([]);
|
||||||
|
const [hotTags, setHotTags] = useState<SongListTag[]>([]);
|
||||||
|
const [songLists, setSongLists] = useState<SongListItem[]>([]);
|
||||||
|
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<string, string | number | undefined>) => {
|
||||||
|
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 (
|
||||||
|
<div>
|
||||||
|
<div className="mb-8 flex items-center justify-between gap-3 border-b border-[rgba(0,0,0,0.08)] dark:border-[rgba(255,255,255,0.08)] pb-4 relative z-[160]">
|
||||||
|
<h2 className="text-2xl font-bold text-black dark:text-white tracking-tight">推荐歌单</h2>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowSourceMenu((open) => !open)}
|
||||||
|
className="flex items-center gap-1.5 px-2.5 py-1 rounded-[6px] bg-[rgba(0,0,0,0.04)] dark:bg-[rgba(255,255,255,0.08)] hover:bg-[rgba(0,0,0,0.08)] dark:hover:bg-[rgba(255,255,255,0.12)] border border-[rgba(0,0,0,0.04)] dark:border-[rgba(255,255,255,0.04)] shadow-sm transition-all"
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-expanded={showSourceMenu}
|
||||||
|
>
|
||||||
|
<span className="text-[13px] text-[rgba(0,0,0,0.5)] dark:text-[rgba(255,255,255,0.5)]">音源</span>
|
||||||
|
<span className="text-[13px] font-medium text-black dark:text-white">{currentSourceLabel}</span>
|
||||||
|
<svg className="w-3.5 h-3.5 opacity-50 ml-0.5 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>
|
||||||
|
|
||||||
|
{showSourceMenu && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="fixed inset-0 z-[150] cursor-default"
|
||||||
|
onClick={() => setShowSourceMenu(false)}
|
||||||
|
aria-label="关闭菜单"
|
||||||
|
/>
|
||||||
|
<div className="absolute right-0 top-[calc(100%+6px)] z-[160] w-32 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">
|
||||||
|
{musicSources.map((item) => {
|
||||||
|
const active = source === item.key;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.key}
|
||||||
|
onClick={() => {
|
||||||
|
setActiveSource(item.key);
|
||||||
|
setShowSourceMenu(false);
|
||||||
|
updateQuery({ source: item.key, tagId: '', page: 1 });
|
||||||
|
}}
|
||||||
|
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 className="mb-6 flex items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{sortOptions.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => {
|
||||||
|
setActiveSortId(item.id);
|
||||||
|
updateQuery({ sortId: item.id, page: 1 });
|
||||||
|
}}
|
||||||
|
className={sortButtonClass(activeSortId === item.id)}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="relative z-[130]">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowTagMenu(true)}
|
||||||
|
onMouseEnter={() => setShowTagMenu(true)}
|
||||||
|
className="flex items-center gap-1.5 px-1 py-2 text-sm text-black dark:text-white transition-colors hover:text-green-500"
|
||||||
|
>
|
||||||
|
<span>{selectedTagLabel}</span>
|
||||||
|
{tagId && (
|
||||||
|
<span
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setActiveTagLabel('');
|
||||||
|
updateQuery({ tagId: '', page: 1 });
|
||||||
|
}}
|
||||||
|
className="ml-1 text-zinc-400 hover:text-red-500"
|
||||||
|
aria-label="清除分类"
|
||||||
|
role="button"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<svg className="w-3.5 h-3.5 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showTagMenu && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="fixed inset-0 z-[119] cursor-default"
|
||||||
|
onClick={() => setShowTagMenu(false)}
|
||||||
|
aria-label="关闭分类"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute right-0 top-[calc(100%+6px)] z-[130] w-[min(760px,calc(100vw-2rem))] max-h-[70vh] overflow-auto rounded-2xl border border-[rgba(0,0,0,0.1)] dark:border-[rgba(255,255,255,0.15)] bg-[rgba(255,255,255,0.88)] dark:bg-[rgba(35,35,35,0.88)] backdrop-blur-2xl shadow-[0_8px_30px_rgba(0,0,0,0.12)] p-4"
|
||||||
|
onMouseLeave={() => setShowTagMenu(false)}
|
||||||
|
>
|
||||||
|
{loadingTags ? (
|
||||||
|
<MusicLoadingIndicator className="py-8" />
|
||||||
|
) : hotTags.length > 0 && (
|
||||||
|
<div className="mb-5">
|
||||||
|
<div className="mb-3 text-xs font-bold uppercase tracking-wider text-zinc-500">热门标签</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{hotTags.map((tag) => (
|
||||||
|
<button
|
||||||
|
key={tag.id}
|
||||||
|
onClick={() => {
|
||||||
|
setActiveTagLabel(tag.name);
|
||||||
|
setShowTagMenu(false);
|
||||||
|
updateQuery({ tagId: tag.name, page: 1 });
|
||||||
|
}}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-sm transition-all ${tagId === tag.name ? 'bg-green-500 text-white' : 'bg-white text-black hover:bg-zinc-100 dark:bg-zinc-800 dark:text-white dark:hover:bg-zinc-700'}`}
|
||||||
|
>
|
||||||
|
{tag.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{groups.length > 0 && groups.map((group) => (
|
||||||
|
<div key={group.name} className="mb-5 last:mb-0">
|
||||||
|
<div className="mb-3 text-xs font-bold uppercase tracking-wider text-zinc-500">{group.name}</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{group.list.map((tag) => (
|
||||||
|
<button
|
||||||
|
key={tag.id}
|
||||||
|
onClick={() => {
|
||||||
|
setActiveTagLabel(tag.name);
|
||||||
|
setShowTagMenu(false);
|
||||||
|
updateQuery({ tagId: tag.name, page: 1 });
|
||||||
|
}}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-sm transition-all ${tagId === tag.name ? 'bg-green-500 text-white' : 'bg-white text-black hover:bg-zinc-100 dark:bg-zinc-800 dark:text-white dark:hover:bg-zinc-700'}`}
|
||||||
|
>
|
||||||
|
{tag.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loadingList ? (
|
||||||
|
<MusicLoadingIndicator className="py-12" />
|
||||||
|
) : songLists.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||||
|
{songLists.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => openDetail(item)}
|
||||||
|
className="group overflow-hidden rounded-2xl border border-white/5 bg-white/5 text-left transition-all hover:-translate-y-0.5 hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<div className="aspect-square bg-white/5">
|
||||||
|
{item.pic ? (
|
||||||
|
<img src={item.pic} alt={item.name} className="h-full w-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full w-full items-center justify-center text-zinc-600">♪</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="p-3">
|
||||||
|
<div className="truncate text-sm font-medium text-white">{item.name}</div>
|
||||||
|
<div className="mt-1 flex items-center justify-between text-xs text-zinc-500">
|
||||||
|
<span>{item.author || '未知作者'}</span>
|
||||||
|
<span>{item.total ? `${item.total} 首` : ''}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center rounded-2xl border border-dashed border-white/10 bg-white/5 p-12 text-center text-zinc-400">
|
||||||
|
<div className="text-lg font-medium text-zinc-300 mb-1">暂无歌单</div>
|
||||||
|
<div className="text-sm text-zinc-500">当前音源暂无推荐歌单数据</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{total > 0 && (
|
||||||
|
<div className="mt-8 flex items-center justify-center gap-3">
|
||||||
|
<button
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => updateQuery({ page: page - 1 })}
|
||||||
|
className="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-white disabled:opacity-40"
|
||||||
|
>
|
||||||
|
上一页
|
||||||
|
</button>
|
||||||
|
<span className="text-sm text-zinc-500">第 {page} 页</span>
|
||||||
|
<button
|
||||||
|
onClick={() => updateQuery({ page: page + 1 })}
|
||||||
|
className="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-white"
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ interface MusicSidebarDrawerProps {
|
|||||||
|
|
||||||
const musicNavItems = [
|
const musicNavItems = [
|
||||||
{ key: 'rankings', label: '排行榜', href: '/music/rankings', icon: 'M3 4h18M8 8h13M3 12h18M8 16h13M3 20h18' },
|
{ 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: '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' },
|
{ 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 (
|
return (
|
||||||
<div className="fixed inset-0 z-[95]">
|
<div className="fixed inset-0 z-[10000]">
|
||||||
<button
|
<button
|
||||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm animate-in fade-in duration-300"
|
className="absolute inset-0 bg-black/60 backdrop-blur-sm animate-in fade-in duration-300"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
|
|||||||
Reference in New Issue
Block a user