fix: 管理后台连接状态 + 修复按钮无响应

连接状态(正式):
- /api/display/state 扩展:screens_online(大屏心跳统计,opc/display/heartbeat)+ last_command(最近指令与发布结果)
- 前端大屏每 10s 上报心跳(稳定 client_id),后端 30s TTL 统计在线大屏数
- 管理台新增「连接状态」卡片:后端/MQTT/在线大屏/最近指令,3s 轮询刷新

按钮无响应根治:
- admin.js 改为 document 事件委托(data-nav/data-control/data-action/复制/预览),不再依赖逐个绑定
- /static 与 /admin 响应加 no-cache 头,杜绝旧 JS/CSS 缓存导致的按钮失效
- SSE 不再自动重载页面(避免与操作回调双重重载清掉反馈);立即播放改为 toast 不重载
This commit is contained in:
Pine
2026-08-17 21:54:23 +08:00
parent 1a95ecbacb
commit c55835fb7d
10 changed files with 297 additions and 86 deletions
+2
View File
@@ -57,6 +57,8 @@ class Settings:
TOPIC_COMMAND = _env("DPM_TOPIC_COMMAND", "opc/display/command")
TOPIC_TICK = _env("DPM_TOPIC_TICK", "opc/dashboard/tick")
TOPIC_ACK = _env("DPM_TOPIC_ACK", "opc/display/ack")
TOPIC_HEARTBEAT = _env("DPM_TOPIC_HEARTBEAT", "opc/display/heartbeat")
SCREEN_TTL = float(_env("DPM_SCREEN_TTL", "30")) # 大屏心跳过期秒数
# ---- 阿里云 DashScopeLLM + 语音识别) ----
DASHSCOPE_API_KEY = _env("DASHSCOPE_API_KEY", "")
+11
View File
@@ -69,6 +69,17 @@ app.add_middleware(
allow_headers=["*"],
)
@app.middleware("http")
async def no_cache_admin_static(request: Request, call_next):
"""管理后台页面与静态资源禁用缓存,避免旧 JS/CSS 残留导致按钮无响应"""
response = await call_next(request)
if request.url.path.startswith(("/static/", "/admin")):
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
return response
app.include_router(router)
# 媒体资源静态服务(上传/播放的文件)
+42 -3
View File
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
"""MQTT 发布中心 —— 后端 前端控制通道
页面控制 / 媒体控制 / 卡片展示指令统一走 opc/display/command
数据快照按 DPM_MQTT_TICK_INTERVAL 推送 opc/dashboard/tick
"""MQTT 发布/订阅中心 —— 后端 前端控制通道
发布:opc/display/command(页面/媒体/卡片控制)、opc/dashboard/tick(数据快照)
订阅:opc/display/heartbeat(大屏在线心跳,统计在线大屏数)
"""
import json
@@ -27,6 +27,8 @@ class MqttHub:
self.client = None
self.connected = False
self._lock = threading.RLock()
self._screens = {} # client_id -> last_seen_ts
self.last_command = None # {action, params, ts, published}
# ---------- 生命周期 ----------
def start(self):
@@ -43,6 +45,7 @@ class MqttHub:
self.client.username_pw_set(settings.MQTT_USERNAME, settings.MQTT_PASSWORD)
self.client.on_connect = self._on_connect
self.client.on_disconnect = self._on_disconnect
self.client.on_message = self._on_message
self.client.connect_async(settings.MQTT_HOST, settings.MQTT_PORT, keepalive=30)
self.client.loop_start()
log.info("MQTT 连接中 %s:%s ...", settings.MQTT_HOST, settings.MQTT_PORT)
@@ -62,6 +65,7 @@ class MqttHub:
if rc == 0:
self.connected = True
log.info("MQTT 已连接 %s:%s", settings.MQTT_HOST, settings.MQTT_PORT)
client.subscribe(settings.TOPIC_HEARTBEAT, qos=0)
else:
log.warning("MQTT 连接失败 rc=%s", rc)
@@ -70,6 +74,34 @@ class MqttHub:
if rc != 0:
log.warning("MQTT 断开(rc=%s),自动重连中...", rc)
def _on_message(self, client, userdata, msg):
"""接收大屏心跳:记录 client_id 与时间"""
if msg.topic == settings.TOPIC_HEARTBEAT:
try:
payload = json.loads(msg.payload.decode("utf-8"))
cid = payload.get("client_id") or msg.topic
with self._lock:
self._screens[cid] = time.time()
# 清理过期
cutoff = time.time() - settings.SCREEN_TTL
self._screens = {k: v for k, v in self._screens.items() if v > cutoff}
except Exception: # noqa: BLE001
pass
# ---------- 状态查询 ----------
def screens_online(self):
with self._lock:
cutoff = time.time() - settings.SCREEN_TTL
return sum(1 for v in self._screens.values() if v > cutoff)
def status(self):
return {
"mqtt_connected": self.connected,
"mqtt_host": f"{settings.MQTT_HOST}:{settings.MQTT_PORT}",
"screens_online": self.screens_online(),
"last_command": self.last_command,
}
# ---------- 发布 ----------
def publish(self, topic, payload, qos=1, retain=False):
if not self.client or not self.connected:
@@ -93,6 +125,13 @@ class MqttHub:
}
ok = self.publish(settings.TOPIC_COMMAND, payload)
payload["published"] = ok
with self._lock:
self.last_command = {
"action": action,
"params": params or {},
"ts": payload["ts"],
"published": ok,
}
if ok:
bus.emit(payload) # SSE 兼容通道(仅发布成功时推送)
return payload
+1 -5
View File
@@ -364,11 +364,7 @@ async def display_command_publish(body: DisplayCommandBody):
@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 回执上报当前页(后续扩展)
}
return hub.status()
# ==================== SSE 兼容通道(MQTT 不可用时前端回退) ====================