feat: 管理后台迁移至 FastAPI + Jinja2 服务端渲染

- GET /admin:Jinja 模板渲染(登录页 / 管理台),媒体库、播放列表、设置由服务端渲染
- POST /admin/login:表单登录 + HMAC 签名 Cookie(DPM_ADMIN_SECRET),GET /admin/logout 退出
- /static/admin.css + admin.js:动态操作(上传进度/播放控制/设置/SSE 状态/预览弹窗)调 REST 后整页刷新
- 前端移除 React Admin 页面与 /admin 路由(Admin.jsx 删除);媒体/播放列表接口保持不变
- 依赖改用 uv 管理(pyproject.toml + uv.lock,含 jinja2),README 更新启动方式
This commit is contained in:
Pine
2026-08-17 21:30:44 +08:00
parent 856ff88440
commit 00cb76c73f
12 changed files with 2730 additions and 626 deletions
-619
View File
@@ -1,619 +0,0 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import * as api from '../utils/api';
import { API_BASE } from '../utils/api';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { enable as enableAutostart, disable as disableAutostart } from '@tauri-apps/plugin-autostart';
import '../styles/admin.css';
/* ============ SVG Icons ============ */
const IconPrev = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M11 3L6 8L11 13" stroke="currentColor" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
const IconPlay = () => (
<svg width="18" height="18" viewBox="0 0 18 18" fill="currentColor">
<polygon points="5,3 15,9 5,15" />
</svg>
);
const IconPause = () => (
<svg width="18" height="18" viewBox="0 0 18 18" fill="currentColor">
<rect x="4" y="3" width="3" height="12" rx="1" />
<rect x="11" y="3" width="3" height="12" rx="1" />
</svg>
);
const IconNext = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M5 3L10 8L5 13" stroke="currentColor" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
/* ============ Badge ============ */
function Badge({ type, label }) {
const map = { video: 'badge-video', image: 'badge-image', url: 'badge-url', local: 'badge-local' };
return <span className={`badge ${map[type] || ''}`}>{label || type}</span>;
}
/* ============ Toast Container ============ */
function ToastContainer({ toasts }) {
return (
<div className="toast-container">
{toasts.map(t => (
<div key={t.id} className={`toast ${t.type}`}>{t.msg}</div>
))}
</div>
);
}
/* ============ Preview Modal ============ */
function PreviewModal({ item, onClose, onAddToPlaylist, onDelete }) {
const [closing, setClosing] = useState(false);
const videoRef = useRef(null);
const handleClose = useCallback(() => {
setClosing(true);
if (videoRef.current) {
videoRef.current.pause();
videoRef.current.removeAttribute('src');
}
setTimeout(() => {
onClose();
setClosing(false);
}, 200);
}, [onClose]);
useEffect(() => {
const onKey = (e) => { if (e.key === 'Escape') handleClose(); };
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [handleClose]);
if (!item) return null;
const previewUrl = item.source === 'url' ? item.relative_path : `${API_BASE}/file/${item.relative_path}`;
const overlayClass = `modal-overlay${closing ? ' closing' : ''}`;
return (
<div className={overlayClass} onClick={(e) => { if (e.target.className.includes('modal-overlay')) handleClose(); }}>
<div className="modal-panel" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<span className="modal-title">{item.name}</span>
<button className="modal-close" onClick={handleClose}>&times;</button>
</div>
<div className="modal-body">
{item.type === 'image' ? (
<img src={previewUrl} alt={item.name} />
) : (
<video ref={videoRef} src={previewUrl} controls autoPlay playsInline />
)}
</div>
<div className="modal-footer">
<span className="modal-info">
<Badge type={item.source} label={item.source === 'url' ? '远程' : '本地'} />
<Badge type={item.type} label={item.type === 'video' ? '视频' : '图片'} />
</span>
<div className="modal-actions">
<button className="btn-accent btn-sm" onClick={() => { onAddToPlaylist(item.relative_path); handleClose(); }}>加入播放</button>
<button className="btn-danger btn-sm" onClick={() => { onDelete(item.relative_path); handleClose(); }}>删除</button>
</div>
</div>
</div>
</div>
);
}
/* ============ Media Card ============ */
function MediaCard({ item, onPreview, onAddToPlaylist, onDelete, showRemove, onRemove }) {
const thumbUrl = item.source === 'url' ? item.relative_path : `${API_BASE}/file/${item.relative_path}`;
return (
<div className="media-card">
{item.type === 'image' ? (
<img src={thumbUrl} loading="lazy" className="thumb" alt={item.name} onClick={() => onPreview(item)} />
) : (
<div className="thumb-video-wrap" onClick={() => onPreview(item)}>
<video src={`${thumbUrl}#t=0.5`} preload="auto" muted playsInline className="thumb" />
<span className="play-badge"></span>
</div>
)}
<div className="body">
<span className="name" title={item.name}>{item.name}</span>
<div className="meta-row">
<Badge type={item.source} label={item.source === 'url' ? '远程' : '本地'} />
<Badge type={item.type} label={item.type === 'video' ? '视频' : '图片'} />
</div>
<div className="actions">
{showRemove ? (
<button className="btn-danger btn-sm" onClick={(e) => { e.stopPropagation(); onRemove(item.relative_path); }}>移出</button>
) : (
<>
<button className="btn-accent btn-sm" onClick={(e) => { e.stopPropagation(); onAddToPlaylist(item.relative_path); }}>加入播放</button>
<button className="btn-danger btn-sm" onClick={(e) => { e.stopPropagation(); onDelete(item.relative_path); }}>删除</button>
</>
)}
</div>
</div>
</div>
);
}
/* =========================================================
Admin Page
========================================================= */
export default function Admin() {
// Auth state
const [isLoggedIn, setIsLoggedIn] = useState(() => localStorage.getItem('token') === 'admin_logged');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
// Data state
const [files, setFiles] = useState([]);
const [playlistItems, setPlaylistItems] = useState([]);
const [playMode, setPlayMode] = useState('sequential');
const [imageDuration, setImageDuration] = useState(5);
const [volume, setVolume] = useState(80);
// UI state
const [statusText, setStatusText] = useState('等待大屏连接...');
const [playState, setPlayState] = useState({ status: 'idle' });
const [previewItem, setPreviewItem] = useState(null);
const [toasts, setToasts] = useState([]);
const [uploadProgress, setUploadProgress] = useState(null);
const [fullscreenMode, setFullscreenMode] = useState(true);
const [autostartEnabled, setAutostartEnabled] = useState(false);
const toastIdRef = useRef(0);
const volTimerRef = useRef(null);
// ============ Toast ============
const addToast = useCallback((msg, type = 'success') => {
const id = ++toastIdRef.current;
setToasts(prev => [...prev, { id, msg, type }]);
setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== id));
}, 2800);
}, []);
// ============ Auth ============
const handleLogin = async () => {
const data = await api.login(username, password);
if (data.success) {
localStorage.setItem('token', 'admin_logged');
setIsLoggedIn(true);
} else {
addToast('登录失败,请检查账号密码', 'error');
}
};
const handleKeyDown = (e) => { if (e.key === 'Enter') handleLogin(); };
const handleLogout = () => {
localStorage.removeItem('token');
setIsLoggedIn(false);
};
// ============ Data Loading ============
const loadSettings = useCallback(async () => {
try {
const d = await api.getSettings();
setVolume(d.volume ?? 80);
setPlayMode(d.play_mode || 'sequential');
setImageDuration(d.image_duration || 5);
setFullscreenMode(d.fullscreen ?? true);
setAutostartEnabled(d.autostart ?? false);
} catch { /* ignore */ }
}, []);
const loadFiles = useCallback(async () => {
try {
const data = await api.getMediaFiles();
setFiles(data.files || []);
} catch { /* ignore */ }
}, []);
const loadPlaylist = useCallback(async () => {
try {
const data = await api.getPlaylist();
setPlaylistItems(data.files || []);
setPlayMode(data.play_mode || 'sequential');
setImageDuration(data.image_duration ?? 5);
setVolume(data.volume ?? 80);
} catch { /* ignore */ }
}, []);
const loadAll = useCallback(() => {
loadSettings();
loadFiles();
loadPlaylist();
}, [loadSettings, loadFiles, loadPlaylist]);
useEffect(() => {
if (isLoggedIn) loadAll();
}, [isLoggedIn, loadAll]);
// ============ SSE ============
useEffect(() => {
if (!isLoggedIn) return;
const es = new EventSource(`${API_BASE}/api/events`);
es.onmessage = (e) => {
try {
const msg = JSON.parse(e.data);
if (msg.action === 'state_update' && msg.state) {
setPlayState(msg.state);
const s = msg.state;
if (s.status === 'idle' || !s.name) {
setStatusText('等待大屏连接...');
} else if (s.status === 'playing') {
setStatusText(`正在播放 — ${s.name}`);
} else if (s.status === 'paused') {
setStatusText(`已暂停 — ${s.name}`);
}
}
if (msg.action === 'minimize_window') {
getCurrentWindow().minimize().catch(() => {});
}
if (msg.action === 'playlist_changed') {
loadPlaylist();
}
if (msg.action === 'settings_changed') {
if (msg.volume !== undefined) setVolume(msg.volume);
if (msg.play_mode !== undefined) setPlayMode(msg.play_mode);
if (msg.image_duration !== undefined) setImageDuration(msg.image_duration);
if (msg.fullscreen !== undefined) {
setFullscreenMode(msg.fullscreen);
getCurrentWindow().setFullscreen(msg.fullscreen).catch(() => {});
}
if (msg.autostart !== undefined) {
setAutostartEnabled(msg.autostart);
if (msg.autostart) { enableAutostart().catch(() => {}); } else { disableAutostart().catch(() => {}); }
}
}
} catch { /* ignore */ }
};
return () => es.close();
}, [isLoggedIn, loadPlaylist]);
// ============ Handlers ============
const handleUpload = async () => {
const inp = document.getElementById('fileInput');
const fileList = Array.from(inp.files);
if (fileList.length === 0) { addToast('请选择文件', 'error'); return; }
try {
setUploadProgress({ percent: 0, fileName: fileList[0].name, current: 1, total: fileList.length });
await api.uploadFilesWithProgress(fileList, setUploadProgress);
setUploadProgress(null);
inp.value = '';
addToast('上传完成');
loadAll();
} catch (err) {
setUploadProgress(null);
addToast(err.message || '上传失败', 'error');
}
};
const handleAddUrl = async () => {
const inp = document.getElementById('urlInput');
const url = inp.value.trim();
if (!url) { addToast('请输入 URL', 'error'); return; }
const data = await api.addUrlMedia(url);
inp.value = '';
addToast(data.duplicate ? 'URL 已存在于媒体库' : '已添加到媒体库');
loadAll();
};
const handleAddToPlaylist = async (path) => {
await api.addToPlaylist(path);
addToast('已加入播放列表');
loadAll();
};
const handleRemoveFromPlaylist = async (path) => {
await api.removeFromPlaylist(path);
addToast('已移出播放列表');
loadAll();
};
const handleDeleteFile = async (path) => {
if (!window.confirm('确定删除该文件?')) return;
await api.deleteMedia(path);
addToast('已删除');
loadAll();
};
const handleSendControl = async (action) => {
await api.sendControl(action);
};
// ============ Return to Screen ============
const navigate = useNavigate();
const fromScreen = new URLSearchParams(window.location.search).get('from') === 'screen';
const handleReturnToScreen = () => {
localStorage.removeItem('token');
setIsLoggedIn(false);
navigate('/screen');
};
// ============ Display Controls ============
const handleToggleFullscreen = async () => {
const newMode = !fullscreenMode;
setFullscreenMode(newMode);
try {
await getCurrentWindow().setFullscreen(newMode);
} catch (e) {
console.error('Fullscreen toggle failed:', e);
}
await api.updateSettings({ fullscreen: newMode });
};
const handleMinimize = async () => {
try {
await getCurrentWindow().minimize();
} catch (e) {
console.error('Minimize failed:', e);
}
await api.sendDisplayCommand('minimize');
};
const handleToggleAutostart = async () => {
const newValue = !autostartEnabled;
setAutostartEnabled(newValue);
try {
if (newValue) {
await enableAutostart();
} else {
await disableAutostart();
}
} catch (e) {
console.error('Autostart toggle failed:', e);
}
await api.updateSettings({ autostart: newValue });
};
// ============ Render ============
if (!isLoggedIn) {
return (
<div className="admin-page">
<div className="login-wrapper">
<div className="login-card">
<div className="login-brand">
<div className="icon">
<div className="iso-top"></div>
<div className="iso-left"></div>
<div className="iso-right"></div>
<div className="iso-dot"></div>
</div>
<h2>昆明市大学生创业园</h2>
<p>大屏幕轮播控制系统</p>
</div>
<div className="field">
<label>账号</label>
<input value={username} onChange={e => setUsername(e.target.value)} placeholder="请输入账号" autoComplete="username" onKeyDown={handleKeyDown} />
</div>
<div className="field">
<label>密码</label>
<input type="password" value={password} onChange={e => setPassword(e.target.value)} placeholder="请输入密码" autoComplete="current-password" onKeyDown={handleKeyDown} />
</div>
<button className="btn-login" onClick={handleLogin}> </button>
</div>
</div>
<ToastContainer toasts={toasts} />
</div>
);
}
const isPlaying = playState.status === 'playing';
const isPaused = playState.status === 'paused';
const hasFiles = files.length > 0;
const hasPlaylist = playlistItems.length > 0;
return (
<div className="admin-page">
<div className={`dashboard active`}>
{/* Topbar */}
<div className="topbar">
<div className="topbar-left">
<div className="brand-dot"></div>
<h1>昆明市大学生创业园</h1>
</div>
<div className="topbar-center">
<span className="status-bar-text">{statusText}</span>
</div>
<div className="topbar-right">
<button className="btn-control" onClick={() => handleSendControl('prev')} title="上一个">
<IconPrev />
</button>
<button className={`btn-control play-btn${isPlaying ? ' active' : ''}`} id="btnPlay" onClick={() => handleSendControl('play')} title="播放">
<IconPlay />
</button>
<button className={`btn-control play-btn${isPaused ? ' active' : ''}`} id="btnPause" onClick={() => handleSendControl('pause')} title="暂停">
<IconPause />
</button>
<button className="btn-control" onClick={() => handleSendControl('next')} title="下一个">
<IconNext />
</button>
<span className="topbar-divider"></span>
{fromScreen && (
<button className="btn-return" onClick={handleReturnToScreen}>返回展播</button>
)}
<button className="btn-return" onClick={() => navigate('/')}>数据大屏</button>
<button className="btn-logout" onClick={handleLogout}>退出</button>
</div>
</div>
{/* Main Content */}
<div className="main-grid">
{/* Mobile Playback Controls (hidden on desktop) */}
<div className="card playback-controls-card">
<div className="card-header">
<h3>播放控制</h3>
</div>
<div className="playback-controls-row">
<button className="btn-control" onClick={() => handleSendControl('prev')} title="上一个">
<IconPrev />
</button>
<button className={`btn-control play-btn${isPlaying ? ' active' : ''}`} onClick={() => handleSendControl('play')} title="播放">
<IconPlay />
</button>
<button className={`btn-control play-btn${isPaused ? ' active' : ''}`} onClick={() => handleSendControl('pause')} title="暂停">
<IconPause />
</button>
<button className="btn-control" onClick={() => handleSendControl('next')} title="下一个">
<IconNext />
</button>
</div>
</div>
{/* Settings Card */}
<div className="card">
<div className="card-header">
<div className="card-icon"><svg t="1778605826535" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2889" width="200" height="200"><path d="M564 771.47H171c-16.57 0-30-13.43-30-30V566.78c0-78.12 63.55-141.67 141.67-141.67H564c16.57 0 30 13.43 30 30v286.36c0 16.57-13.43 30-30 30z m-363-60h333V485.11H282.67c-45.03 0-81.67 36.64-81.67 81.67v144.69z" fill="#9BC5ED" p-id="2890"></path><path d="M830.72 212.82l-77.32-44.64c-30.39-17.55-68.38 4.39-68.38 39.48v230.42c-35.84-37.33-86.24-60.58-142.08-60.58-108.76 0-196.93 88.17-196.93 196.93s88.17 196.93 196.93 196.93 196.93-88.17 196.93-196.93c0-1.32-0.02-2.63-0.05-3.95 0.03-0.51 0.05-1.02 0.05-1.53V341.6a46.01 46.01 0 0 0 13.53-5.19l77.32-44.64c30.39-17.55 30.39-61.42 0-78.96z" fill="#1D5DCE" p-id="2891"></path><path d="M630.69 501.22m-30.86 0a30.86 30.86 0 1 0 61.72 0 30.86 30.86 0 1 0-61.72 0Z" fill="#FFFFFF" p-id="2892"></path><path d="M865.63 671.66l-146.65-84.67c-28.18-16.27-63.41 4.07-63.41 36.61v169.34c0 32.54 35.23 52.88 63.41 36.61l146.65-84.67c28.18-16.27 28.18-56.95 0-73.22z" fill="#9BC5ED" p-id="2893"></path></svg></div>
<h3>播放设置</h3>
</div>
<div className="form-inline">
<div className="form-group">
<label>播放模式</label>
<select value={playMode} onChange={e => { const v = e.target.value; setPlayMode(v); api.updateSettings({ play_mode: v }); }}>
<option value="sequential">顺序播放</option>
<option value="random">随机播放</option>
</select>
</div>
<div className="form-group">
<label>图片时长</label>
<input type="number" value={imageDuration} min="1" max="300" onChange={e => { const v = Number(e.target.value); setImageDuration(v); api.updateSettings({ image_duration: v }); }} />
</div>
<div className="form-group">
<label>音量</label>
<input type="range" min="0" max="100" value={volume} onChange={e => { const v = Number(e.target.value); setVolume(v); if (volTimerRef.current) clearTimeout(volTimerRef.current); volTimerRef.current = setTimeout(() => api.updateSettings({ volume: v }), 200); }} />
<div className="volume-label">{volume}%</div>
</div>
</div>
</div>
{/* Display Control Card */}
<div className="card">
<div className="card-header">
<div className="card-icon"><svg t="1778605826535" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2889" width="200" height="200"><path d="M564 771.47H171c-16.57 0-30-13.43-30-30V566.78c0-78.12 63.55-141.67 141.67-141.67H564c16.57 0 30 13.43 30 30v286.36c0 16.57-13.43 30-30 30z m-363-60h333V485.11H282.67c-45.03 0-81.67 36.64-81.67 81.67v144.69z" fill="#9BC5ED" p-id="2890"></path><path d="M830.72 212.82l-77.32-44.64c-30.39-17.55-68.38 4.39-68.38 39.48v230.42c-35.84-37.33-86.24-60.58-142.08-60.58-108.76 0-196.93 88.17-196.93 196.93s88.17 196.93 196.93 196.93 196.93-88.17 196.93-196.93c0-1.32-0.02-2.63-0.05-3.95 0.03-0.51 0.05-1.02 0.05-1.53V341.6a46.01 46.01 0 0 0 13.53-5.19l77.32-44.64c30.39-17.55 30.39-61.42 0-78.96z" fill="#1D5DCE" p-id="2891"></path><path d="M630.69 501.22m-30.86 0a30.86 30.86 0 1 0 61.72 0 30.86 30.86 0 1 0-61.72 0Z" fill="#FFFFFF" p-id="2892"></path><path d="M865.63 671.66l-146.65-84.67c-28.18-16.27-63.41 4.07-63.41 36.61v169.34c0 32.54 35.23 52.88 63.41 36.61l146.65-84.67c28.18-16.27 28.18-56.95 0-73.22z" fill="#9BC5ED" p-id="2893"></path></svg></div>
<h3>显示控制</h3>
</div>
<div className="form-inline">
<div className="form-group">
<label>显示模式</label>
<button className="btn-outline" onClick={handleToggleFullscreen} style={{ width: '100%' }}>
{fullscreenMode ? '切换窗口模式' : '切换大屏模式'}
</button>
</div>
<div className="form-group">
<label>窗口操作</label>
<button className="btn-outline" onClick={handleMinimize} style={{ width: '100%' }}>最小化窗口</button>
</div>
<div className="form-group">
<label>开机自启动</label>
<button className={`btn-outline${autostartEnabled ? ' active' : ''}`} onClick={handleToggleAutostart} style={{ width: '100%' }}>
{autostartEnabled ? '已开启' : '已关闭'}
</button>
</div>
</div>
</div>
{/* Upload Card */}
<div className="card">
<div className="card-header">
<div className="card-icon"><svg t="1778605682023" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2373" width="200" height="200"><path d="M171.47 182.43m102.33 0l479.61 0q102.33 0 102.33 102.33l0 327.32q0 102.33-102.33 102.33l-479.61 0q-102.33 0-102.33-102.33l0-327.32q0-102.33 102.33-102.33Z" fill="#1D5DCE" p-id="2374"></path><path d="M369.83 789.15m24 0l239.55 0q24 0 24 24l0 3.81q0 24-24 24l-239.55 0q-24 0-24-24l0-3.81q0-24 24-24Z" fill="#9BC5ED" p-id="2375"></path><path d="M602.01 409.07l-131.36-75.84c-30.29-17.49-68.16 4.37-68.16 39.35v151.68c0 34.98 37.87 56.84 68.16 39.35l131.36-75.84c30.29-17.49 30.29-61.22 0-78.71z" fill="#FFFFFF" p-id="2376"></path></svg></div>
<h3>添加媒体</h3>
</div>
<div className="form-group">
<label>本地上传</label>
<div className="form-row">
<input type="file" id="fileInput" accept=".mp4,.mkv,.avi,.jpg,.jpeg,.png" multiple />
<button className="btn-accent" onClick={handleUpload} disabled={uploadProgress !== null}>上传</button>
</div>
{uploadProgress && (
<div className="upload-progress">
<div className="progress-bar">
<div className="progress-fill" style={{ width: `${uploadProgress.percent}%` }}></div>
</div>
<span className="progress-text">
{uploadProgress.fileName} ({uploadProgress.current}/{uploadProgress.total}) {uploadProgress.percent}%
</span>
</div>
)}
</div>
<div className="form-group">
<label>远程 URL</label>
<div className="form-row">
<input type="text" id="urlInput" placeholder="https://example.com/media.mp4" />
<button className="btn-accent" onClick={handleAddUrl}>添加</button>
</div>
</div>
</div>
{/* Media Library */}
<div className="card full">
<div className="card-header">
<div className="card-icon"><svg t="1778605713121" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2544" width="200" height="200"><path d="M271.77 238.65m57.41 0l411.19 0q57.41 0 57.41 57.41l0 337.03q0 57.41-57.41 57.41l-411.19 0q-57.41 0-57.41-57.41l0-337.03q0-57.41 57.41-57.41Z" fill="#9BC5ED" p-id="2545"></path><path d="M774.49 824H252.84c-36.18 0-65.51-29.33-65.51-65.51V437.66c0-36.18 29.33-65.51 65.51-65.51h224.04c22.54 0 43.49-11.58 55.47-30.67l68.98-109.82c11.99-19.08 32.94-30.67 55.47-30.67h117.68c36.18 0 65.51 29.33 65.51 65.51v491.98c0 36.18-29.33 65.51-65.51 65.51z" fill="#1D5DCE" p-id="2546"></path><path d="M577.25 672.42m24 0l97.53 0q24 0 24 24l0 11.11q0 24-24 24l-97.53 0q-24 0-24-24l0-11.11q0-24 24-24Z" fill="#FFFFFF" p-id="2547"></path></svg></div>
<h3>媒体库</h3>
</div>
<div className="media-grid">
{!hasFiles ? (
<div className="empty-state">
<div className="empty-icon">{'\uD83D\uDCF7'}</div>
暂无媒体文件<br />请上传或通过 URL 添加
</div>
) : (
files.map((item, i) => (
<MediaCard
key={`file-${i}`}
item={item}
onPreview={setPreviewItem}
onAddToPlaylist={handleAddToPlaylist}
onDelete={handleDeleteFile}
/>
))
)}
</div>
</div>
{/* Playlist */}
<div className="card full">
<div className="card-header">
<div className="card-icon"><svg t="1778605746078" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2715" width="200" height="200"><path d="M279.22 176m86.62 0l311.75 0q86.62 0 86.62 86.62l0 464.37q0 86.62-86.62 86.62l-311.75 0q-86.62 0-86.62-86.62l0-464.37q0-86.62 86.62-86.62Z" fill="#1D5DCE" p-id="2716"></path><path d="M199.22 570.82l-0.08 231.85c0 29.45 23.86 53.33 53.31 53.33h395.81c54.36 0 73.85-71.82 26.95-99.3L279.47 524.85c-35.53-20.82-80.24 4.8-80.25 45.98z" fill="#9BC5ED" p-id="2717"></path><path d="M844.62 570.82l0.08 231.85c0 29.45-23.86 53.33-53.31 53.33H395.58c-54.36 0-73.85-71.82-26.95-99.3l395.74-231.85c35.53-20.82 80.24 4.8 80.25 45.98z" fill="#9BC5ED" p-id="2718"></path><path d="M397.94 308.15m24 0l209.55 0q24 0 24 24l0 3.81q0 24-24 24l-209.55 0q-24 0-24-24l0-3.81q0-24 24-24Z" fill="#FFFFFF" p-id="2719"></path><path d="M397.94 419.15m24 0l129.55 0q24 0 24 24l0 3.81q0 24-24 24l-129.55 0q-24 0-24-24l0-3.81q0-24 24-24Z" fill="#FFFFFF" p-id="2720"></path><path d="M502.13 752.53l-31.42 36.81c-14.42 16.89-2.41 42.91 19.79 42.91h62.85c22.2 0 34.2-26.02 19.79-42.91l-31.42-36.81c-10.39-12.17-29.19-12.17-39.58 0z" fill="#1D5DCE" p-id="2721"></path></svg></div>
<h3>播放列表</h3>
</div>
<div className="media-grid" id="playlistGrid">
{!hasPlaylist ? (
<div className="empty-state">
<div className="empty-icon">{'\u25B6'}</div>
播放列表为空<br />从上方媒体库添加内容
</div>
) : (
playlistItems.map((item, i) => (
<MediaCard
key={`pl-${i}`}
item={item}
onPreview={setPreviewItem}
onAddToPlaylist={handleAddToPlaylist}
onDelete={handleDeleteFile}
showRemove
onRemove={handleRemoveFromPlaylist}
/>
))
)}
</div>
</div>
</div>
</div>
{/* Preview Modal */}
<PreviewModal
item={previewItem}
onClose={() => setPreviewItem(null)}
onAddToPlaylist={handleAddToPlaylist}
onDelete={handleDeleteFile}
/>
{/* Toast */}
<ToastContainer toasts={toasts} />
</div>
);
}