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:
@@ -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")) # 大屏心跳过期秒数
|
||||
|
||||
# ---- 阿里云 DashScope(LLM + 语音识别) ----
|
||||
DASHSCOPE_API_KEY = _env("DASHSCOPE_API_KEY", "")
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 不可用时前端回退) ====================
|
||||
|
||||
@@ -1187,3 +1187,57 @@ button { font-family: "Poppins", "PingFang SC", sans-serif; cursor: pointer; tra
|
||||
@media (max-width: 900px) {
|
||||
.page-switch-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
连接状态卡片
|
||||
========================================================= */
|
||||
.conn-status {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.conn-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 7px 10px;
|
||||
background: rgba(0, 189, 125, 0.04);
|
||||
border: 1px solid rgba(0, 189, 125, 0.12);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.conn-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
background: #d1d5db;
|
||||
}
|
||||
.conn-dot.on {
|
||||
background: #16A34A;
|
||||
box-shadow: 0 0 6px rgba(22, 163, 74, 0.7);
|
||||
animation: connPulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
.conn-dot.off {
|
||||
background: #DC2626;
|
||||
box-shadow: 0 0 6px rgba(220, 38, 38, 0.6);
|
||||
}
|
||||
@keyframes connPulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
.conn-label {
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary, #4b5563);
|
||||
width: 72px;
|
||||
}
|
||||
.conn-value {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text, #111827);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
+115
-76
@@ -1,6 +1,7 @@
|
||||
/* =========================================================
|
||||
Admin 管理后台前端逻辑(Jinja 页面配套)
|
||||
动态操作调用 FastAPI REST,完成后整页刷新(服务端重渲染)
|
||||
事件采用 document 委托绑定,保证按钮点击稳定响应;
|
||||
连接状态每 3s 轮询 /api/display/state 刷新
|
||||
========================================================= */
|
||||
(function () {
|
||||
'use strict';
|
||||
@@ -34,12 +35,12 @@
|
||||
})
|
||||
.catch(function () { toast('网络错误', 'error'); });
|
||||
}
|
||||
function reload(msg) {
|
||||
function reload(msg, delay) {
|
||||
if (msg) toast(msg);
|
||||
setTimeout(function () { location.reload(); }, 350);
|
||||
setTimeout(function () { location.reload(); }, delay || 600);
|
||||
}
|
||||
|
||||
/* ---------- 播放状态(SSE) ---------- */
|
||||
/* ---------- 播放状态(SSE,仅更新状态文字,不自动重载页面) ---------- */
|
||||
var statusText = document.getElementById('statusText');
|
||||
try {
|
||||
var es = new EventSource('/api/events');
|
||||
@@ -51,33 +52,118 @@
|
||||
else if (s.status === 'playing') statusText.textContent = '正在播放 — ' + s.name;
|
||||
else if (s.status === 'paused') statusText.textContent = '已暂停 — ' + s.name;
|
||||
}
|
||||
if (msg.action === 'playlist_changed') reload();
|
||||
};
|
||||
} catch (err) { /* ignore */ }
|
||||
|
||||
/* ---------- 页面切换(MQTT 控制大屏) ---------- */
|
||||
document.querySelectorAll('[data-nav]').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
/* =========================================================
|
||||
连接状态轮询(/api/display/state)
|
||||
========================================================= */
|
||||
var connEls = {
|
||||
api: document.getElementById('connApi'),
|
||||
mqtt: document.getElementById('connMqtt'),
|
||||
screens: document.getElementById('connScreens'),
|
||||
lastCmd: document.getElementById('connLastCmd'),
|
||||
};
|
||||
function setConn(id, ok, value) {
|
||||
var el = connEls[id];
|
||||
if (!el) return;
|
||||
var dot = el.querySelector('.conn-dot');
|
||||
var val = el.querySelector('.conn-value');
|
||||
if (dot) dot.className = 'conn-dot ' + (ok ? 'on' : 'off');
|
||||
if (val) val.textContent = value;
|
||||
}
|
||||
function pollStatus() {
|
||||
fetch('/api/display/state', { cache: 'no-store' })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (d) {
|
||||
setConn('api', true, '在线');
|
||||
setConn('mqtt', !!d.mqtt_connected, d.mqtt_connected ? '已连接(' + (d.mqtt_host || '') + ')' : '未连接');
|
||||
setConn('screens', (d.screens_online || 0) > 0, (d.screens_online || 0) + ' 台');
|
||||
var lc = d.last_command;
|
||||
if (lc) {
|
||||
var names = { navigate: '页面切换', play: '播放', pause: '暂停', next: '下一项', prev: '上一项',
|
||||
'play_target': '指定播放', alert: '通知', show_card: '卡片', set_mode: '播放模式' };
|
||||
var when = new Date(lc.ts).toLocaleTimeString('zh-CN', { hour12: false });
|
||||
setConn('lastCmd', !!lc.published,
|
||||
(names[lc.action] || lc.action) + ' · ' + (lc.published ? '已广播' : '未广播') + ' · ' + when);
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
setConn('api', false, '离线');
|
||||
setConn('mqtt', false, '未知');
|
||||
setConn('screens', false, '未知');
|
||||
});
|
||||
}
|
||||
if (connEls.api) { pollStatus(); setInterval(pollStatus, 3000); }
|
||||
|
||||
/* =========================================================
|
||||
事件委托:页面切换 / 播放控制 / 媒体操作
|
||||
========================================================= */
|
||||
document.addEventListener('click', function (e) {
|
||||
var btn = e.target.closest ? e.target.closest('[data-nav], [data-control], [data-action], .copy-btn, [data-preview]') : null;
|
||||
if (!btn) return;
|
||||
e.stopPropagation();
|
||||
|
||||
// 预览
|
||||
if (btn.hasAttribute('data-preview')) { openPreview(btn.closest('.media-card')); return; }
|
||||
|
||||
// 复制 URL
|
||||
if (btn.classList.contains('copy-btn')) {
|
||||
var url = btn.closest('.media-url').getAttribute('data-url') || '';
|
||||
var full = /^https?:\/\//.test(url) ? url : window.location.origin + url;
|
||||
var done = function () { toast('链接已复制'); };
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(full).then(done).catch(function () { fallbackCopy(full); done(); });
|
||||
} else { fallbackCopy(full); done(); }
|
||||
return;
|
||||
}
|
||||
|
||||
// 页面切换
|
||||
if (btn.hasAttribute('data-nav')) {
|
||||
var page = btn.getAttribute('data-nav');
|
||||
post('/api/display/command', { action: 'navigate', params: { page: page } }, function () {
|
||||
toast('已切换大屏至「' + btn.textContent.trim() + '」');
|
||||
});
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
/* ---------- 播放控制 ---------- */
|
||||
document.querySelectorAll('[data-control]').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
// 播放控制
|
||||
if (btn.hasAttribute('data-control')) {
|
||||
var action = btn.getAttribute('data-control');
|
||||
if (action === 'minimize') {
|
||||
post('/api/display/command', { action: 'minimize', params: {} });
|
||||
} else {
|
||||
post('/api/control', { action: action });
|
||||
if (action === 'minimize') post('/api/display/command', { action: 'minimize', params: {} });
|
||||
else post('/api/control', { action: action });
|
||||
return;
|
||||
}
|
||||
|
||||
// 媒体操作
|
||||
if (btn.hasAttribute('data-action')) {
|
||||
var card = btn.closest('.media-card');
|
||||
var path = card.getAttribute('data-path');
|
||||
var act = btn.getAttribute('data-action');
|
||||
if (act === 'play-now') {
|
||||
post('/api/playlist/play', { path: path }, function () { toast('已发送播放指令,大屏即将播放'); });
|
||||
} else if (act === 'add-playlist') {
|
||||
post('/api/playlist/add', { path: path }, function () { reload('已加入播放列表'); });
|
||||
} else if (act === 'remove-playlist') {
|
||||
post('/api/playlist/remove', { path: path }, function () { reload('已移出播放列表'); });
|
||||
} else if (act === 'delete') {
|
||||
if (!window.confirm('确定删除该文件?')) return;
|
||||
post('/api/delete', { path: path }, function () { reload('已删除'); });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/* ---------- 播放设置 ---------- */
|
||||
function fallbackCopy(text) {
|
||||
var ta = document.createElement('textarea');
|
||||
ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0';
|
||||
document.body.appendChild(ta); ta.select();
|
||||
try { document.execCommand('copy'); } catch (err) { /* ignore */ }
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
播放设置(change/input 事件)
|
||||
========================================================= */
|
||||
var playMode = document.getElementById('playMode');
|
||||
if (playMode) playMode.addEventListener('change', function () {
|
||||
post('/api/settings', { play_mode: playMode.value }, function () { toast('播放模式已更新'); });
|
||||
@@ -105,20 +191,17 @@
|
||||
|
||||
/* ---------- 显示控制 ---------- */
|
||||
var btnFullscreen = document.getElementById('btnFullscreen');
|
||||
var btnAutostart = document.getElementById('btnAutostart');
|
||||
function renderDisplay() {
|
||||
if (btnFullscreen) btnFullscreen.textContent = fullscreen ? '切换窗口模式' : '切换大屏模式';
|
||||
var btnAutostart = document.getElementById('btnAutostart');
|
||||
if (btnAutostart) { btnAutostart.textContent = autostart ? '已开启' : '已关闭'; btnAutostart.classList.toggle('active', autostart); }
|
||||
}
|
||||
if (btnFullscreen) btnFullscreen.addEventListener('click', function () {
|
||||
fullscreen = !fullscreen;
|
||||
renderDisplay();
|
||||
fullscreen = !fullscreen; renderDisplay();
|
||||
post('/api/settings', { fullscreen: fullscreen });
|
||||
});
|
||||
var btnAutostart = document.getElementById('btnAutostart');
|
||||
if (btnAutostart) btnAutostart.addEventListener('click', function () {
|
||||
autostart = !autostart;
|
||||
renderDisplay();
|
||||
autostart = !autostart; renderDisplay();
|
||||
post('/api/settings', { autostart: autostart });
|
||||
});
|
||||
renderDisplay();
|
||||
@@ -129,12 +212,10 @@
|
||||
var inp = document.getElementById('fileInput');
|
||||
var fileList = Array.from(inp.files || []);
|
||||
if (fileList.length === 0) { toast('请选择文件', 'error'); return; }
|
||||
|
||||
var prog = document.getElementById('uploadProgress');
|
||||
var fill = document.getElementById('progressFill');
|
||||
var text = document.getElementById('progressText');
|
||||
prog.style.display = 'block';
|
||||
|
||||
var done = 0;
|
||||
fileList.forEach(function (file) {
|
||||
var fd = new FormData();
|
||||
@@ -167,7 +248,7 @@
|
||||
if (!url) { toast('请输入 URL', 'error'); return; }
|
||||
post('/api/media/add-url', { url: url }, function (d) {
|
||||
if (play) {
|
||||
post('/api/playlist/play', { path: url }, function () { reload('已加入并开始播放'); });
|
||||
post('/api/playlist/play', { path: url }, function () { toast('已加入并发送播放指令'); });
|
||||
} else {
|
||||
reload(d && d.duplicate ? 'URL 已存在于媒体库' : '已添加到媒体库');
|
||||
}
|
||||
@@ -176,54 +257,15 @@
|
||||
if (btnAddUrl) btnAddUrl.addEventListener('click', function () { addUrlThen(false); });
|
||||
if (btnAddUrlPlay) btnAddUrlPlay.addEventListener('click', function () { addUrlThen(true); });
|
||||
|
||||
/* ---------- 复制资源 URL(FastAPI 输出) ---------- */
|
||||
document.querySelectorAll('.copy-btn').forEach(function (btn) {
|
||||
btn.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
var url = btn.closest('.media-url').getAttribute('data-url') || '';
|
||||
var full = /^https?:\/\//.test(url) ? url : window.location.origin + url;
|
||||
var done = function () { toast('链接已复制'); };
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(full).then(done).catch(function () { fallbackCopy(full); done(); });
|
||||
} else {
|
||||
fallbackCopy(full); done();
|
||||
}
|
||||
});
|
||||
});
|
||||
function fallbackCopy(text) {
|
||||
var ta = document.createElement('textarea');
|
||||
ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0';
|
||||
document.body.appendChild(ta); ta.select();
|
||||
try { document.execCommand('copy'); } catch (err) { /* ignore */ }
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
|
||||
/* ---------- 媒体操作(立即播放 / 加入播放 / 移出 / 删除) ---------- */
|
||||
document.querySelectorAll('[data-action]').forEach(function (btn) {
|
||||
btn.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
var card = btn.closest('.media-card');
|
||||
var path = card.getAttribute('data-path');
|
||||
var action = btn.getAttribute('data-action');
|
||||
if (action === 'play-now') {
|
||||
post('/api/playlist/play', { path: path }, function () { toast('正在播放'); });
|
||||
} else if (action === 'add-playlist') {
|
||||
post('/api/playlist/add', { path: path }, function () { reload('已加入播放列表'); });
|
||||
} else if (action === 'remove-playlist') {
|
||||
post('/api/playlist/remove', { path: path }, function () { reload('已移出播放列表'); });
|
||||
} else if (action === 'delete') {
|
||||
if (!window.confirm('确定删除该文件?')) return;
|
||||
post('/api/delete', { path: path }, function () { reload('已删除'); });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/* ---------- 预览弹窗 ---------- */
|
||||
/* =========================================================
|
||||
预览弹窗
|
||||
========================================================= */
|
||||
var modal = document.getElementById('previewModal');
|
||||
var modalBody = document.getElementById('previewBody');
|
||||
var current = null;
|
||||
|
||||
function openPreview(card) {
|
||||
if (!card || !modal) return;
|
||||
var path = card.getAttribute('data-path');
|
||||
var type = card.getAttribute('data-type');
|
||||
var source = card.getAttribute('data-source');
|
||||
@@ -246,11 +288,8 @@
|
||||
modal.style.display = 'none';
|
||||
current = null;
|
||||
}
|
||||
document.querySelectorAll('[data-preview]').forEach(function (el) {
|
||||
el.addEventListener('click', function () { openPreview(el.closest('.media-card')); });
|
||||
});
|
||||
document.getElementById('previewClose').addEventListener('click', closePreview);
|
||||
modal.addEventListener('click', function (e) {
|
||||
if (modal) modal.addEventListener('click', function (e) {
|
||||
if (e.target === modal) closePreview();
|
||||
});
|
||||
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closePreview(); });
|
||||
@@ -260,7 +299,7 @@
|
||||
});
|
||||
document.getElementById('previewPlay').addEventListener('click', function () {
|
||||
if (!current) return;
|
||||
post('/api/playlist/play', { path: current.path }, function () { closePreview(); toast('正在播放'); });
|
||||
post('/api/playlist/play', { path: current.path }, function () { closePreview(); toast('已发送播放指令'); });
|
||||
});
|
||||
document.getElementById('previewDelete').addEventListener('click', function () {
|
||||
if (!current) return;
|
||||
|
||||
@@ -74,6 +74,36 @@
|
||||
</div>
|
||||
|
||||
<div class="main-grid">
|
||||
<!-- 连接状态 -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-icon"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="#1D5DCE" stroke-width="1.6"><path d="M5 12.5a10 10 0 0 1 14 0"/><path d="M8 15.5a6 6 0 0 1 8 0"/><path d="M12 19h.01"/></svg></div>
|
||||
<h3>连接状态</h3>
|
||||
</div>
|
||||
<div class="conn-status">
|
||||
<div class="conn-row" id="connApi">
|
||||
<span class="conn-dot"></span>
|
||||
<span class="conn-label">后端服务</span>
|
||||
<span class="conn-value">检测中...</span>
|
||||
</div>
|
||||
<div class="conn-row" id="connMqtt">
|
||||
<span class="conn-dot"></span>
|
||||
<span class="conn-label">MQTT Broker</span>
|
||||
<span class="conn-value">检测中...</span>
|
||||
</div>
|
||||
<div class="conn-row" id="connScreens">
|
||||
<span class="conn-dot"></span>
|
||||
<span class="conn-label">在线大屏</span>
|
||||
<span class="conn-value">检测中...</span>
|
||||
</div>
|
||||
<div class="conn-row" id="connLastCmd">
|
||||
<span class="conn-dot"></span>
|
||||
<span class="conn-label">最近指令</span>
|
||||
<span class="conn-value">—</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 页面切换 -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
|
||||
Reference in New Issue
Block a user