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
+3
View File
@@ -16,6 +16,9 @@ DPM_MQTT_PASSWORD=123456
DPM_MQTT_WS=ws://192.168.1.3:8083/mqtt DPM_MQTT_WS=ws://192.168.1.3:8083/mqtt
DPM_MQTT_TICK_INTERVAL=2.2 DPM_MQTT_TICK_INTERVAL=2.2
# 管理后台 Cookie 签名密钥
DPM_ADMIN_SECRET=dpm-admin-secret-change-me
# 阿里云 DashScopeLLM + 语音识别) # 阿里云 DashScopeLLM + 语音识别)
DASHSCOPE_API_KEY=sk-xxx DASHSCOPE_API_KEY=sk-xxx
DPM_LLM_MODEL=qwen-plus DPM_LLM_MODEL=qwen-plus
+3 -4
View File
@@ -24,10 +24,9 @@
```bash ```bash
cd backend cd backend
python3 -m venv .venv uv sync # 安装依赖(fastapi/uvicorn/paho-mqtt/dashscope/jinja2 等)
.venv/bin/pip install -r requirements.txt cp .env.example .env # 按需修改(含 DashScope Key、MQTT 账号)
cp .env.example .env # 按需修改(含 DashScope Key、MQTT 账号) uv run python main.py # 或 .venv/bin/python main.py
.venv/bin/python main.py
``` ```
启动后:API 与前端页面均在 `http://0.0.0.0:10085`(浏览器直接打开即大屏)。 启动后:API 与前端页面均在 `http://0.0.0.0:10085`(浏览器直接打开即大屏)。
+91
View File
@@ -0,0 +1,91 @@
# -*- coding: utf-8 -*-
"""管理后台页面(FastAPI + Jinja2 服务端渲染)
GET /admin → 登录页 或 管理台(按 Cookie 鉴权)
POST /admin/login → 表单登录,成功写入签名 Cookie
GET /admin/logout → 清除 Cookie
媒体库 / 播放列表 / 设置均由 Jinja 服务端渲染,动态操作用少量 JS 调 REST
"""
import hashlib
import hmac
import logging
from fastapi import Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from pathlib import Path
from .config import settings
from .storage import storage
from .routers import _list_media, _playlist_files
log = logging.getLogger("dpm.admin")
TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "templates"
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
# ---------------- 签名 Cookie ----------------
def _admin_token():
return hmac.new(settings.ADMIN_SECRET.encode(), b"dpm-admin", hashlib.sha256).hexdigest()
def _verify_admin_cookie(cookie):
return bool(cookie) and hmac.compare_digest(cookie, _admin_token())
def _set_admin_cookie(resp):
resp.set_cookie("dpm_admin", _admin_token(), max_age=7 * 86400, httponly=True, samesite="lax")
return resp
def _clear_admin_cookie(resp):
resp.delete_cookie("dpm_admin")
return resp
# ---------------- 路由 ----------------
async def admin_page(request: Request):
if not _verify_admin_cookie(request.cookies.get("dpm_admin")):
return templates.TemplateResponse(
request=request,
name="admin.html",
context={
"logged_in": False,
"error": request.query_params.get("error") == "1",
},
)
files = _list_media()["files"]
playlist = _playlist_files()
s = storage.get_settings()
return templates.TemplateResponse(
request=request,
name="admin.html",
context={
"logged_in": True,
"error": False,
"files": files,
"playlist": playlist["files"],
"play_mode": s.get("play_mode", "sequential"),
"image_duration": s.get("image_duration", 5),
"volume": s.get("volume", 80),
"fullscreen": s.get("fullscreen", True),
"autostart": s.get("autostart", False),
"from_screen": request.query_params.get("from") == "screen",
},
)
async def admin_login(request: Request):
form = await request.form()
s = storage.get_settings()
if form.get("username") == s.get("username") and form.get("password") == s.get("password"):
return _set_admin_cookie(RedirectResponse("/admin", status_code=303))
return RedirectResponse("/admin?error=1", status_code=303)
async def admin_logout():
return _clear_admin_cookie(RedirectResponse("/admin", status_code=303))
+3
View File
@@ -65,5 +65,8 @@ class Settings:
LLM_BASE_URL = _env("DPM_LLM_BASE_URL", "https://dashscope.aliyuncs.com/compatible-mode/v1") LLM_BASE_URL = _env("DPM_LLM_BASE_URL", "https://dashscope.aliyuncs.com/compatible-mode/v1")
ASR_TIMEOUT = float(_env("DPM_ASR_TIMEOUT", "30")) ASR_TIMEOUT = float(_env("DPM_ASR_TIMEOUT", "30"))
# ---- 管理后台 ----
ADMIN_SECRET = _env("DPM_ADMIN_SECRET", "dpm-admin-secret-change-me")
settings = Settings() settings = Settings()
+10
View File
@@ -16,6 +16,7 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from .admin import admin_login, admin_logout, admin_page
from .config import settings from .config import settings
from .event_bus import bus from .event_bus import bus
from .mqtt import hub from .mqtt import hub
@@ -74,6 +75,15 @@ app.include_router(router)
settings.MEDIA_DIR.mkdir(parents=True, exist_ok=True) settings.MEDIA_DIR.mkdir(parents=True, exist_ok=True)
app.mount("/file", StaticFiles(directory=str(settings.MEDIA_DIR)), name="media") app.mount("/file", StaticFiles(directory=str(settings.MEDIA_DIR)), name="media")
# 管理后台静态资源(admin.css / admin.js
_STATIC_DIR = Path(__file__).resolve().parent.parent / "static"
app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static")
# 管理后台(FastAPI + Jinja2 服务端渲染)—— 必须在 SPA 回退之前注册
app.add_api_route("/admin", admin_page, methods=["GET"], include_in_schema=False)
app.add_api_route("/admin/login", admin_login, methods=["POST"], include_in_schema=False)
app.add_api_route("/admin/logout", admin_logout, methods=["GET"], include_in_schema=False)
# ---------- 前端静态托管 + SPA 回退(浏览器直接访问 :8000 即可) ---------- # ---------- 前端静态托管 + SPA 回退(浏览器直接访问 :8000 即可) ----------
+1
View File
@@ -11,4 +11,5 @@ dependencies = [
"python-multipart>=0.0.9", "python-multipart>=0.0.9",
"uvicorn[standard]>=0.29", "uvicorn[standard]>=0.29",
"dashscope>=1.26", "dashscope>=1.26",
"jinja2>=3.1",
] ]
File diff suppressed because it is too large Load Diff
+225
View File
@@ -0,0 +1,225 @@
/* =========================================================
Admin 管理后台前端逻辑(Jinja 页面配套)
动态操作调用 FastAPI REST,完成后整页刷新(服务端重渲染)
========================================================= */
(function () {
'use strict';
var init = window.__DPM_INIT || { fullscreen: true, autostart: false };
var fullscreen = !!init.fullscreen;
var autostart = !!init.autostart;
/* ---------- Toast ---------- */
var toastContainer = document.getElementById('toastContainer');
function toast(msg, type) {
if (!toastContainer) return;
var el = document.createElement('div');
el.className = 'toast ' + (type || 'success');
el.textContent = msg;
toastContainer.appendChild(el);
setTimeout(function () { el.remove(); }, 2800);
}
/* ---------- 通用请求 ---------- */
function post(url, body, cb) {
fetch(url, {
method: 'POST',
headers: body ? { 'Content-Type': 'application/json' } : undefined,
body: body ? JSON.stringify(body) : undefined,
})
.then(function (r) { return r.json().catch(function () { return {}; }); })
.then(function (d) {
if (d && d.ok === false) { toast(d.error || '操作失败', 'error'); return; }
if (cb) cb(d);
})
.catch(function () { toast('网络错误', 'error'); });
}
function reload(msg) {
if (msg) toast(msg);
setTimeout(function () { location.reload(); }, 350);
}
/* ---------- 播放状态(SSE ---------- */
var statusText = document.getElementById('statusText');
try {
var es = new EventSource('/api/events');
es.onmessage = function (e) {
var msg; try { msg = JSON.parse(e.data); } catch (err) { return; }
if (msg.action === 'state_update' && msg.state && statusText) {
var s = msg.state;
if (!s.status || s.status === 'idle' || !s.name) statusText.textContent = '等待大屏连接...';
else if (s.status === 'playing') statusText.textContent = '正在播放 — ' + s.name;
else if (s.status === 'paused') statusText.textContent = '已暂停 — ' + s.name;
}
if (msg.action === 'playlist_changed') reload();
};
} catch (err) { /* ignore */ }
/* ---------- 播放控制 ---------- */
document.querySelectorAll('[data-control]').forEach(function (btn) {
btn.addEventListener('click', function () {
var action = btn.getAttribute('data-control');
if (action === 'minimize') {
post('/api/display/command', { action: 'minimize', params: {} });
} else {
post('/api/control', { action: action });
}
});
});
/* ---------- 播放设置 ---------- */
var playMode = document.getElementById('playMode');
if (playMode) playMode.addEventListener('change', function () {
post('/api/settings', { play_mode: playMode.value }, function () { toast('播放模式已更新'); });
});
var imageDuration = document.getElementById('imageDuration');
if (imageDuration) imageDuration.addEventListener('change', function () {
var v = Number(imageDuration.value);
if (v < 1 || v > 300) { toast('时长需在 1-300 秒', 'error'); imageDuration.value = 5; return; }
post('/api/settings', { image_duration: v }, function () { toast('图片时长已更新'); });
});
var volumeRange = document.getElementById('volumeRange');
var volumeLabel = document.getElementById('volumeLabel');
var volTimer = null;
if (volumeRange && volumeLabel) {
volumeRange.addEventListener('input', function () {
volumeLabel.textContent = volumeRange.value + '%';
if (volTimer) clearTimeout(volTimer);
volTimer = setTimeout(function () {
post('/api/settings', { volume: Number(volumeRange.value) }, function () { toast('音量已更新'); });
}, 300);
});
}
/* ---------- 显示控制 ---------- */
var btnFullscreen = document.getElementById('btnFullscreen');
function renderDisplay() {
if (btnFullscreen) btnFullscreen.textContent = fullscreen ? '切换窗口模式' : '切换大屏模式';
var btnAutostart = document.getElementById('btnAutostart');
if (btnAutostart) { btnAutostart.textContent = autostart ? '已开启' : '已关闭'; btnAutostart.classList.toggle('active', autostart); }
}
if (btnFullscreen) btnFullscreen.addEventListener('click', function () {
fullscreen = !fullscreen;
renderDisplay();
post('/api/settings', { fullscreen: fullscreen });
});
var btnAutostart = document.getElementById('btnAutostart');
if (btnAutostart) btnAutostart.addEventListener('click', function () {
autostart = !autostart;
renderDisplay();
post('/api/settings', { autostart: autostart });
});
renderDisplay();
/* ---------- 上传(带进度) ---------- */
var btnUpload = document.getElementById('btnUpload');
if (btnUpload) btnUpload.addEventListener('click', function () {
var inp = document.getElementById('fileInput');
var fileList = Array.from(inp.files || []);
if (fileList.length === 0) { toast('请选择文件', 'error'); return; }
var prog = document.getElementById('uploadProgress');
var fill = document.getElementById('progressFill');
var text = document.getElementById('progressText');
prog.style.display = 'block';
var done = 0;
fileList.forEach(function (file) {
var fd = new FormData();
fd.append('file', file);
var xhr = new XMLHttpRequest();
xhr.open('POST', '/upload');
xhr.upload.onprogress = function (e) {
if (e.lengthComputable) {
var pct = Math.round((e.loaded / e.total) * 100);
var overall = Math.round(((done + e.loaded / e.total) / fileList.length) * 100);
fill.style.width = overall + '%';
text.textContent = file.name + ' (' + (done + 1) + '/' + fileList.length + ') — ' + pct + '%';
}
};
xhr.onload = function () {
done += 1;
if (done === fileList.length) reload('上传完成');
};
xhr.onerror = function () { toast(file.name + ' 上传失败', 'error'); done += 1; };
xhr.send(fd);
});
});
/* ---------- 远程 URL ---------- */
var btnAddUrl = document.getElementById('btnAddUrl');
if (btnAddUrl) btnAddUrl.addEventListener('click', function () {
var inp = document.getElementById('urlInput');
var url = (inp.value || '').trim();
if (!url) { toast('请输入 URL', 'error'); return; }
post('/api/media/add-url', { url: url }, function (d) {
reload(d && d.duplicate ? 'URL 已存在于媒体库' : '已添加到媒体库');
});
});
/* ---------- 媒体操作(加入播放 / 移出 / 删除) ---------- */
document.querySelectorAll('[data-action]').forEach(function (btn) {
btn.addEventListener('click', function (e) {
e.stopPropagation();
var card = btn.closest('.media-card');
var path = card.getAttribute('data-path');
var action = btn.getAttribute('data-action');
if (action === 'add-playlist') {
post('/api/playlist/add', { path: path }, function () { reload('已加入播放列表'); });
} else if (action === 'remove-playlist') {
post('/api/playlist/remove', { path: path }, function () { reload('已移出播放列表'); });
} else if (action === 'delete') {
if (!window.confirm('确定删除该文件?')) return;
post('/api/delete', { path: path }, function () { reload('已删除'); });
}
});
});
/* ---------- 预览弹窗 ---------- */
var modal = document.getElementById('previewModal');
var modalBody = document.getElementById('previewBody');
var current = null;
function openPreview(card) {
var path = card.getAttribute('data-path');
var type = card.getAttribute('data-type');
var source = card.getAttribute('data-source');
var name = card.getAttribute('data-name');
var url = source === 'url' ? path : '/file/' + encodeURI(path);
current = { path: path, url: url, type: type, name: name };
document.getElementById('previewTitle').textContent = name;
modalBody.innerHTML = type === 'image'
? '<img src="' + url + '" alt="' + name + '">'
: '<video src="' + url + '" controls autoplay playsinline></video>';
document.getElementById('previewMeta').innerHTML =
'<span class="badge ' + (source === 'url' ? 'badge-url' : 'badge-local') + '">' + (source === 'url' ? '远程' : '本地') + '</span>' +
'<span class="badge ' + (type === 'video' ? 'badge-video' : 'badge-image') + '">' + (type === 'video' ? '视频' : '图片') + '</span>';
modal.style.display = 'flex';
}
function closePreview() {
var v = modalBody.querySelector('video');
if (v) { v.pause(); v.removeAttribute('src'); v.load(); }
modalBody.innerHTML = '';
modal.style.display = 'none';
current = null;
}
document.querySelectorAll('[data-preview]').forEach(function (el) {
el.addEventListener('click', function () { openPreview(el.closest('.media-card')); });
});
document.getElementById('previewClose').addEventListener('click', closePreview);
modal.addEventListener('click', function (e) {
if (e.target === modal) closePreview();
});
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closePreview(); });
document.getElementById('previewAdd').addEventListener('click', function () {
if (!current) return;
post('/api/playlist/add', { path: current.path }, function () { closePreview(); reload('已加入播放列表'); });
});
document.getElementById('previewDelete').addEventListener('click', function () {
if (!current) return;
if (!window.confirm('确定删除该文件?')) return;
post('/api/delete', { path: current.path }, function () { closePreview(); reload('已删除'); });
});
})();
+263
View File
@@ -0,0 +1,263 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>管理后台 · 昆明市大学生创业园</title>
<link rel="stylesheet" href="/static/admin.css">
</head>
<body>
{% if not logged_in %}
<!-- ==================== 登录页 ==================== -->
<div class="admin-page">
<div class="login-wrapper">
<div class="login-card">
<div class="login-brand">
<div class="icon">
<div class="iso-top"></div>
<div class="iso-left"></div>
<div class="iso-right"></div>
<div class="iso-dot"></div>
</div>
<h2>昆明市大学生创业园</h2>
<p>大屏幕轮播控制系统</p>
</div>
{% if error %}
<div class="toast error" id="loginError" style="position:static;margin-bottom:12px;">账号或密码错误,请重试</div>
{% endif %}
<form method="post" action="/admin/login">
<div class="field">
<label>账号</label>
<input name="username" placeholder="请输入账号" autocomplete="username" required>
</div>
<div class="field">
<label>密码</label>
<input type="password" name="password" placeholder="请输入密码" autocomplete="current-password" required>
</div>
<button type="submit" class="btn-login">登 录</button>
</form>
</div>
</div>
</div>
{% else %}
<!-- ==================== 管理台 ==================== -->
<div class="admin-page">
<div class="dashboard active">
<!-- 顶栏 -->
<div class="topbar">
<div class="topbar-left">
<div class="brand-dot"></div>
<h1>昆明市大学生创业园</h1>
</div>
<div class="topbar-center">
<span class="status-bar-text" id="statusText">等待大屏连接...</span>
</div>
<div class="topbar-right">
<button class="btn-control" data-control="prev" title="上一个">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M11 3L6 8L11 13" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<button class="btn-control play-btn" data-control="play" title="播放">
<svg width="18" height="18" viewBox="0 0 18 18" fill="currentColor"><polygon points="5,3 15,9 5,15"/></svg>
</button>
<button class="btn-control play-btn" data-control="pause" title="暂停">
<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>
</button>
<button class="btn-control" data-control="next" title="下一个">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M5 3L10 8L5 13" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<span class="topbar-divider"></span>
{% if from_screen %}<a class="btn-return" href="/screen">返回展播</a>{% endif %}
<a class="btn-return" href="/">数据大屏</a>
<a class="btn-logout" href="/admin/logout">退出</a>
</div>
</div>
<div class="main-grid">
<!-- 播放设置 -->
<div class="card">
<div class="card-header">
<div class="card-icon"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="#1D5DCE" stroke-width="1.6"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg></div>
<h3>播放设置</h3>
</div>
<div class="form-inline">
<div class="form-group">
<label>播放模式</label>
<select id="playMode">
<option value="sequential" {% if play_mode == 'sequential' %}selected{% endif %}>顺序播放</option>
<option value="random" {% if play_mode == 'random' %}selected{% endif %}>随机播放</option>
</select>
</div>
<div class="form-group">
<label>图片时长(秒)</label>
<input type="number" id="imageDuration" value="{{ image_duration }}" min="1" max="300">
</div>
<div class="form-group">
<label>音量</label>
<input type="range" id="volumeRange" min="0" max="100" value="{{ volume }}">
<div class="volume-label" id="volumeLabel">{{ volume }}%</div>
</div>
</div>
</div>
<!-- 显示控制 -->
<div class="card">
<div class="card-header">
<div class="card-icon"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="#1D5DCE" stroke-width="1.6"><rect x="3" y="4" width="18" height="13" rx="2"/><path d="M8 21h8M12 17v4"/></svg></div>
<h3>显示控制</h3>
</div>
<div class="form-inline">
<div class="form-group">
<label>显示模式</label>
<button class="btn-outline" id="btnFullscreen" style="width:100%;">切换大屏模式</button>
</div>
<div class="form-group">
<label>窗口操作</label>
<button class="btn-outline" data-control="minimize" style="width:100%;">最小化窗口</button>
</div>
<div class="form-group">
<label>开机自启动</label>
<button class="btn-outline" id="btnAutostart" style="width:100%;">已关闭</button>
</div>
</div>
</div>
<!-- 添加媒体 -->
<div class="card">
<div class="card-header">
<div class="card-icon"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="#1D5DCE" stroke-width="1.6"><path d="M12 16V4m0 0L7 9m5-5l5 5"/><path d="M4 16v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3"/></svg></div>
<h3>添加媒体</h3>
</div>
<div class="form-group">
<label>本地上传</label>
<div class="form-row">
<input type="file" id="fileInput" accept=".mp4,.mkv,.avi,.jpg,.jpeg,.png" multiple>
<button class="btn-accent" id="btnUpload">上传</button>
</div>
<div class="upload-progress" id="uploadProgress" style="display:none;">
<div class="progress-bar"><div class="progress-fill" id="progressFill" style="width:0%"></div></div>
<span class="progress-text" id="progressText"></span>
</div>
</div>
<div class="form-group">
<label>远程 URL</label>
<div class="form-row">
<input type="text" id="urlInput" placeholder="https://example.com/media.mp4">
<button class="btn-accent" id="btnAddUrl">添加</button>
</div>
</div>
</div>
<!-- 媒体库 -->
<div class="card full">
<div class="card-header">
<div class="card-icon"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="#1D5DCE" stroke-width="1.6"><path d="M4 20V10l6-5 6 5v10"/><path d="M10 20v-5h4v5"/></svg></div>
<h3>媒体库({{ files|length }}</h3>
</div>
<div class="media-grid">
{% if files %}
{% for item in files %}
<div class="media-card" data-path="{{ item.relative_path }}" data-type="{{ item.type }}" data-source="{{ item.source }}" data-name="{{ item.name }}">
{% if item.type == 'image' %}
<img src="{{ item.url }}" loading="lazy" class="thumb" alt="{{ item.name }}" data-preview>
{% else %}
<div class="thumb-video-wrap" data-preview>
<video src="{{ item.url }}#t=0.5" preload="auto" muted playsinline class="thumb"></video>
<span class="play-badge"></span>
</div>
{% endif %}
<div class="body">
<span class="name" title="{{ item.name }}">{{ item.name }}</span>
<div class="meta-row">
<span class="badge {{ 'badge-url' if item.source == 'url' else 'badge-local' }}">{{ '远程' if item.source == 'url' else '本地' }}</span>
<span class="badge {{ 'badge-video' if item.type == 'video' else 'badge-image' }}">{{ '视频' if item.type == 'video' else '图片' }}</span>
</div>
<div class="actions">
<button class="btn-accent btn-sm" data-action="add-playlist">加入播放</button>
<button class="btn-danger btn-sm" data-action="delete">删除</button>
</div>
</div>
</div>
{% endfor %}
{% else %}
<div class="empty-state">
<div class="empty-icon"><svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="#9ca3af" stroke-width="1.4"><rect x="3" y="5" width="18" height="14" rx="2"/><circle cx="9" cy="10" r="2"/><path d="M3 17l5-4 4 3 4-4 5 5"/></svg></div>
暂无媒体文件<br>请上传或通过 URL 添加
</div>
{% endif %}
</div>
</div>
<!-- 播放列表 -->
<div class="card full">
<div class="card-header">
<div class="card-icon"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="#1D5DCE" stroke-width="1.6"><path d="M4 6h16M4 12h16M4 18h10"/></svg></div>
<h3>播放列表({{ playlist|length }}</h3>
</div>
<div class="media-grid">
{% if playlist %}
{% for item in playlist %}
<div class="media-card" data-path="{{ item.relative_path }}" data-type="{{ item.type }}" data-source="{{ item.source }}" data-name="{{ item.name }}">
{% if item.type == 'image' %}
<img src="{{ item.url }}" loading="lazy" class="thumb" alt="{{ item.name }}" data-preview>
{% else %}
<div class="thumb-video-wrap" data-preview>
<video src="{{ item.url }}#t=0.5" preload="auto" muted playsinline class="thumb"></video>
<span class="play-badge"></span>
</div>
{% endif %}
<div class="body">
<span class="name" title="{{ item.name }}">{{ item.name }}</span>
<div class="meta-row">
<span class="badge {{ 'badge-url' if item.source == 'url' else 'badge-local' }}">{{ '远程' if item.source == 'url' else '本地' }}</span>
<span class="badge {{ 'badge-video' if item.type == 'video' else 'badge-image' }}">{{ '视频' if item.type == 'video' else '图片' }}</span>
</div>
<div class="actions">
<button class="btn-danger btn-sm" data-action="remove-playlist">移出</button>
</div>
</div>
</div>
{% endfor %}
{% else %}
<div class="empty-state">
<div class="empty-icon"></div>
播放列表为空<br>从上方媒体库添加内容
</div>
{% endif %}
</div>
</div>
</div>
</div>
<!-- 预览弹窗 -->
<div class="modal-overlay" id="previewModal" style="display:none;">
<div class="modal-panel">
<div class="modal-header">
<span class="modal-title" id="previewTitle"></span>
<button class="modal-close" id="previewClose">&times;</button>
</div>
<div class="modal-body" id="previewBody"></div>
<div class="modal-footer">
<span class="modal-info" id="previewMeta"></span>
<div class="modal-actions">
<button class="btn-accent btn-sm" id="previewAdd">加入播放</button>
<button class="btn-danger btn-sm" id="previewDelete">删除</button>
</div>
</div>
</div>
</div>
<!-- Toast -->
<div class="toast-container" id="toastContainer"></div>
</div>
<script>
window.__DPM_INIT = {
fullscreen: {{ 'true' if fullscreen else 'false' }},
autostart: {{ 'true' if autostart else 'false' }}
};
</script>
<script src="/static/admin.js"></script>
{% endif %}
</body>
</html>
+1005
View File
File diff suppressed because it is too large Load Diff
+1 -3
View File
@@ -1,5 +1,4 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import Admin from './pages/Admin';
import MediaScreen from './pages/MediaScreen'; import MediaScreen from './pages/MediaScreen';
import DataScreen from './pages/DataScreen'; import DataScreen from './pages/DataScreen';
import DigitalTwin from './pages/DigitalTwin'; import DigitalTwin from './pages/DigitalTwin';
@@ -18,8 +17,7 @@ export default function App() {
<Route path="/ai" element={<AiAssistant />} /> <Route path="/ai" element={<AiAssistant />} />
<Route path="/screen" element={<MediaScreen />} /> <Route path="/screen" element={<MediaScreen />} />
</Route> </Route>
{/* 管理后台:独立布局(不参与大屏循环) */} {/* 管理后台已迁移至 FastAPI + JinjaGET /admin),不由 React 路由接管 */}
<Route path="/admin" element={<Admin />} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
-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>
);
}