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
+89
View File
@@ -0,0 +1,89 @@
"""管理端:节目(播放清单)CRUD。"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import delete, select
from sqlalchemy.orm import Session, selectinload
from ..auth import get_current_user
from ..database import get_db
from ..models import Asset, Playlist, PlaylistItem, ScreenBinding
from ..schemas import PlaylistCreate, PlaylistItemIn, PlaylistOut, PlaylistUpdate
router = APIRouter(prefix="/api/playlists", tags=["playlists"], dependencies=[Depends(get_current_user)])
def _get_playlist_or_404(db: Session, playlist_id: int) -> Playlist:
playlist = db.get(Playlist, playlist_id, options=[selectinload(Playlist.items).selectinload(PlaylistItem.asset)])
if not playlist:
raise HTTPException(status_code=404, detail="节目不存在")
return playlist
def _replace_items(db: Session, playlist_id: int, items: list[PlaylistItemIn]):
"""全量替换节目项。"""
asset_ids = {item.asset_id for item in items}
if asset_ids:
found = set(db.scalars(select(Asset.id).where(Asset.id.in_(asset_ids))).all())
missing = asset_ids - found
if missing:
raise HTTPException(status_code=400, detail=f"素材不存在: {sorted(missing)}")
db.execute(delete(PlaylistItem).where(PlaylistItem.playlist_id == playlist_id))
for idx, item in enumerate(items):
db.add(
PlaylistItem(
playlist_id=playlist_id,
asset_id=item.asset_id,
sort_order=idx,
duration=item.duration,
)
)
@router.get("", response_model=list[PlaylistOut])
def list_playlists(db: Session = Depends(get_db)):
return db.scalars(
select(Playlist)
.options(selectinload(Playlist.items).selectinload(PlaylistItem.asset))
.order_by(Playlist.created_at.desc())
).all()
@router.get("/{playlist_id}", response_model=PlaylistOut)
def get_playlist(playlist_id: int, db: Session = Depends(get_db)):
return _get_playlist_or_404(db, playlist_id)
@router.post("", response_model=PlaylistOut)
def create_playlist(body: PlaylistCreate, db: Session = Depends(get_db)):
playlist = Playlist(name=body.name, description=body.description)
db.add(playlist)
db.flush()
_replace_items(db, playlist.id, body.items)
db.commit()
return _get_playlist_or_404(db, playlist.id)
@router.put("/{playlist_id}", response_model=PlaylistOut)
def update_playlist(playlist_id: int, body: PlaylistUpdate, db: Session = Depends(get_db)):
playlist = _get_playlist_or_404(db, playlist_id)
if body.name is not None:
playlist.name = body.name
if body.description is not None:
playlist.description = body.description
if body.items is not None:
_replace_items(db, playlist.id, body.items)
db.commit()
return _get_playlist_or_404(db, playlist_id)
@router.delete("/{playlist_id}")
def delete_playlist(playlist_id: int, db: Session = Depends(get_db)):
playlist = _get_playlist_or_404(db, playlist_id)
has_active_binding = (
db.scalar(select(ScreenBinding.id).where(ScreenBinding.playlist_id == playlist_id, ScreenBinding.active == True)) # noqa: E712
is not None
)
db.execute(delete(ScreenBinding).where(ScreenBinding.playlist_id == playlist_id))
db.delete(playlist)
db.commit()
return {"ok": True, "unbound_screens": has_active_binding}