feat: 拆出 FastAPI 独立后端 + MQTT 控制 + 阿里云 AI
后端(backend/,FastAPI :10085): - 全部数据 REST 接口:dashboard 快照 / 企业分区 / 播放列表 / 媒体 / 设置 - MQTT 控制通道:opc/display/command(切页/播放/卡片/通知), 管理端 POST /api/display/command → MQTT 广播 → 所有大屏同步响应 - 阿里云 DashScope:通义千问 LLM + 函数调用(工具经 MQTT 广播), paraformer-realtime-v2 语音识别 /api/ai/asr - 媒体资源统一由后端存储返回(上传/列表/静态服务) - SSE /api/events 保留作 MQTT 不可用时的兼容回退 前端: - config.js + .env.local 配置后端地址与 MQTT 账号(前端 dpm / 服务端 dpmserver) - mqtt.js 客户端 + useMqttControl(MQTT 驱动切页/媒体/卡片/通知) - DpmOverlays 全局覆盖层(通知 toast + 企业/分区/总览卡片) - useParkSim 改为后端 API 数据源(离线回退本地模拟) - AiChatPanel:对话走后端 LLM(工具调用),语音走本地录音 + 后端 ASR - MediaScreen:媒体控制走 MQTT(保留 SSE 回退),修复 useEffect TDZ Rust:lib.rs 移除内嵌 HTTP 服务器,只保留薄壳(窗口/权限/自启/npc 隧道) 安全:backend/.env、.env.local、media、data.json 已 gitignore
This commit is contained in:
+3
-12
@@ -1,15 +1,6 @@
|
||||
// API 基地址
|
||||
// 同源请求(hostname=localhost/127.0.0.1):`''` 相对路径,开发模式或 macOS Tauri
|
||||
// Windows Tauri(hostname 含 "tauri"):后端在本机,固定 http://localhost:10801
|
||||
// 局域网访问(hostname 为 LAN IP):后端在同一台机器的 10801 端口
|
||||
const hostname = window.location.hostname;
|
||||
const isLocal = hostname === 'localhost' || hostname === '127.0.0.1';
|
||||
const isTauri = hostname.includes('tauri');
|
||||
const BASE = isLocal
|
||||
? ''
|
||||
: isTauri
|
||||
? 'http://localhost:10801'
|
||||
: `http://${hostname}:10801`;
|
||||
// API 基地址 —— 独立 FastAPI 后端(配置见 src/config.js,可用 window.__DPM_API__ 覆盖)
|
||||
import { API_BASE as BASE } from '../config';
|
||||
|
||||
export const API_BASE = BASE;
|
||||
|
||||
async function request(url, options = {}) {
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import mqtt from 'mqtt';
|
||||
import { MQTT_URL, MQTT_TOPIC_COMMAND, MQTT_TOPIC_TICK, MQTT_USERNAME, MQTT_PASSWORD } from '../config';
|
||||
|
||||
/* =========================================================
|
||||
MQTT 客户端(mqtt.js)—— 后端控制通道
|
||||
订阅 opc/display/command(页面/媒体/卡片控制)与 opc/dashboard/tick(数据快照)
|
||||
断线自动重连;未连接时静默降级(页面仍可本地操作)
|
||||
========================================================= */
|
||||
|
||||
let client = null;
|
||||
let status = 'connecting'; // connecting | connected | offline
|
||||
const listeners = new Set();
|
||||
|
||||
function notifyStatus() {
|
||||
window.dispatchEvent(new CustomEvent('dpm:mqtt-status', { detail: { status } }));
|
||||
}
|
||||
|
||||
export function connectMqtt() {
|
||||
if (client) return;
|
||||
try {
|
||||
client = mqtt.connect(MQTT_URL, {
|
||||
reconnectPeriod: 3000,
|
||||
connectTimeout: 8000,
|
||||
clientId: `dpm-screen-${Math.random().toString(16).slice(2, 8)}`,
|
||||
clean: true,
|
||||
username: MQTT_USERNAME || undefined,
|
||||
password: MQTT_PASSWORD || undefined,
|
||||
});
|
||||
client.on('connect', () => {
|
||||
status = 'connected';
|
||||
notifyStatus();
|
||||
client.subscribe([MQTT_TOPIC_COMMAND, MQTT_TOPIC_TICK], { qos: 1 });
|
||||
});
|
||||
client.on('reconnect', () => {
|
||||
status = 'connecting';
|
||||
notifyStatus();
|
||||
});
|
||||
client.on('close', () => {
|
||||
status = 'offline';
|
||||
notifyStatus();
|
||||
});
|
||||
client.on('error', () => {
|
||||
status = 'offline';
|
||||
notifyStatus();
|
||||
});
|
||||
client.on('message', (topic, payload) => {
|
||||
let data = null;
|
||||
try {
|
||||
data = JSON.parse(payload.toString());
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
listeners.forEach((fn) => fn(topic, data));
|
||||
});
|
||||
} catch (e) {
|
||||
status = 'offline';
|
||||
notifyStatus();
|
||||
}
|
||||
}
|
||||
|
||||
export function getMqttStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
/** 订阅 MQTT 消息,返回取消订阅函数 */
|
||||
export function onMqttMessage(fn) {
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
}
|
||||
|
||||
/** React Hook:MQTT 连接状态 */
|
||||
export function useMqttStatus() {
|
||||
const [s, setS] = useState(status);
|
||||
useEffect(() => {
|
||||
connectMqtt();
|
||||
const on = (e) => setS(e.detail.status);
|
||||
window.addEventListener('dpm:mqtt-status', on);
|
||||
return () => window.removeEventListener('dpm:mqtt-status', on);
|
||||
}, []);
|
||||
return s;
|
||||
}
|
||||
+19
-4
@@ -1,8 +1,10 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { API_BASE } from '../config';
|
||||
|
||||
/* =========================================================
|
||||
园区模拟数据引擎(共享)—— 供数据大屏 / 媒体播放页复用
|
||||
每 2.2s 跳动一次,保持页眉与园区概览页脚数据一致
|
||||
园区数据引擎(共享)—— 供数据大屏 / 媒体播放页复用
|
||||
数据来自 FastAPI 后端 GET /api/dashboard/snapshot(每 2.2s 刷新)
|
||||
后端不可用时回退本地模拟引擎,保证离线可用
|
||||
========================================================= */
|
||||
|
||||
export const fmtInt = (n) => Math.round(n).toLocaleString('zh-CN');
|
||||
@@ -125,8 +127,21 @@ function nextSnapshot(s) {
|
||||
export function useParkSim() {
|
||||
const [snap, setSnap] = useState(initSnapshot);
|
||||
useEffect(() => {
|
||||
const iv = setInterval(() => setSnap((p) => nextSnapshot(p)), 2200);
|
||||
return () => clearInterval(iv);
|
||||
let cancelled = false;
|
||||
const load = async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/dashboard/snapshot`, { cache: 'no-store' });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
if (!cancelled && data && data.token) setSnap(data);
|
||||
} catch {
|
||||
// 后端不可用:本地模拟引擎继续推进(离线兜底)
|
||||
if (!cancelled) setSnap((p) => nextSnapshot(p));
|
||||
}
|
||||
};
|
||||
load();
|
||||
const iv = setInterval(load, 2200);
|
||||
return () => { cancelled = true; clearInterval(iv); };
|
||||
}, []);
|
||||
return snap;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { onMqttMessage, connectMqtt } from './mqtt';
|
||||
import { PAGE_ORDER } from './pageNav';
|
||||
|
||||
/* =========================================================
|
||||
MQTT 控制 —— 后端通过 opc/display/command 控制大屏
|
||||
· navigate / navigate_rel:页面切换(与方向键同一通道)
|
||||
· play / pause / next / prev / set_mode:媒体控制
|
||||
· alert:全局通知
|
||||
· show_card:展示信息卡片(AI 工具调用 / 管理端指令)
|
||||
========================================================= */
|
||||
|
||||
const PAGE_ALIAS = {
|
||||
home: '/', '数据大屏': '/', '首页': '/',
|
||||
twin: '/twin', '数字孪生': '/twin', '3d': '/twin',
|
||||
ai: '/ai', 'ai助手': '/ai',
|
||||
screen: '/screen', '媒体轮播': '/screen', media: '/screen',
|
||||
};
|
||||
|
||||
function resolvePage(page) {
|
||||
if (!page) return null;
|
||||
if (PAGE_ALIAS[String(page).toLowerCase()] !== undefined) return PAGE_ALIAS[String(page).toLowerCase()];
|
||||
if (PAGE_ORDER.some((p) => p.path === page)) return page;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function useMqttControl() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
connectMqtt();
|
||||
const off = onMqttMessage((topic, cmd) => {
|
||||
if (topic !== 'opc/display/command' || !cmd || !cmd.action) return;
|
||||
|
||||
switch (cmd.action) {
|
||||
case 'navigate': {
|
||||
const path = resolvePage(cmd.params?.page);
|
||||
if (path) navigate(path);
|
||||
break;
|
||||
}
|
||||
case 'navigate_rel': {
|
||||
const delta = Number(cmd.params?.delta ?? 1) || 1;
|
||||
const idx = PAGE_ORDER.findIndex((p) => p.path === location.pathname);
|
||||
if (idx >= 0) {
|
||||
const n = PAGE_ORDER.length;
|
||||
navigate(PAGE_ORDER[(idx + delta + n) % n].path);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'alert':
|
||||
window.dispatchEvent(new CustomEvent('dpm:alert', { detail: cmd.params || {} }));
|
||||
break;
|
||||
case 'show_card':
|
||||
window.dispatchEvent(new CustomEvent('dpm:show-card', { detail: cmd.params || {} }));
|
||||
break;
|
||||
case 'play':
|
||||
case 'pause':
|
||||
case 'next':
|
||||
case 'prev':
|
||||
case 'set_mode':
|
||||
case 'minimize':
|
||||
case 'settings_changed':
|
||||
case 'playlist_changed':
|
||||
window.dispatchEvent(new CustomEvent('dpm:media-control', { detail: cmd }));
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// 离线兜底:AI 对话返回的工具调用在 MQTT 未连接时本地执行
|
||||
const onLocal = (e) => {
|
||||
const { action, params } = e.detail || {};
|
||||
if (action === 'navigate') {
|
||||
const path = resolvePage(params?.page);
|
||||
if (path) navigate(path);
|
||||
} else if (action === 'control') {
|
||||
window.dispatchEvent(new CustomEvent('dpm:media-control', { detail: { action: params?.action } }));
|
||||
} else if (action === 'alert') {
|
||||
window.dispatchEvent(new CustomEvent('dpm:alert', { detail: params || {} }));
|
||||
} else if (action === 'show_card') {
|
||||
window.dispatchEvent(new CustomEvent('dpm:show-card', { detail: params || {} }));
|
||||
}
|
||||
};
|
||||
window.addEventListener('dpm:mqtt-local', onLocal);
|
||||
return () => {
|
||||
off();
|
||||
window.removeEventListener('dpm:mqtt-local', onLocal);
|
||||
};
|
||||
}, [navigate, location.pathname]);
|
||||
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user