init: EasyScreen 电子菜单系统
- server: FastAPI 后端(多屏管理、素材上传、节目编排、客户端注册/配置/心跳/崩溃上报、管理台托管) - android: Java 客户端(minSdk 23,全屏图片/视频轮播、远程配置、开机自启、崩溃上报) - web: React + Vite + antd 管理台(屏幕/素材/节目管理) - 屏幕设备 ID 关联机制、gunicorn 生产部署脚本
This commit is contained in:
@@ -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}
|
||||
Reference in New Issue
Block a user