init: EasyScreen 电子菜单系统

- server: FastAPI 后端(多屏管理、素材上传、节目编排、客户端注册/配置/心跳/崩溃上报、管理台托管)
- android: Java 客户端(minSdk 23,全屏图片/视频轮播、远程配置、开机自启、崩溃上报)
- web: React + Vite + antd 管理台(屏幕/素材/节目管理)
- 屏幕设备 ID 关联机制、gunicorn 生产部署脚本
This commit is contained in:
Tatta
2026-08-12 20:57:55 +08:00
commit 4e2775c8c3
58 changed files with 6451 additions and 0 deletions
+92
View File
@@ -0,0 +1,92 @@
"""管理端:素材上传与管理。"""
import os
import uuid
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from ..auth import get_current_user
from ..config import get_server_config
from ..database import get_db
from ..models import Asset, PlaylistItem
from ..schemas import AssetOut
router = APIRouter(prefix="/api/assets", tags=["assets"], dependencies=[Depends(get_current_user)])
_EXT_MAP = {
"image/jpeg": ".jpg",
"image/png": ".png",
"image/gif": ".gif",
"image/webp": ".webp",
"video/mp4": ".mp4",
"video/webm": ".webm",
"video/x-matroska": ".mkv",
}
@router.get("", response_model=list[AssetOut])
def list_assets(db: Session = Depends(get_db)):
return db.scalars(select(Asset).order_by(Asset.created_at.desc())).all()
@router.post("", response_model=AssetOut)
async def upload_asset(file: UploadFile = File(...), db: Session = Depends(get_db)):
"""上传图片/视频素材。"""
cfg = get_server_config()
content_type = file.content_type or ""
ext = _EXT_MAP.get(content_type)
if not ext:
raise HTTPException(status_code=400, detail=f"不支持的素材类型: {content_type or '未知'}")
max_bytes = cfg.get("max_upload_mb", 500) * 1024 * 1024
# 流式读取并限制大小
data = b""
while chunk := await file.read(1024 * 1024):
data += chunk
if len(data) > max_bytes:
raise HTTPException(status_code=413, detail=f"文件超过大小限制 {cfg['max_upload_mb']}MB")
filename = f"{uuid.uuid4().hex}{ext}"
upload_dir = os.path.abspath(os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), cfg.get("upload_dir", "./uploads")))
os.makedirs(upload_dir, exist_ok=True)
dest = os.path.join(upload_dir, filename)
with open(dest, "wb") as f:
f.write(data)
is_video = content_type.startswith("video/")
asset = Asset(
name=file.filename or filename,
type="video" if is_video else "image",
url=f"/media/{filename}",
size=len(data),
duration=0 if is_video else 10, # 图片默认展示 10 秒
)
db.add(asset)
db.commit()
db.refresh(asset)
return asset
@router.delete("/{asset_id}")
def delete_asset(asset_id: int, db: Session = Depends(get_db)):
asset = db.get(Asset, asset_id)
if not asset:
raise HTTPException(status_code=404, detail="素材不存在")
used = db.scalar(select(func.count(PlaylistItem.id)).where(PlaylistItem.asset_id == asset_id))
if used:
raise HTTPException(
status_code=409,
detail=f"该素材正被 {used} 个节目引用,请先从节目中移除再删除",
)
cfg = get_server_config()
upload_dir = os.path.abspath(os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), cfg.get("upload_dir", "./uploads")))
file_path = os.path.join(upload_dir, os.path.basename(asset.url))
if os.path.exists(file_path):
os.remove(file_path)
db.delete(asset)
db.commit()
return {"ok": True}