Files
DPM/backend/app/routers.py
T
Pine 1a95ecbacb feat: 管理后台补齐完整功能
- 页面切换卡片:数据大屏/数字孪生/AI 助手/媒体轮播 → MQTT navigate 控制所有大屏
- 立即播放:新增 POST /api/playlist/play(加入播放列表 + MQTT play_target),
  MediaScreen 监听 play_target 跳转并播放指定媒体
- 远程 URL 支持两种操作:添加到媒体库 / 加入播放列表
- 本地媒体卡片显示 FastAPI 输出 URL(/file/...)+ 一键复制
- 预览弹窗增加「立即播放」;新增 page-switch/media-url/copy-btn 样式
2026-08-17 21:38:04 +08:00

394 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""全部 REST 路由 —— 数据 / 媒体 / 播放列表 / 设置 / AI / 展示控制"""
import asyncio
import json
import logging
import time
from pathlib import Path
from fastapi import APIRouter, Request, UploadFile, File
from fastapi.responses import JSONResponse, StreamingResponse
from pydantic import BaseModel, Field
from .ai_tools import run_chat, run_tools
from .asr import transcribe as asr_transcribe
from .config import settings
from .event_bus import bus
from .llm import run_chat as llm_run_chat
from .mqtt import hub
from .sim_engine import sim_engine
from .storage import storage
log = logging.getLogger("dpm.api")
router = APIRouter()
ALLOWED_EXT = {".mp4", ".mkv", ".avi", ".jpg", ".jpeg", ".png"}
def _media_type(path):
lower = path.lower()
if lower.endswith((".mp4", ".mkv", ".avi")):
return "video"
return "image"
# ==================== 数据模型 ====================
class SettingsBody(BaseModel):
volume: int | None = None
play_mode: str | None = None
image_duration: int | None = None
fullscreen: bool | None = None
autostart: bool | None = None
username: str | None = None
password: str | None = None
class ActionBody(BaseModel):
action: str
class PathBody(BaseModel):
path: str
class UrlBody(BaseModel):
url: str
class StatePayload(BaseModel):
status: str = ""
index: int = 0
name: str = ""
media_type: str = ""
class StateBody(BaseModel):
state: StatePayload
class DisplayCommandBody(BaseModel):
action: str
params: dict = Field(default_factory=dict)
class ChatMessage(BaseModel):
role: str
content: str
class ChatBody(BaseModel):
messages: list[ChatMessage]
# ==================== 认证 / 设置 ====================
@router.post("/api/login")
async def login(request: Request):
form = await request.form()
s = storage.get_settings()
return JSONResponse({"success": form.get("username") == s["username"] and form.get("password") == s["password"]})
@router.get("/api/settings")
async def get_settings():
s = storage.get_settings()
return {k: s[k] for k in ("volume", "play_mode", "image_duration", "fullscreen", "autostart")}
@router.post("/api/settings")
async def update_settings(body: SettingsBody):
storage.update_settings(**body.model_dump(exclude_none=True))
s = storage.get_settings()
hub.publish_command("settings_changed", {"volume": s["volume"], "play_mode": s["play_mode"]})
return {"ok": True}
# ==================== 媒体 ====================
def _list_media():
files = []
media_dir = Path(settings.MEDIA_DIR)
if media_dir.exists():
for p in sorted(media_dir.iterdir()):
if not p.is_file() or p.suffix.lower() not in ALLOWED_EXT:
continue
files.append({
"name": p.name, "relative_path": p.name,
"type": _media_type(p.name), "url": f"/file/{p.name}", "source": "local",
})
for item in storage.get_url_media():
files.append({
"name": item.get("name", item.get("url", "")),
"relative_path": item.get("url", ""),
"type": item.get("type", "image"),
"url": item.get("url", ""),
"source": "url",
})
return {"files": files}
@router.get("/media")
async def list_media():
return _list_media()
@router.post("/upload")
async def upload(file: UploadFile = File(...)):
name = file.filename or "file"
ext = Path(name).suffix.lower()
if ext not in ALLOWED_EXT:
return JSONResponse({"ok": False, "error": "不支持的文件类型"}, status_code=400)
settings.MEDIA_DIR.mkdir(parents=True, exist_ok=True)
stem = Path(name).stem
new_name = f"{stem}_{time.strftime('%Y%m%d%H%M%S')}{ext}"
save_path = settings.MEDIA_DIR / new_name
with save_path.open("wb") as f:
while True:
chunk = await file.read(1024 * 1024)
if not chunk:
break
f.write(chunk)
hub.publish_command("playlist_changed")
return {"ok": True, "name": new_name}
@router.post("/api/delete")
async def delete_media(body: PathBody):
path = body.path
if not path.startswith(("http://", "https://")):
target = settings.MEDIA_DIR / path
try:
if target.exists() and target.is_file():
target.unlink()
except OSError:
pass
storage.delete_media(path)
hub.publish_command("playlist_changed")
return {"ok": True}
@router.post("/api/media/add-url")
async def add_url(body: UrlBody):
url = body.url.strip()
if not url:
return {"ok": False}
name = url.rsplit("/", 1)[-1].split("?", 1)[0] or url
t = _media_type(url)
is_new = storage.add_url_media(url, name, t)
return {"ok": True, "duplicate": None if is_new else True}
# ==================== 播放列表 ====================
def _playlist_files():
files = []
media_dir = Path(settings.MEDIA_DIR)
for item in storage.get_playlist():
path = item.get("path", "")
if item.get("source") == "url":
files.append({
"name": item.get("name") or path,
"relative_path": path, "type": _media_type(path), "url": path, "source": "url",
})
else:
fp = media_dir / path
if fp.exists():
files.append({
"name": fp.name, "relative_path": path,
"type": _media_type(path), "url": f"/file/{path}", "source": "local",
})
s = storage.get_settings()
return {"files": files, "volume": s["volume"], "play_mode": s["play_mode"], "image_duration": s["image_duration"]}
@router.get("/api/playlist")
async def get_playlist():
return _playlist_files()
@router.post("/api/playlist/add")
async def add_playlist(body: PathBody):
storage.add_to_playlist(body.path)
hub.publish_command("playlist_changed")
return {"ok": True}
@router.post("/api/playlist/remove")
async def remove_playlist(body: PathBody):
storage.remove_from_playlist(body.path)
hub.publish_command("playlist_changed")
return {"ok": True}
@router.post("/api/playlist/play")
async def play_playlist_item(body: PathBody):
"""指定媒体立即播放:加入播放列表(如未在)→ MQTT 广播 play_target → 大屏跳转播放"""
path = body.path
storage.add_to_playlist(path)
hub.publish_command("playlist_changed")
cmd = hub.publish_command("play_target", {"path": path})
return {"ok": True, "cmd_id": cmd.get("cmd_id")}
# ==================== 播放控制 / 状态 ====================
_CONTROL_ACTIONS = {"play", "pause", "next", "prev"}
@router.post("/api/control")
async def control(body: ActionBody):
if body.action not in _CONTROL_ACTIONS:
return JSONResponse({"ok": False, "error": "action 必须是 play/pause/next/prev"}, status_code=400)
hub.publish_command(body.action)
return {"ok": True, "action": body.action}
@router.get("/api/state")
async def get_state():
return {"state": {"status": "unknown", "index": 0, "name": "", "media_type": ""}}
@router.post("/api/state")
async def update_state(body: StateBody):
hub.publish_command("state_update", body.state.model_dump())
return {"ok": True}
@router.post("/api/display-command")
async def display_command(body: ActionBody):
if body.action == "minimize":
hub.publish_command("minimize")
return {"ok": True, "action": "minimize"}
return JSONResponse({"ok": False, "error": "不支持的显示命令"}, status_code=400)
# ==================== 数据(大屏全部数据来自后端) ====================
@router.get("/api/dashboard/snapshot")
async def dashboard_snapshot():
return sim_engine.snapshot()
@router.get("/api/dashboard/overview")
async def dashboard_overview():
return sim_engine.snapshot()
# ==================== 园区企业 ====================
_COMPANIES = [
{"name": "云南派音人工智能科技", "zone": "加速区", "room": "A1", "color": "#4c8dff"},
{"name": "米勒克尔蓝宝石珠宝", "zone": "加速区", "room": "A2", "color": "#4c8dff"},
{"name": "中泰研学合作", "zone": "加速区", "room": "A3", "color": "#4c8dff"},
{"name": "云南宸中低空经济", "zone": "加速区", "room": "A4", "color": "#4c8dff"},
{"name": "昆明智海银高文化科技", "zone": "加速区", "room": "A5", "color": "#4c8dff"},
{"name": "AI机器人大模型训练", "zone": "加速区", "room": "A6", "color": "#4c8dff"},
{"name": "云南廷秀文旅康养", "zone": "加速区", "room": "A7", "color": "#4c8dff"},
{"name": "瀚颖AI+教育信息咨询", "zone": "加速区", "room": "A8", "color": "#4c8dff"},
{"name": "云南大学AI+创业平台", "zone": "加速区", "room": "A9", "color": "#4c8dff"},
{"name": "仰光客厅", "zone": "国际区", "room": "I1", "color": "#22d3ee"},
{"name": "云南上古绝学文化", "zone": "国际区", "room": "I2", "color": "#22d3ee"},
{"name": "中越生物医疗", "zone": "国际区", "room": "I3", "color": "#22d3ee"},
{"name": "酷享野农AI农业", "zone": "国际区", "room": "I4", "color": "#22d3ee"},
{"name": "滇缅国际设计", "zone": "国际区", "room": "I5", "color": "#22d3ee"},
{"name": "昆明舒诺生物科技", "zone": "国际区", "room": "I6", "color": "#22d3ee"},
{"name": "达岸教育管理", "zone": "国际区", "room": "I7", "color": "#22d3ee"},
{"name": "Facebook越南跨境电商", "zone": "成长区", "room": "G1", "color": "#34d399"},
{"name": "研X同行者网络", "zone": "成长区", "room": "G2", "color": "#34d399"},
{"name": "朵哈·玫瑰特色产业链", "zone": "成长区", "room": "G3", "color": "#34d399"},
{"name": "昆明云韵体育", "zone": "成长区", "room": "G4", "color": "#34d399"},
{"name": "南菌优培食用菌", "zone": "成长区", "room": "G5", "color": "#34d399"},
{"name": "五华区丽裳文化", "zone": "成长区", "room": "G6", "color": "#34d399"},
{"name": "云南星瑞航空", "zone": "成长区", "room": "G7", "color": "#34d399"},
{"name": "综合直播私域平台", "zone": "成长区", "room": "G8", "color": "#34d399"},
{"name": "园区管理办公室", "zone": "园区管理", "room": "M", "color": "#fbbf24"},
]
@router.get("/api/park/companies")
async def park_companies():
return {"companies": _COMPANIES}
@router.get("/api/park/zones")
async def park_zones():
zones = ["加速区", "国际区", "成长区"]
counts = {z: sum(1 for c in _COMPANIES if c["zone"] == z) for z in zones}
colors = {"加速区": "#4c8dff", "国际区": "#22d3ee", "成长区": "#34d399"}
return {"zones": [{"name": z, "count": counts[z], "color": colors[z]} for z in zones]}
# ==================== AI 对话(通义千问 + 工具调用) ====================
@router.post("/api/ai/chat")
async def ai_chat(body: ChatBody):
"""大模型对话:工具调用(切页/控制/卡片/通知)经 MQTT 广播到所有大屏
返回 {reply, tools, model}tools 供请求端本地同步执行"""
messages = [m.model_dump() for m in body.messages]
result = llm_run_chat(messages)
return result
@router.post("/api/ai/asr")
async def ai_asr(file: UploadFile = File(...), format: str = "m4a"):
"""语音识别:上传录音 → 阿里云 paraformer 转写为文本"""
data = await file.read()
if not data:
return JSONResponse({"ok": False, "error": "空音频"}, status_code=400)
try:
text = asr_transcribe(data, fmt=format)
return {"ok": True, "text": text}
except Exception as e: # noqa: BLE001
log.warning("ASR 转写失败: %s", e)
return JSONResponse({"ok": False, "error": str(e)}, status_code=502)
# ==================== 展示控制(管理端 → MQTT ====================
_VALID_DISPLAY_ACTIONS = {"navigate", "navigate_rel", "play", "pause", "next", "prev",
"alert", "show_card", "set_mode", "minimize"}
@router.post("/api/display/command")
async def display_command_publish(body: DisplayCommandBody):
"""管理端/任意客户端通过 REST 发控制指令 → 后端转 MQTT 广播给所有大屏"""
if body.action not in _VALID_DISPLAY_ACTIONS:
return JSONResponse({"ok": False, "error": f"不支持的指令: {body.action}"}, status_code=400)
cmd = hub.publish_command(body.action, body.params)
return {"ok": cmd.get("published", False), "cmd_id": cmd["cmd_id"], "action": body.action,
"mqtt_connected": hub.connected}
@router.get("/api/display/state")
async def display_state():
return {
"mqtt_connected": hub.connected,
"mqtt_host": f"{settings.MQTT_HOST}:{settings.MQTT_PORT}",
"page": None, # 前端可通过 MQTT 回执上报当前页(后续扩展)
}
# ==================== SSE 兼容通道(MQTT 不可用时前端回退) ====================
@router.get("/api/events")
async def sse_events(request: Request):
q = bus.subscribe()
async def gen():
try:
while True:
if await request.is_disconnected():
break
try:
data = await asyncio.wait_for(q.get(), timeout=15)
yield f"data: {data}\n\n"
except asyncio.TimeoutError:
yield ": keepalive\n\n"
finally:
bus.unsubscribe(q)
return StreamingResponse(gen(), media_type="text/event-stream")