368 lines
20 KiB
HTML
368 lines
20 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport"="width=device-width, initial-scale=1.0">
|
|
<title>Meow Music - 音乐播放器</title>
|
|
<script crossorigin src="https://cdn.jsdelivr.net/npm/react@18/umd/react.production.min.js"></script>
|
|
<script crossorigin src="https://cdn.jsdelivr.net/npm/react-dom@18/umd/react-dom.production.min.js"></script>
|
|
<script src="https://cdn.jsdelivr.net/npm/@babel/standalone@7/babel.min.js"></script>
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
<style>
|
|
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; }
|
|
.gradient-bg { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); }
|
|
.glass { background: rgba(255, 255, 255, 0.1); backdrop-filter: blur(10px); border: 1px solid rgba(255, 255, 255, 0.2); }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="root"></div>
|
|
|
|
<script type="text/babel">
|
|
const { useState, useEffect } = React;
|
|
|
|
// API 配置
|
|
const API_BASE = '';
|
|
|
|
// 本地存储工具
|
|
const storage = {
|
|
getToken: () => localStorage.getItem('token'),
|
|
setToken: (token) => localStorage.setItem('token', token),
|
|
removeToken: () => localStorage.removeItem('token'),
|
|
getUser: () => JSON.parse(localStorage.getItem('user') || 'null'),
|
|
setUser: (user) => localStorage.setItem('user', JSON.stringify(user)),
|
|
removeUser: () => localStorage.removeItem('user')
|
|
};
|
|
|
|
// API 请求工具
|
|
const api = {
|
|
async request(url, options = {}) {
|
|
const token = storage.getToken();
|
|
const headers = {
|
|
'Content-Type': 'application/json',
|
|
...(token && { 'Authorization': `Bearer ${token}` }),
|
|
...options.headers
|
|
};
|
|
|
|
const response = await fetch(API_BASE + url, { ...options, headers });
|
|
if (!response.ok) throw new Error(await response.text());
|
|
return response.json();
|
|
},
|
|
|
|
// 认证
|
|
register: (data) => api.request('/api/auth/register', { method: 'POST', body: JSON.stringify(data) }),
|
|
login: (data) => api.request('/api/auth/login', { method: 'POST', body: JSON.stringify(data) }),
|
|
logout: () => api.request('/api/auth/logout', { method: 'POST' }),
|
|
getCurrentUser: () => api.request('/api/auth/me'),
|
|
|
|
// 音乐搜索
|
|
searchMusic: (song, artist = '') => api.request(`/stream_pcm?song=${encodeURIComponent(song)}&singer=${encodeURIComponent(artist)}`),
|
|
|
|
// 歌单管理
|
|
getPlaylists: () => api.request('/api/user/playlists'),
|
|
createPlaylist: (data) => api.request('/api/user/playlists/create', { method: 'POST', body: JSON.stringify(data) }),
|
|
addToPlaylist: (playlistId, song) => api.request(`/api/user/playlists/add-song?playlist_id=${playlistId}`, { method: 'POST', body: JSON.stringify(song) }),
|
|
removeFromPlaylist: (playlistId, title, artist) => api.request(`/api/user/playlists/remove-song?playlist_id=${playlistId}&title=${encodeURIComponent(title)}&artist=${encodeURIComponent(artist)}`, { method: 'DELETE' }),
|
|
deletePlaylist: (playlistId) => api.request(`/api/user/playlists/delete?playlist_id=${playlistId}`, { method: 'DELETE' })
|
|
};
|
|
|
|
// 登录/注册页面
|
|
function AuthPage({ onLogin }) {
|
|
const [isLogin, setIsLogin] = useState(true);
|
|
const [formData, setFormData] = useState({ username: '', email: '', password: '' });
|
|
const [error, setError] = useState('');
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
const handleSubmit = async (e) => {
|
|
e.preventDefault();
|
|
setError('');
|
|
setLoading(true);
|
|
|
|
try {
|
|
const data = isLogin
|
|
? await api.login({ username: formData.username, password: formData.password })
|
|
: await api.register(formData);
|
|
|
|
storage.setToken(data.token);
|
|
storage.setUser(data.user);
|
|
onLogin(data.user);
|
|
} catch (err) {
|
|
setError(err.message || '操作失败,请重试');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="min-h-screen gradient-bg flex items-center justify-center p-4">
|
|
<div className="glass rounded-2xl p-8 w-full max-w-md text-white">
|
|
<h1 className="text-3xl font-bold mb-6 text-center">🎵 Meow Music</h1>
|
|
|
|
<div className="flex gap-4 mb-6">
|
|
<button onClick={() => setIsLogin(true)} className={`flex-1 py-2 rounded-lg ${isLogin ? 'bg-white text-purple-600' : 'bg-white/20'}`}>
|
|
登录
|
|
</button>
|
|
<button onClick={() => setIsLogin(false)} className={`flex-1 py-2 rounded-lg ${!isLogin ? 'bg-white text-purple-600' : 'bg-white/20'}`}>
|
|
注册
|
|
</button>
|
|
</div>
|
|
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
<input
|
|
type="text"
|
|
placeholder="用户名"
|
|
value={formData.username}
|
|
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
|
|
className="w-full px-4 py-3 rounded-lg bg-white/20 border border-white/30 text-white placeholder-white/60 focus:outline-none focus:ring-2 focus:ring-white/50"
|
|
required
|
|
/>
|
|
|
|
{!isLogin && (
|
|
<input
|
|
type="email"
|
|
placeholder="邮箱"
|
|
value={formData.email}
|
|
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
|
className="w-full px-4 py-3 rounded-lg bg-white/20 border border-white/30 text-white placeholder-white/60 focus:outline-none focus:ring-2 focus:ring-white/50"
|
|
required
|
|
/>
|
|
)}
|
|
|
|
<input
|
|
type="password"
|
|
placeholder="密码"
|
|
value={formData.password}
|
|
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
|
className="w-full px-4 py-3 rounded-lg bg-white/20 border border-white/30 text-white placeholder-white/60 focus:outline-none focus:ring-2 focus:ring-white/50"
|
|
required
|
|
/>
|
|
|
|
{error && <div className="bg-red-500/20 border border-red-500 text-white px-4 py-2 rounded-lg">{error}</div>}
|
|
|
|
<button
|
|
type="submit"
|
|
disabled={loading}
|
|
className="w-full py-3 bg-white text-purple-600 rounded-lg font-semibold hover:bg-white/90 transition disabled:opacity-50"
|
|
>
|
|
{loading ? '处理中...' : (isLogin ? '登录' : '注册')}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 主应用页面
|
|
function MusicApp({ user, onLogout }) {
|
|
const [currentView, setCurrentView] = useState('search');
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [searchResult, setSearchResult] = useState(null);
|
|
const [playlists, setPlaylists] = useState([]);
|
|
const [selectedPlaylist, setSelectedPlaylist] = useState(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [audio, setAudio] = useState(null);
|
|
const [isPlaying, setIsPlaying] = useState(false);
|
|
|
|
useEffect(() => {
|
|
loadPlaylists();
|
|
}, []);
|
|
|
|
const loadPlaylists = async () => {
|
|
try {
|
|
const data = await api.getPlaylists();
|
|
setPlaylists(data || []);
|
|
} catch (err) {
|
|
console.error('加载歌单失败:', err);
|
|
}
|
|
};
|
|
|
|
const handleSearch = async (e) => {
|
|
e.preventDefault();
|
|
if (!searchQuery.trim()) return;
|
|
|
|
setLoading(true);
|
|
try {
|
|
const result = await api.searchMusic(searchQuery);
|
|
setSearchResult(result);
|
|
} catch (err) {
|
|
alert('搜索失败: ' + err.message);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handlePlay = (song) => {
|
|
if (audio) {
|
|
audio.pause();
|
|
}
|
|
const newAudio = new Audio(song.audio_full_url);
|
|
newAudio.play();
|
|
setAudio(newAudio);
|
|
setIsPlaying(true);
|
|
newAudio.onended = () => setIsPlaying(false);
|
|
};
|
|
|
|
const handleAddToPlaylist = async (playlistId, song) => {
|
|
try {
|
|
await api.addToPlaylist(playlistId, song);
|
|
alert('添加成功');
|
|
loadPlaylists();
|
|
} catch (err) {
|
|
alert('添加失败: ' + err.message);
|
|
}
|
|
};
|
|
|
|
const handleCreatePlaylist = async () => {
|
|
const name = prompt('请输入歌单名称:');
|
|
if (!name) return;
|
|
|
|
try {
|
|
await api.createPlaylist({ name, description: '' });
|
|
loadPlaylists();
|
|
} catch (err) {
|
|
alert('创建失败: ' + err.message);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gray-50">
|
|
{/* 顶部导航栏 */}
|
|
<nav className="gradient-bg text-white p-4 shadow-lg">
|
|
<div className="container mx-auto flex justify-between items-center">
|
|
<h1 className="text-2xl font-bold">🎵 Meow Music</h1>
|
|
<div className="flex items-center gap-4">
|
|
<span>欢迎, {user.username}</span>
|
|
<button onClick={onLogout} className="bg-white/20 px-4 py-2 rounded-lg hover:bg-white/30 transition">
|
|
退出
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
|
|
<div className="container mx-auto p-4 flex gap-4">
|
|
{/* 侧边栏 */}
|
|
<div className="w-64 bg-white rounded-lg shadow p-4">
|
|
<button onClick={() => setCurrentView('search')} className={`w-full text-left px-4 py-2 rounded-lg mb-2 ${currentView === 'search' ? 'bg-purple-100 text-purple-600' : 'hover:bg-gray-100'}`}>
|
|
🔍 搜索音乐
|
|
</button>
|
|
|
|
<div className="mt-6">
|
|
<div className="flex justify-between items-center mb-2">
|
|
<h3 className="font-semibold text-gray-700">我的歌单</h3>
|
|
<button onClick={handleCreatePlaylist} className="text-purple-600 hover:text-purple-700">+</button>
|
|
</div>
|
|
|
|
{playlists.map(playlist => (
|
|
<button
|
|
key={playlist.id}
|
|
onClick={() => { setCurrentView('playlist'); setSelectedPlaylist(playlist); }}
|
|
className={`w-full text-left px-4 py-2 rounded-lg mb-1 ${selectedPlaylist?.id === playlist.id ? 'bg-purple-100 text-purple-600' : 'hover:bg-gray-100'}`}
|
|
>
|
|
{playlist.name} ({playlist.songs?.length || 0})
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 主内容区 */}
|
|
<div className="flex-1 bg-white rounded-lg shadow p-6">
|
|
{currentView === 'search' && (
|
|
<div>
|
|
<h2 className="text-2xl font-bold mb-4">搜索音乐</h2>
|
|
|
|
<form onSubmit={handleSearch} className="mb-6">
|
|
<div className="flex gap-2">
|
|
<input
|
|
type="text"
|
|
placeholder="输入歌曲名称..."
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="flex-1 px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-600"
|
|
/>
|
|
<button type="submit" disabled={loading} className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition disabled:opacity-50">
|
|
{loading ? '搜索中...' : '搜索'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
|
|
{searchResult && searchResult.title && (
|
|
<div className="border rounded-lg p-4 bg-gradient-to-r from-purple-50 to-pink-50">
|
|
<div className="flex justify-between items-start">
|
|
<div className="flex-1">
|
|
<h3 className="text-xl font-bold">{searchResult.title}</h3>
|
|
<p className="text-gray-600">👤 {searchResult.artist}</p>
|
|
{searchResult.from_cache && <span className="inline-block mt-2 px-2 py-1 bg-blue-100 text-blue-600 text-sm rounded">缓存</span>}
|
|
</div>
|
|
|
|
<div className="flex gap-2">
|
|
<button onClick={() => handlePlay(searchResult)} className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700">
|
|
▶ 播放
|
|
</button>
|
|
|
|
{playlists.length > 0 && (
|
|
<select onChange={(e) => handleAddToPlaylist(e.target.value, searchResult)} className="px-4 py-2 border rounded-lg">
|
|
<option value="">添加到...</option>
|
|
{playlists.map(p => (
|
|
<option key={p.id} value={p.id}>{p.name}</option>
|
|
))}
|
|
</select>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{currentView === 'playlist' && selectedPlaylist && (
|
|
<div>
|
|
<h2 className="text-2xl font-bold mb-4">{selectedPlaylist.name}</h2>
|
|
<p className="text-gray-600 mb-6">{selectedPlaylist.description || '暂无描述'}</p>
|
|
|
|
{selectedPlaylist.songs && selectedPlaylist.songs.length > 0 ? (
|
|
<div className="space-y-2">
|
|
{selectedPlaylist.songs.map((song, index) => (
|
|
<div key={index} className="border rounded-lg p-4 flex justify-between items-center hover:bg-gray-50">
|
|
<div>
|
|
<h4 className="font-semibold">{song.title}</h4>
|
|
<p className="text-sm text-gray-600">{song.artist}</p>
|
|
</div>
|
|
<button onClick={() => handlePlay(song)} className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700">
|
|
▶ 播放
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="text-gray-500 text-center py-8">暂无歌曲</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 根组件
|
|
function App() {
|
|
const [user, setUser] = useState(storage.getUser());
|
|
|
|
const handleLogin = (userData) => {
|
|
setUser(userData);
|
|
};
|
|
|
|
const handleLogout = () => {
|
|
storage.removeToken();
|
|
storage.removeUser();
|
|
setUser(null);
|
|
};
|
|
|
|
return user ? <MusicApp user={user} onLogout={handleLogout} /> : <AuthPage onLogin={handleLogin} />;
|
|
}
|
|
|
|
// 渲染应用
|
|
ReactDOM.render(<App />, document.getElementById('root'));
|
|
</script>
|
|
</body>
|
|
</html>
|