Files
MeowMusicServer/theme/music-app.html
2025-12-09 16:33:44 +08:00

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>