优化音乐界面

This commit is contained in:
mtvpls
2026-05-26 20:42:27 +08:00
rodzic 2448b1660e
commit 41b8e1cce6
3 zmienionych plików z 102 dodań i 126 usunięć
+4 -107
Wyświetl plik
@@ -1884,20 +1884,14 @@ export default function MusicClient({ children: _children }: { children?: React.
}
if (pathname === '/music/search') {
const source = normalizeSource(searchParams.get('source') || currentSource);
const keyword = searchParams.get('q') || '';
setCurrentSource(source);
setCurrentView('search');
setCurrentPlaylistTitle(keyword ? `搜索: ${keyword}` : '搜索');
setSearchKeyword(keyword);
if (keyword) {
void searchSongs(keyword, false);
} else {
setSongs([]);
setActiveSearchKeyword('');
setSearchPage(1);
setSearchHasMore(false);
}
setSongs([]);
setActiveSearchKeyword('');
setSearchPage(1);
setSearchHasMore(false);
return;
}
@@ -2627,48 +2621,6 @@ export default function MusicClient({ children: _children }: { children?: React.
</div>
<span className="font-bold text-lg text-white"></span>
</div>
<div className="md:hidden relative">
<button
onClick={() => setShowSourceMenu(true)}
className="relative h-10 min-w-[132px] rounded-full border border-white/10 bg-gradient-to-r from-white/8 to-white/4 shadow-[inset_0_1px_0_rgba(255,255,255,0.08)] px-3"
>
<div className="absolute inset-0 flex items-center justify-between px-3">
<div className="flex items-center gap-2 min-w-0">
<div className="w-6 h-6 rounded-full bg-green-500/15 text-green-400 flex items-center justify-center shrink-0">
<svg className="w-3.5 h-3.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" />
</svg>
</div>
<div className="min-w-0">
<div className="text-[9px] uppercase tracking-[0.18em] text-zinc-500 leading-none"></div>
<div className="text-sm font-medium text-white leading-tight truncate">
{musicSources.find((source) => source.key === currentSource)?.label || '酷我'}
</div>
</div>
</div>
<div className="w-7 h-7 rounded-full bg-white/6 border border-white/8 flex items-center justify-center text-zinc-300 shrink-0">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
</button>
</div>
<div className="hidden md:flex flex-wrap bg-white/5 rounded-lg p-1 gap-1 border border-white/5">
{musicSources.map((source) => (
<button
key={source.key}
onClick={() => switchSource(source.key)}
className={`px-3 py-1 md:px-4 rounded text-[10px] font-bold tracking-wider transition-all ${
currentSource === source.key
? 'bg-green-500 text-white border border-white/30 shadow-lg shadow-green-500/50'
: 'text-zinc-400 border border-transparent'
}`}
>
{source.label}
</button>
))}
</div>
</div>
</div>
</header>
@@ -3530,61 +3482,6 @@ export default function MusicClient({ children: _children }: { children?: React.
</div>
)}
{showSourceMenu && (
<div className="md:hidden fixed inset-0 z-[90]">
<button
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={() => setShowSourceMenu(false)}
aria-label="关闭音源菜单"
/>
<div className="absolute inset-x-0 bottom-0 rounded-t-3xl border-t border-white/10 bg-zinc-950/98 px-4 pb-6 pt-4 shadow-2xl">
<div className="mx-auto mb-4 h-1.5 w-12 rounded-full bg-white/15" />
<div className="mb-3 px-1 text-sm font-medium text-white"></div>
<div className="space-y-2">
{musicSources.map((source) => {
const active = currentSource === source.key;
return (
<button
key={source.key}
onClick={() => {
setShowSourceMenu(false);
if (!active) switchSource(source.key);
}}
className={`flex w-full items-center justify-between rounded-2xl border px-4 py-3 text-left transition-all ${
active
? 'border-green-500/50 bg-green-500/12 text-white'
: 'border-white/8 bg-white/5 text-zinc-200'
}`}
>
<div className="flex items-center gap-3">
<div className={`flex h-9 w-9 items-center justify-center rounded-full ${active ? 'bg-green-500/20 text-green-400' : 'bg-white/8 text-zinc-400'}`}>
<svg className="w-4 h-4" 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" />
</svg>
</div>
<div className="text-base font-medium">{source.label}</div>
</div>
{active ? (
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-green-500 text-white">
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="3" d="M5 13l4 4L19 7" />
</svg>
</div>
) : (
<div className="text-zinc-500">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 5l7 7-7 7" />
</svg>
</div>
)}
</button>
);
})}
</div>
</div>
</div>
)}
{/* Add to Playlist Modal */}
<AddToPlaylistModal
song={songToAddToPlaylist}
+20 -13
Wyświetl plik
@@ -36,19 +36,26 @@ export default function MusicRankingsPage() {
return (
<div>
<div className="mb-5 hidden flex-wrap gap-2 md:flex">
{musicSources.map((source) => (
<button
key={source.key}
onClick={() => router.push(`/music/rankings?source=${source.key}`)}
className={`rounded-lg border px-3 py-1.5 text-xs font-bold ${currentSource === source.key ? 'border-green-500 bg-green-500 text-white' : 'border-white/10 bg-white/5 text-zinc-400'}`}
>
{source.label}
</button>
))}
</div>
<div className="flex items-center justify-between mb-6 border-b border-white/5 pb-2">
<h2 className="text-xs font-mono text-white/50 tracking-widest"></h2>
<div className="mb-6 flex items-center justify-between gap-3 border-b border-white/5 pb-3">
<h2 className="shrink-0 text-xs font-mono text-white/50 tracking-widest"></h2>
<div className="flex max-w-[calc(100vw-7rem)] justify-end overflow-x-auto rounded-full border border-white/10 bg-white/[0.04] p-1 shadow-inner shadow-black/20 md:max-w-none md:overflow-visible">
{musicSources.map((source) => {
const active = currentSource === source.key;
return (
<button
key={source.key}
onClick={() => router.push(`/music/rankings?source=${source.key}`)}
className={`shrink-0 rounded-full px-3 py-1.5 text-xs font-bold transition-all ${
active
? 'bg-gradient-to-r from-green-500 to-emerald-400 text-white shadow-lg shadow-green-500/25'
: 'text-zinc-400 hover:bg-white/10 hover:text-white'
}`}
>
{source.label}
</button>
);
})}
</div>
</div>
{loading ? <MusicLoadingIndicator className="py-8" /> : playlists.length > 0 ? (
<div className="space-y-2">
+78 -6
Wyświetl plik
@@ -5,7 +5,7 @@ import { useRouter, useSearchParams } from 'next/navigation';
import { playMusicList } from '@/lib/music/actions';
import { MusicLoadingIndicator, type Song } from '../MusicClient';
import SongList from '../SongList';
import { mapSong, normalizeSource } from '@/lib/music/shared';
import { mapSong, musicSources, normalizeSource } from '@/lib/music/shared';
export default function MusicSearchPage() {
const router = useRouter();
@@ -13,21 +13,28 @@ export default function MusicSearchPage() {
const source = normalizeSource(searchParams.get('source'));
const q = searchParams.get('q') || '';
const [keyword, setKeyword] = useState(q);
const [selectedSource, setSelectedSource] = useState(source);
const [songs, setSongs] = useState<Song[]>([]);
const [loading, setLoading] = useState(false);
const [showSourceMenu, setShowSourceMenu] = useState(false);
useEffect(() => {
setSelectedSource(source);
setKeyword(q);
if (!q) {
setSongs([]);
return;
}
const controller = new AbortController();
setLoading(true);
fetch(`/api/music/v2/search?source=${source}&q=${encodeURIComponent(q)}&page=1&limit=20`)
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(() => setSongs([]))
.catch((error) => {
if (error?.name !== 'AbortError') setSongs([]);
})
.finally(() => setLoading(false));
return () => controller.abort();
}, [source, q]);
const submit = () => {
@@ -35,16 +42,81 @@ export default function MusicSearchPage() {
if (next) router.push(`/music/search?source=${source}&q=${encodeURIComponent(next)}`);
};
const changeSource = (nextSource: string) => {
const normalizedSource = normalizeSource(nextSource);
const next = keyword.trim() || q;
setSelectedSource(normalizedSource);
setShowSourceMenu(false);
router.push(`/music/search?source=${normalizedSource}${next ? `&q=${encodeURIComponent(next)}` : ''}`);
};
const currentSourceLabel = musicSources.find((item) => item.key === selectedSource)?.label || '音源';
return (
<div>
<div className="mb-6 flex items-center gap-2">
<div className="relative h-11 flex-1 rounded-xl bg-white/10">
<div className="relative h-11 flex-1 rounded-xl border border-white/10 bg-white/10">
<div className="pointer-events-none absolute inset-y-0 left-3 flex items-center">
<svg className="h-4 w-4 text-zinc-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
</div>
<input value={keyword} onChange={(e) => setKeyword(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && submit()} className="h-full w-full border-0 bg-transparent pl-10 pr-4 text-sm text-white outline-none placeholder:text-zinc-500" placeholder="搜索歌曲或艺术家..." />
<input value={keyword} onChange={(e) => setKeyword(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && submit()} className="h-full w-full border-0 bg-transparent pl-10 pr-28 text-sm text-white outline-none placeholder:text-zinc-500" placeholder="搜索歌曲或艺术家..." />
<div className="absolute inset-y-0 right-2 flex items-center">
<div className="relative h-8">
<button
type="button"
onClick={() => setShowSourceMenu((open) => !open)}
className="flex h-8 min-w-[84px] items-center justify-between gap-1 rounded-lg border border-white/10 bg-zinc-900/90 pl-3 pr-2 text-xs font-medium text-white shadow-inner shadow-white/5 transition-colors hover:bg-zinc-800"
aria-haspopup="listbox"
aria-expanded={showSourceMenu}
aria-label="选择音源"
>
<span>{currentSourceLabel}</span>
<svg className={`h-3.5 w-3.5 text-zinc-400 transition-transform ${showSourceMenu ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
{showSourceMenu && (
<>
<button
type="button"
className="fixed inset-0 z-40 cursor-default"
onClick={() => setShowSourceMenu(false)}
aria-label="关闭音源菜单"
/>
<div className="absolute right-0 top-10 z-50 w-32 overflow-hidden rounded-xl border border-white/10 bg-zinc-900/95 p-1 shadow-2xl shadow-black/50 backdrop-blur-md" role="listbox">
{musicSources.map((item) => {
const active = item.key === selectedSource;
return (
<button
key={item.key}
type="button"
onClick={() => changeSource(item.key)}
className={`flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-xs transition-colors ${
active ? 'bg-green-500/20 text-green-300' : 'text-zinc-300 hover:bg-white/10 hover:text-white'
}`}
role="option"
aria-selected={active}
>
<span>{item.label}</span>
{active && (
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M5 13l4 4L19 7" />
</svg>
)}
</button>
);
})}
</div>
</>
)}
</div>
</div>
</div>
<button onClick={submit} className="h-11 rounded-xl bg-green-600 px-5 text-sm font-medium text-white hover:bg-green-700"></button>
<button onClick={submit} className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-green-600 text-white hover:bg-green-700" aria-label="搜索" title="搜索">
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</button>
</div>
<div className="flex items-center justify-between mb-6 border-b border-white/5 pb-2">
<div className="flex items-center gap-3 min-w-0">