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:
@@ -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
|
||||||
|
|
||||||
# 阿里云 DashScope(LLM + 语音识别)
|
# 阿里云 DashScope(LLM + 语音识别)
|
||||||
DASHSCOPE_API_KEY=sk-xxx
|
DASHSCOPE_API_KEY=sk-xxx
|
||||||
DPM_LLM_MODEL=qwen-plus
|
DPM_LLM_MODEL=qwen-plus
|
||||||
|
|||||||
+3
-4
@@ -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`(浏览器直接打开即大屏)。
|
||||||
|
|||||||
@@ -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))
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 即可) ----------
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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('已删除'); });
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -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">×</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>
|
||||||
Generated
+1005
File diff suppressed because it is too large
Load Diff
+1
-3
@@ -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 + Jinja(GET /admin),不由 React 路由接管 */}
|
||||||
<Route path="/admin" element={<Admin />} />
|
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
@@ -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}>×</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user