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
+147
View File
@@ -0,0 +1,147 @@
"""管理端:屏幕 CRUD、绑定节目。"""
import uuid
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
from ..auth import get_current_user
from ..database import get_db
from ..models import Playlist, Screen, ScreenBinding
from ..schemas import (
BindRequest,
PlaylistOut,
ScreenCreate,
ScreenDetail,
ScreenOut,
ScreenUpdate,
)
router = APIRouter(prefix="/api/screens", tags=["screens"], dependencies=[Depends(get_current_user)])
ONLINE_WINDOW = timedelta(seconds=90) # 与客户端心跳间隔保持一致
def _apply_online_status(screen: Screen) -> Screen:
"""根据最后心跳时间动态刷新在线状态(不落库,仅展示层)。"""
if screen.last_seen and datetime.now() - screen.last_seen <= ONLINE_WINDOW:
screen.status = "online"
else:
screen.status = "offline"
return screen
def _get_screen_or_404(db: Session, screen_id: int) -> Screen:
screen = db.get(Screen, screen_id)
if not screen:
raise HTTPException(status_code=404, detail="屏幕不存在")
return screen
@router.get("", response_model=list[ScreenDetail])
def list_screens(db: Session = Depends(get_db)):
screens = db.scalars(
select(Screen)
.options(selectinload(Screen.bindings).selectinload(ScreenBinding.playlist))
.order_by(Screen.id)
).all()
result = []
for s in screens:
_apply_online_status(s)
d = ScreenDetail.model_validate(s)
active = next((b for b in s.bindings if b.active), None)
if active and active.playlist:
d.playlist = PlaylistOut.model_validate(active.playlist)
result.append(d)
return result
@router.get("/{screen_id}", response_model=ScreenDetail)
def get_screen(screen_id: int, db: Session = Depends(get_db)):
screen = db.get(
Screen,
screen_id,
options=[selectinload(Screen.bindings).selectinload(ScreenBinding.playlist).selectinload(Playlist.items)],
)
if not screen:
raise HTTPException(status_code=404, detail="屏幕不存在")
_apply_online_status(screen)
detail = ScreenDetail.model_validate(screen)
active = next((b for b in screen.bindings if b.active), None)
if active and active.playlist:
detail.playlist = PlaylistOut.model_validate(active.playlist)
return detail
@router.post("", response_model=ScreenOut)
def create_screen(body: ScreenCreate, db: Session = Depends(get_db)):
"""手动创建屏幕(可选指定 device_id 用于客户端注册匹配)。"""
device_id = body.device_id
if not device_id:
# 未指定设备 ID 时:先写临时 UUID 满足 NOT NULL 约束,flush 拿到 id 后改为 pre-{id}
screen = Screen(name=body.name, location=body.location, device_id=f"tmp-{uuid.uuid4().hex}")
db.add(screen)
db.flush()
screen.device_id = f"pre-{screen.id}"
else:
exists = db.scalars(select(Screen).where(Screen.device_id == device_id)).first()
if exists:
raise HTTPException(status_code=409, detail=f"device_id 已存在(屏幕: {exists.name}")
screen = Screen(name=body.name, location=body.location, device_id=device_id)
db.add(screen)
db.commit()
db.refresh(screen)
return screen
@router.put("/{screen_id}", response_model=ScreenOut)
def update_screen(screen_id: int, body: ScreenUpdate, db: Session = Depends(get_db)):
screen = _get_screen_or_404(db, screen_id)
if body.name is not None:
screen.name = body.name
if body.location is not None:
screen.location = body.location
db.commit()
db.refresh(screen)
return screen
@router.delete("/{screen_id}")
def delete_screen(screen_id: int, db: Session = Depends(get_db)):
screen = _get_screen_or_404(db, screen_id)
db.delete(screen)
db.commit()
return {"ok": True}
@router.post("/{screen_id}/bind", response_model=ScreenDetail)
def bind_playlist(screen_id: int, body: BindRequest, db: Session = Depends(get_db)):
"""绑定节目到屏幕(置为 active,其它绑定取消 active)。"""
screen = _get_screen_or_404(db, screen_id)
playlist = db.get(Playlist, body.playlist_id)
if not playlist:
raise HTTPException(status_code=404, detail="节目不存在")
for b in screen.bindings:
b.active = False
existing = next((b for b in screen.bindings if b.playlist_id == body.playlist_id), None)
if existing:
existing.active = True
else:
db.add(ScreenBinding(screen_id=screen.id, playlist_id=body.playlist_id, active=True))
db.commit()
detail = get_screen(screen_id, db)
return detail
@router.delete("/{screen_id}/bind")
def unbind_playlist(screen_id: int, db: Session = Depends(get_db)):
"""解除屏幕当前绑定。"""
screen = _get_screen_or_404(db, screen_id)
for b in screen.bindings:
if b.active:
db.delete(b)
db.commit()
return {"ok": True}