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:
Pine
2026-08-17 21:25:46 +08:00
parent 7f1c2ac4f0
commit 856ff88440
34 changed files with 3639 additions and 378 deletions
+165 -209
View File
@@ -1,241 +1,214 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import Icon from './Icons';
import { API_BASE } from '../config';
import { getMqttStatus } from '../utils/mqtt';
/* =========================================================
AI 智能助手对话面板(共享)—— 遵循 bd- 浅色科技 UI 规范
两种交互模式:
· 文字对话:消息流 + 快捷提问 + 输入发送
· 语音对话:语音交互按钮(聆听 → 识别 → 以文字进入对话流
AI 智能助手对话面板 —— 文字 / 语音双模式
· 文字:后端 /api/ai/chat(阿里云通义千问 + 工具调用)
· 语音:本地录音 → 后端 /api/ai/asr(阿里云 paraformer)→ 进入对话流
后端不可用时自动回退本地模拟(离线兜底
========================================================= */
/* ---------- 快捷提问 ---------- */
const QUICK_QUESTIONS = [
'如何申请入驻园区?',
'园区有哪些政策扶持?',
'办公场地怎么收费?',
'展示入驻企业卡片',
'AI 赋能服务有哪些?',
'三个分区有什么区别?',
'切换到数字孪生页面',
];
/* 语音识别兜底题库(无麦克风权限 / 环境不支持时模拟识别 */
const VOICE_SAMPLES = [
'如何申请入驻园区?',
'园区有哪些政策扶持?',
'办公场地怎么收费?',
'AI 赋能服务有哪些?',
'三个分区有什么区别?',
'园区有哪些入驻企业?',
'怎么联系创业导师?',
/* 离线兜底回复(后端不可用时 */
const FALLBACK_REPLIES = [
'园区提供「空间 + 孵化 + 融资 + 政策 + 资源 + AI 赋能 + 综合服务」七位一体服务,在园项目 158 家,累计带动就业 2,186 人,累计营收 2.08 亿元。',
'入驻流程:提交申请 → 项目评审 → 签订协议 → 安排场地并对接导师服务。在园项目 158 家,累计孵化 208 余家。',
'园区政策覆盖场地租金减免、创业补贴、税费优惠与高企认定申报辅导(在园高企 32 家)。',
'园区按成长阶段分三个区域:加速区(A1–A12)、国际区(I1–I7)、成长区(G1–G8),共 39 家入驻企业。',
];
const pick = (arr) => arr[Math.floor(Math.random() * arr.length)];
/* ---------- 园区知识库(模拟回复) ---------- */
const WELCOME = '您好,我是 OPC 园区 AI 智能助手。\n\n我可以为您解答入驻流程、政策扶持、场地服务、AI 赋能、企业名录等问题。您可以在「文字」模式输入问题,或切换到「语音」模式直接说话提问。';
const RULES = [
{
match: /入驻|申请|入孵|入园|如何加入|怎样加入/,
reply: '入驻流程如下:\n\n1. 提交入驻申请(线上表单或到园区服务中心现场办理)\n2. 项目评审(园区组织专家对项目可行性、团队背景进行评估)\n3. 签订入驻协议,办理入驻手续\n4. 安排办公场地,对接创业导师与孵化服务\n\n目前园区在园项目 158 家,累计孵化企业 208 余家。如需协助,可直接联系园区服务中心。',
},
{
match: /政策|补贴|优惠|扶持|减免|奖励|资助/,
reply: '园区政策服务覆盖「七位一体」中的政策维度:\n\n· 场地租金减免:符合条件的大学生创业项目可享受场地优惠\n· 创业补贴:协助对接省、市两级创业补贴与一次性创业资助\n· 税费优惠:对接小微企业所得税减免、增值税优惠等政策\n· 项目申报:指导高企认定(目前在园高新技术企业 32 家)、科技型中小企业备案\n\n更多政策细节可咨询园区政策服务窗口。',
},
{
match: /场地|办公室|工位|租金|收费|面积|多少钱|价格/,
reply: '园区总建筑面积 1.2 万㎡,按功能划分为三个区域:\n\n· 加速区(A1–A12):成长型项目办公室,共 11 间独立办公室 + 1 间园区管理办公室\n· 国际区(I1–I7):面向国际合作的办公空间(7 间)\n· 成长区(G1–G8):初创团队成长空间(8 间)\n\n租金按创业项目阶段与政策给予差异化优惠,具体可到园区服务中心咨询,或询问「入驻流程」。',
},
{
match: /AI|模型|算力|tokens|token|工具|智能|赋能|大模型/,
reply: '园区 AI 赋能服务是「七位一体」的核心特色:\n\n· 大模型推理:接入 DeepSeek-V3、通义千问、智谱 GLM-4、豆包、讯飞星火等多模型\n· 工具调用:文档生成、数据查询、图像创作、代码执行、语音合成等智能工具\n· 算力与成本:实时提供 token 消耗统计与速率监控,帮助企业控制 AI 使用成本\n\n今日园区 AI 推理任务持续运行,多模型负载均衡调度,服务节点 12/12 在线。',
},
{
match: /加速区|国际区|成长区|分区|区域|区别|布局/,
reply: '园区按企业成长阶段划分三个区域:\n\n· 加速区(蓝色):面向已具备产品与市场验证的成长型项目,提供独立办公室(A1–A12),目前在驻 9 家企业\n· 国际区(青色):面向国际合作与跨境业务项目(I1–I7),在驻 7 家\n· 成长区(绿色):面向初创团队的共享成长空间(G1–G8),在驻 8 家\n\n园区管理办公室位于加速区 M 房间,提供一站式服务。',
},
{
match: /企业|公司|名录|名单|入驻企业|有哪些企业|查.*企业/,
reply: '园区目前有 39 家入驻企业,分布在三个区域:\n\n· 加速区:云南派音人工智能科技、米勒克尔蓝宝石珠宝、云南宸中低空经济等 9 家\n· 国际区:仰光客厅、中越生物医疗、酷享野农AI农业等 7 家\n· 成长区:Facebook越南跨境电商、昆明云韵体育、云南星瑞航空等 8 家\n\n您也可以直接查看右侧「企业分布」面板,按分区浏览企业名录与房间号。',
},
{
match: /融资|投资|资金|贷款|估值|资本/,
reply: '园区融资服务包含:\n\n· 融资对接:定期组织路演与投资机构对接活动\n· 孵化估值:累计孵化企业估值约 5.2 亿元\n· 金融支持:协助对接银行创业贷款、政府创业担保贷款\n· 财务辅导:提供融资方案设计与商业计划书辅导\n\n有融资需求的企业可向园区服务中心提交商业计划书。',
},
{
match: /就业|招聘|岗位|人才|用工/,
reply: '园区累计带动就业 2,186 人(其中高校毕业生 1,680 人,实习岗位 506 个)。\n\n· 企业招聘:在驻企业持续发布岗位(今日新增多个岗位)\n· 人才对接:与在昆高校共建实习实训基地\n· 创业带动:园区鼓励以创业带动就业,多个项目已形成稳定用工\n\n具体招聘岗位可关注园区实时动态与入驻企业名录。',
},
{
match: /导师|培训|课程|辅导|孵化服务|孵化器/,
reply: '园区配备创业导师 58 位,提供全周期孵化服务:\n\n· 一对一辅导:创业导师定期坐诊,覆盖商业模式、法律财税、市场营销\n· 培训课程:创业训练营、行业专题沙龙、政策解读会\n· 资源对接:技术、供应链、渠道等产业资源链接\n· 公共服务:工商注册、知识产权、法律咨询一站式代办',
},
{
match: /你好|您好|hi|hello|嗨|在吗/,
reply: '您好!我是 OPC 园区 AI 智能助手。\n\n可以问我:入驻流程、政策扶持、场地收费、AI 赋能、企业名录、融资服务等问题,也可以切换到「语音」模式直接说话提问。',
},
{
match: /谢谢|感谢|好的|明白|ok/,
reply: '不客气,随时为您服务。您也可以点击右侧「企业分布」查看入驻企业,或继续向我提问。',
},
];
const DEFAULT_REPLY = '园区提供「空间 + 孵化 + 融资 + 政策 + 资源 + AI 赋能 + 综合服务」七位一体服务,在园项目 158 家,累计带动就业 2,186 人,累计营收 2.08 亿元。\n\n您可以这样问我:\n· 如何申请入驻园区?\n· 园区有哪些政策扶持?\n· AI 赋能服务有哪些?';
function getReply(input) {
const text = String(input || '').trim();
for (const rule of RULES) {
if (rule.match.test(text)) return rule.reply;
}
return DEFAULT_REPLY;
}
/* 本地兜底识别题库(ASR 不可用时) */
const VOICE_SAMPLES = QUICK_QUESTIONS;
/* ---------- 消息 ---------- */
let msgId = 0;
const nextId = () => ++msgId;
const pick = (arr) => arr[Math.floor(Math.random() * arr.length)];
/** 执行后端返回的工具调用。
后端已通过 MQTT 广播(发布成功时所有大屏都会收到,本地跳过避免重复);
仅当 MQTT 发布失败 / 后端不可达时本地执行,保证当前屏幕也能响应 */
function executeLocalTools(tools, mqttPublished) {
if (!Array.isArray(tools) || tools.length === 0) return;
if (mqttPublished !== false) return;
for (const tool of tools) {
const type = tool.type;
const params = tool.params || {};
if (type === 'navigate' || type === 'control' || type === 'alert' || type === 'show_card') {
window.dispatchEvent(new CustomEvent('dpm:mqtt-local', { detail: { action: type, params } }));
}
}
}
export default function AiChatPanel() {
const [msgs, setMsgs] = useState(() => [
{ id: nextId(), role: 'ai', text: WELCOME },
]);
const [msgs, setMsgs] = useState(() => [{ id: nextId(), role: 'ai', text: '您好,我是 OPC 园区 AI 智能助手(阿里云通义千问)。\n\n支持文字与语音提问,可以为您解答入驻流程、政策扶持、场地服务、AI 赋能等问题,还可以通过语音或文字让我切换页面、展示企业卡片。' }]);
const [input, setInput] = useState('');
const [typing, setTyping] = useState(false);
const [mode, setMode] = useState('text'); // text | voice
const [voiceState, setVoiceState] = useState('idle'); // idle | listening | processing
const [mode, setMode] = useState('text');
const [voiceState, setVoiceState] = useState('idle'); // idle | recording | transcribing
const scrollRef = useRef(null);
const timerRef = useRef(null);
const recRef = useRef(null);
const recorderRef = useRef(null);
const chunksRef = useRef([]);
const voiceStateRef = useRef(voiceState);
useEffect(() => { voiceStateRef.current = voiceState; }, [voiceState]);
// 新消息 / 状态变化自动滚动到底部
useEffect(() => {
const el = scrollRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [msgs, typing, mode, voiceState]);
// 卸载清理:定时器 + 语音识别
useEffect(() => {
return () => {
clearTimeout(timerRef.current);
try { recRef.current && recRef.current.abort && recRef.current.abort(); } catch { /* ignore */ }
if (recorderRef.current && recorderRef.current.state !== 'inactive') {
try { recorderRef.current.stop(); } catch { /* ignore */ }
}
};
}, []);
/** 生成用户消息并触发 AI 回复 */
const send = useCallback((raw, viaVoice = false) => {
/** 发送消息 → 后端 LLM */
const send = useCallback(async (raw, viaVoice = false) => {
const text = String(raw || '').trim();
if (!text || typing) return;
setInput('');
setMsgs((prev) => [...prev, { id: nextId(), role: 'me', text, voice: viaVoice }]);
setTyping(true);
const reply = getReply(text);
timerRef.current = setTimeout(() => {
setMsgs((prev) => [...prev, { id: nextId(), role: 'ai', text: reply }]);
setTyping(false);
}, 700 + Math.random() * 600);
let reply = null;
let tools = [];
try {
const res = await fetch(`${API_BASE}/api/ai/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: [{ role: 'user', content: text }] }),
});
if (res.ok) {
const data = await res.json();
reply = data.reply;
tools = data.tools || [];
}
} catch { /* 后端不可用 */ }
if (!reply) reply = pick(FALLBACK_REPLIES);
setTyping(false);
setMsgs((prev) => [...prev, { id: nextId(), role: 'ai', text: reply }]);
executeLocalTools(tools);
}, [typing]);
const clear = useCallback(() => {
clearTimeout(timerRef.current);
setTyping(false);
setMsgs([{ id: nextId(), role: 'ai', text: WELCOME }]);
setMsgs([{ id: nextId(), role: 'ai', text: '您好,我是 OPC 园区 AI 智能助手。有什么可以帮您?' }]);
}, []);
/* ================= 语音对话 ================= */
/* ================= 语音对话(录音 → 后端 ASR ================= */
// 供安全兜底定时器读取当前状态
const voiceStateRef = useRef(voiceState);
useEffect(() => { voiceStateRef.current = voiceState; }, [voiceState]);
/** 结束聆听:把识别出的文字送入对话流 */
const finishVoice = useCallback((text) => {
setVoiceState('processing');
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
setVoiceState('idle');
send(text, true);
}, 450);
}, [send]);
/** 兜底:环境不支持 / 无麦克风时模拟识别 */
const fallbackVoice = useCallback(() => {
if (recRef.current) { try { recRef.current.abort(); } catch { /* ignore */ } recRef.current = null; }
finishVoice(pick(VOICE_SAMPLES));
}, [finishVoice]);
/** 点击语音按钮:idle → 开始聆听;listening → 结束 */
const toggleVoice = useCallback(() => {
if (voiceState === 'processing') return;
if (voiceState === 'listening') {
// 用户主动结束:尝试取识别结果,无结果则兜底
clearTimeout(timerRef.current);
if (recRef.current) {
try { recRef.current.stop(); } catch { /* ignore */ }
}
return; // 由 onend/onerror 或兜底定时器完成收尾
const pickMimeType = () => {
const types = ['audio/mp4', 'audio/webm;codecs=opus', 'audio/ogg;codecs=opus', 'audio/webm'];
for (const t of types) {
if (window.MediaRecorder && MediaRecorder.isTypeSupported(t)) return t;
}
// 开始聆听
setVoiceState('listening');
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
if (SR) {
try {
const rec = new SR();
rec.lang = 'zh-CN';
rec.interimResults = false;
rec.maxAlternatives = 1;
let done = false;
rec.onresult = (e) => {
if (done) return;
done = true;
clearTimeout(timerRef.current);
const t = Array.from(e.results).map((r) => r[0].transcript).join(' ');
finishVoice(t || pick(VOICE_SAMPLES));
return '';
};
const startRecording = useCallback(() => {
const mime = pickMimeType();
if (!window.MediaRecorder || !navigator.mediaDevices?.getUserMedia) {
// 无录音能力:直接模拟
setVoiceState('transcribing');
timerRef.current = setTimeout(() => {
setVoiceState('idle');
send(pick(VOICE_SAMPLES), true);
}, 1200);
return;
}
navigator.mediaDevices.getUserMedia({ audio: true })
.then((stream) => {
const rec = new MediaRecorder(stream, mime ? { mimeType: mime } : undefined);
chunksRef.current = [];
rec.ondataavailable = (e) => { if (e.data && e.data.size > 0) chunksRef.current.push(e.data); };
rec.onstop = async () => {
stream.getTracks().forEach((t) => t.stop());
setVoiceState('transcribing');
const blob = new Blob(chunksRef.current, { type: mime || 'audio/webm' });
try {
const fmt = (mime || 'webm').split(';')[0].split('/')[1] || 'm4a';
const form = new FormData();
form.append('file', blob, `recording.${fmt === 'mp4' ? 'm4a' : fmt}`);
form.append('format', fmt === 'mp4' ? 'm4a' : fmt);
const res = await fetch(`${API_BASE}/api/ai/asr`, { method: 'POST', body: form });
const data = await res.json();
const text = (data && data.text || '').trim();
if (text) {
send(text, true);
} else {
setVoiceState('idle');
setMsgs((prev) => [...prev, { id: nextId(), role: 'ai', text: '抱歉,没有听清您的问题,请再试一次,或改用文字输入。' }]);
}
} catch {
// 后端不可用:回退本地模拟识别
setVoiceState('transcribing');
timerRef.current = setTimeout(() => {
setVoiceState('idle');
send(pick(VOICE_SAMPLES), true);
}, 1000);
}
};
rec.onerror = () => {
if (done) return;
done = true;
clearTimeout(timerRef.current);
fallbackVoice();
stream.getTracks().forEach((t) => t.stop());
setVoiceState('idle');
send(pick(VOICE_SAMPLES), true);
};
rec.onend = () => {
recRef.current = null;
if (!done) {
done = true;
clearTimeout(timerRef.current);
fallbackVoice();
}
};
recRef.current = rec;
recorderRef.current = rec;
rec.start();
// 安全兜底:4 秒内无结果则模拟(识别静默失败的环境)
setVoiceState('recording');
})
.catch(() => {
// 麦克风权限被拒:模拟
setVoiceState('transcribing');
timerRef.current = setTimeout(() => {
if (!done && voiceStateRef.current === 'listening') {
done = true;
try { rec.abort(); } catch { /* ignore */ }
fallbackVoice();
}
}, 4000);
return;
} catch { /* 继续走兜底 */ }
}
// 无 SpeechRecognition:模拟聆听 → 识别
timerRef.current = setTimeout(() => {
fallbackVoice();
}, 1300);
}, [voiceState, finishVoice, fallbackVoice]);
setVoiceState('idle');
send(pick(VOICE_SAMPLES), true);
}, 1200);
});
}, [send]);
const toggleVoice = useCallback(() => {
if (voiceStateRef.current === 'recording') {
const rec = recorderRef.current;
if (rec && rec.state === 'recording') {
rec.stop(); // 触发 onstop → 上传识别
} else {
setVoiceState('idle');
}
} else if (voiceStateRef.current === 'idle') {
startRecording();
}
}, [startRecording]);
// 切换模式时中止聆听
const switchMode = useCallback((m) => {
setMode(m);
if (voiceState === 'listening') {
clearTimeout(timerRef.current);
if (recRef.current) { try { recRef.current.abort(); } catch { /* ignore */ } recRef.current = null; }
if (voiceStateRef.current === 'recording') {
try { recorderRef.current && recorderRef.current.stop(); } catch { /* ignore */ }
setVoiceState('idle');
}
}, [voiceState]);
}, []);
const voiceHint = {
idle: '点击麦克风,说出你的问题',
listening: '正在聆听… 请说出你的问题',
processing: '正在识别语音…',
recording: '正在聆听… 再次点击结束',
transcribing: '正在识别语音…',
}[voiceState];
return (
@@ -245,41 +218,29 @@ export default function AiChatPanel() {
<span className="bd-chat-logo"><Icon name="robot" size={17} /></span>
<div className="bd-chat-titles">
<b>AI 智能助手</b>
<span>OPC 园区知识库 · 文字 / 语音双模式</span>
<span>阿里云通义千问 · 语音识别</span>
</div>
<div className="bd-chat-modes">
<button
className={mode === 'text' ? 'active' : ''}
onClick={() => switchMode('text')}
title="文字对话"
>
<button className={mode === 'text' ? 'active' : ''} onClick={() => switchMode('text')} title="文字对话">
<Icon name="message-circle" size={13} />文字
</button>
<button
className={mode === 'voice' ? 'active' : ''}
onClick={() => switchMode('voice')}
title="语音对话"
>
<button className={mode === 'voice' ? 'active' : ''} onClick={() => switchMode('voice')} title="语音对话">
<Icon name="microphone" size={13} />语音
</button>
</div>
<span className="bd-chat-model"><i />DeepSeek-V3</span>
<span className="bd-chat-model"><i />通义千问</span>
<button className="bd-chat-clear" title="清空对话" onClick={clear}>
<Icon name="eraser" size={15} />
</button>
</div>
{/* 消息区(文字 / 语音共用) */}
{/* 消息区 */}
<div className="bd-chat-msgs" ref={scrollRef}>
{msgs.map((m) => (
<div className={`bd-msg ${m.role}`} key={m.id}>
{m.role === 'ai' && (
<span className="bd-msg-avatar"><Icon name="robot" size={13} /></span>
)}
{m.role === 'ai' && <span className="bd-msg-avatar"><Icon name="robot" size={13} /></span>}
<div className="bd-msg-bubble">
{m.voice && (
<span className="bd-msg-voice-tag"><Icon name="microphone" size={10} />语音</span>
)}
{m.voice && <span className="bd-msg-voice-tag"><Icon name="microphone" size={10} />语音</span>}
{m.text}
</div>
</div>
@@ -292,7 +253,7 @@ export default function AiChatPanel() {
)}
</div>
{/* 文字模式:快捷提问 + 输入区 */}
{/* 文字模式 */}
{mode === 'text' && (
<>
<div className="bd-chat-quick">
@@ -306,7 +267,7 @@ export default function AiChatPanel() {
<div className="bd-chat-input">
<input
value={input}
placeholder="输入你的问题,如:如何申请入驻园区?"
placeholder="输入你的问题,如:展示入驻企业卡片"
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.nativeEvent.isComposing) {
@@ -315,39 +276,34 @@ export default function AiChatPanel() {
}
}}
/>
<button
className="bd-chat-send"
disabled={!input.trim() || typing}
onClick={() => send(input)}
title="发送"
>
<button className="bd-chat-send" disabled={!input.trim() || typing} onClick={() => send(input)} title="发送">
<Icon name="send" size={16} />
</button>
</div>
</>
)}
{/* 语音模式:语音交互按钮 */}
{/* 语音模式 */}
{mode === 'voice' && (
<div className="bd-chat-voice">
<div className={`bd-chat-voice-hint ${voiceState}`}>
{voiceState === 'listening' && <span className="bd-voice-wave"><i /><i /><i /><i /><i /></span>}
{voiceState === 'recording' && <span className="bd-voice-wave"><i /><i /><i /><i /><i /></span>}
{voiceHint}
</div>
<button
className={`bd-chat-mic ${voiceState}`}
onClick={toggleVoice}
title={voiceState === 'listening' ? '结束并发送' : '开始语音提问'}
title={voiceState === 'recording' ? '结束并发送' : '开始语音提问'}
>
{voiceState === 'listening' && <span className="bd-mic-ring r1" />}
{voiceState === 'listening' && <span className="bd-mic-ring r2" />}
{voiceState === 'recording' && <span className="bd-mic-ring r1" />}
{voiceState === 'recording' && <span className="bd-mic-ring r2" />}
<Icon
name={voiceState === 'listening' ? 'microphone-filled' : 'microphone'}
name={voiceState === 'recording' ? 'microphone-filled' : 'microphone'}
size={30}
/>
</button>
<div className="bd-chat-voice-tip">
{voiceState === 'listening' ? '再次点击结束并发送' : '点击开始说话 · 再次点击结束'}
{voiceState === 'recording' ? '再次点击结束并识别' : '点击开始说话 · 阿里云语音识别'}
</div>
</div>
)}
+160
View File
@@ -0,0 +1,160 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import Icon from './Icons';
import { API_BASE } from '../config';
/* =========================================================
DpmOverlays —— 后端/AI 指令驱动的全局覆盖层
· alert → 右上角通知(自动消失)
· show_card → 中央信息卡片(企业分布 / 分区 / 园区总览 / 自定义)
遵循 bd- 浅色科技 UI 规范,图标全部使用 appica SVG
========================================================= */
function CardContent({ card }) {
const [data, setData] = useState(null);
const { card: type, title, content } = card || {};
useEffect(() => {
let alive = true;
if (type === 'companies') {
fetch(`${API_BASE}/api/park/companies`)
.then((r) => r.json())
.then((d) => alive && setData(d.companies || []))
.catch(() => alive && setData([]));
} else if (type === 'zones') {
fetch(`${API_BASE}/api/park/zones`)
.then((r) => r.json())
.then((d) => alive && setData(d.zones || []))
.catch(() => alive && setData([]));
} else if (type === 'overview') {
fetch(`${API_BASE}/api/dashboard/snapshot`)
.then((r) => r.json())
.then((d) => alive && setData(d))
.catch(() => alive && setData(null));
}
return () => { alive = false; };
}, [type]);
if (type === 'companies') {
const groups = {};
(data || []).forEach((c) => {
(groups[c.zone] = groups[c.zone] || []).push(c);
});
return (
<div className="bd-ov-card-body">
{Object.keys(groups).map((z) => (
<div className="bd-ov-card-group" key={z}>
<div className="bd-ov-card-group-title">{z}</div>
<div className="bd-ov-card-chips">
{groups[z].map((c) => (
<span className="bd-ov-card-chip" key={c.room}>
<b style={{ color: c.color, borderColor: c.color }}>{c.room}</b>
{c.name}
</span>
))}
</div>
</div>
))}
</div>
);
}
if (type === 'zones') {
return (
<div className="bd-ov-card-body">
<div className="bd-ov-card-chips">
{(data || []).map((z) => (
<span className="bd-ov-card-chip" key={z.name}>
<b style={{ color: z.color, borderColor: z.color }}>{z.name}</b>
{z.count}
</span>
))}
</div>
</div>
);
}
if (type === 'overview' && data) {
const rows = [
['在园项目', `${data.projects?.inPark ?? '-'}`],
['累计孵化', `${data.projects?.cum ?? '-'}`],
['带动就业', `${(data.jobs?.total ?? 0).toLocaleString()}`],
['累计营收', `${(data.revenue?.total ?? 0) / 10000} 亿`],
['今日营收', `${data.revenue?.today ?? '-'}`],
['工具调用', `${(data.tools?.today ?? 0).toLocaleString()}`],
['AI 速率', `${data.token?.rate ?? '-'} t/s`],
['设备在线', `${data.park?.devices ?? '-'}%`],
];
return (
<div className="bd-ov-card-grid">
{rows.map(([k, v]) => (
<div className="bd-ov-card-stat" key={k}>
<span>{k}</span>
<b>{v}</b>
</div>
))}
</div>
);
}
// custom / 未知类型:标题 + 内容
return (
<div className="bd-ov-card-body">
<div className="bd-ov-card-text">{content || '(无内容)'}</div>
</div>
);
}
export default function DpmOverlays() {
const [alert, setAlert] = useState(null); // {title, content, id}
const [card, setCard] = useState(null); // {card, title, content, id}
const alertTimer = useRef(null);
const showAlert = useCallback((params) => {
const item = { title: params.title || '提示', content: params.content || '', id: Date.now() + Math.random() };
setAlert(item);
clearTimeout(alertTimer.current);
alertTimer.current = setTimeout(() => setAlert(null), 6000);
}, []);
const showCard = useCallback((params) => {
setCard({ card: params.card || 'custom', title: params.title || '', content: params.content || '', id: Date.now() + Math.random() });
}, []);
useEffect(() => {
const onAlert = (e) => showAlert(e.detail || {});
const onCard = (e) => showCard(e.detail || {});
window.addEventListener('dpm:alert', onAlert);
window.addEventListener('dpm:show-card', onCard);
return () => {
window.removeEventListener('dpm:alert', onAlert);
window.removeEventListener('dpm:show-card', onCard);
};
}, [showAlert, showCard]);
return (
<>
{alert && (
<div className="bd-overlay bd-overlay-alert" key={alert.id}>
<div className="bd-overlay-alert-ico"><Icon name="broadcast" size={15} /></div>
<div className="bd-overlay-alert-body">
<b>{alert.title}</b>
{alert.content && <span>{alert.content}</span>}
</div>
<button className="bd-overlay-close" onClick={() => setAlert(null)}><Icon name="x" size={13} /></button>
</div>
)}
{card && (
<div className="bd-overlay bd-overlay-mask" onClick={() => setCard(null)}>
<div className="bd-overlay-card" onClick={(e) => e.stopPropagation()}>
<div className="bd-overlay-card-head">
<span className="bd-overlay-card-ico"><Icon name="sparkles" size={15} /></span>
<b>{card.title || 'AI 助手卡片'}</b>
<button className="bd-overlay-close" onClick={() => setCard(null)}><Icon name="x" size={14} /></button>
</div>
<CardContent card={card} />
</div>
</div>
)}
</>
);
}
+4 -1
View File
@@ -20,7 +20,10 @@ export default function ParkOverviewStrip({ d }) {
];
return (
<footer className="bd-strip">
<div className="bd-strip-title"><Icon name="map-pin" size={14} /> 园区概览</div>
<div className="bd-strip-title">
<Icon name="map-pin" size={14} />
园区概览
</div>
<div className="bd-strip-chips">
{chips.map((c, i) => (
<div className="bd-chip" key={i}>
+8 -1
View File
@@ -1,16 +1,21 @@
import { Outlet } from 'react-router-dom';
import PageHeader from './PageHeader';
import DpmOverlays from './DpmOverlays';
import { usePageNav } from '../utils/pageNav';
import { useMqttControl } from '../utils/useMqttControl';
/* =========================================================
ScreenLayout —— 大屏页面统一布局
通过 App 嵌套路由注入统一页眉 + 全局左右键循环导航
通过 MQTT 接收后端控制(页面切换 / 媒体控制 / 卡片 / 通知)
页脚由各页面按需自行渲染(数字孪生页无需页脚)
========================================================= */
export default function ScreenLayout() {
// 全局左右方向键循环切换(数据大屏 / 数字孪生 / 媒体轮播)
// 全局左右方向键循环切换(数据大屏 / 数字孪生 / AI 助手 / 媒体轮播)
usePageNav();
// 后端 MQTT 控制通道(切页 / 媒体控制 / 卡片 / 通知)
useMqttControl();
return (
<div className="bd-layout">
@@ -18,6 +23,8 @@ export default function ScreenLayout() {
<main className="bd-layout-main">
<Outlet />
</main>
{/* 全局覆盖层(通知 / 卡片) */}
<DpmOverlays />
</div>
);
}
+19
View File
@@ -0,0 +1,19 @@
/* =========================================================
运行配置 —— 后端地址 / MQTT Broker
优先级:window.__DPM_*__ 全局变量 > Vite 环境变量(.env.local> 内置默认
部署时在项目根目录 .env.local 配置(已 gitignore):
VITE_API_BASE=http://192.168.x.x:18003
VITE_MQTT_URL=ws://192.168.x.x:8083/mqtt
VITE_MQTT_USERNAME=...
VITE_MQTT_PASSWORD=...
========================================================= */
const env = import.meta.env || {};
export const API_BASE = window.__DPM_API__ || env.VITE_API_BASE || 'http://localhost:10085';
export const MQTT_URL = window.__DPM_MQTT__ || env.VITE_MQTT_URL || 'ws://192.168.1.3:8083/mqtt';
export const MQTT_USERNAME = window.__DPM_MQTT_USER__ || env.VITE_MQTT_USERNAME || '';
export const MQTT_PASSWORD = window.__DPM_MQTT_PASS__ || env.VITE_MQTT_PASSWORD || '';
export const MQTT_TOPIC_COMMAND = 'opc/display/command';
export const MQTT_TOPIC_TICK = 'opc/dashboard/tick';
+4 -119
View File
@@ -2,10 +2,11 @@ import { useState, useEffect, useRef } from 'react';
import '../styles/datascreen.css';
import Icon from '../components/Icons';
import ParkOverviewStrip from '../components/ParkOverviewStrip';
import { useParkSim } from '../utils/parkData';
/* =========================================================
昆明市大学生创业园 · OPC 智能园区数字运营中心
—— 数据大屏(色科技风 · 实时模拟数据)
—— 数据大屏(色科技风 · 数据来自 FastAPI 后端
---------------------------------------------------------
基础数据来源:
· 《昆明市创业园宣传册》:在园项目 150+ / 累计孵化 200+ /
@@ -13,7 +14,7 @@ import ParkOverviewStrip from '../components/ParkOverviewStrip';
高企 32 家 / 重点研发项目 18 项 / 大赛奖项 47 项
· 《昆明市大学生创业园入驻企业信息表》:39 家本期入驻企业
(区域、行业、名称)
大屏实时数据为演示模拟(每 2.2s 跳动一次
大屏实时数据由后端 GET /api/dashboard/snapshot 提供(每 2.2s 刷新
========================================================= */
/* ---------- 真实基础数据(入驻企业信息表) ---------- */
@@ -100,129 +101,13 @@ const TOOL_RANK = [
{ name: '语音合成', value: 10 },
];
const TOOL_NAMES = TOOL_RANK.map((t) => t.name);
const MODEL_NAMES = MODELS.map((m) => m.name);
const COMPANY_NAMES = PROJECTS.map((p) => p[0]);
/* ---------- 工具函数 ---------- */
const pick = (arr) => arr[Math.floor(Math.random() * arr.length)];
const fmtInt = (n) => Math.round(n).toLocaleString('zh-CN');
const fmtWan = (n) => (n >= 10000 ? (n / 10000).toFixed(2) + '亿' : n >= 100 ? n.toFixed(1) + '万' : n.toFixed(1));
const nowTime = () => new Date().toLocaleTimeString('zh-CN', { hour12: false });
function makeSeries(base, growth, noise, n = 30) {
const arr = [];
let v = base;
for (let i = 0; i < n; i++) {
v = v * (1 + growth) + (Math.random() - 0.5) * noise;
arr.push(Math.max(1, Math.round(v * 100) / 100));
}
return arr;
}
/* 实时动态事件模板 */
function genEvent() {
const r = Math.random;
const pool = [
{ icon: 'bolt', text: `AI 推理任务完成 · 消耗 ${Math.round(800 + r() * 9000).toLocaleString()} tokens${pick(MODEL_NAMES)}` },
{ icon: 'wrench', text: `工具「${pick(TOOL_NAMES)}」被调用 ${Math.round(10 + r() * 90)}` },
{ icon: 'building', text: `${pick(COMPANY_NAMES)}」提交入驻申请 · 进入评审流程` },
{ icon: 'users', text: `${pick(COMPANY_NAMES)}」新增招聘岗位 ${Math.round(1 + r() * 5)}` },
{ icon: 'coin', text: `园区企业完成一笔 ¥${(0.5 + r() * 9).toFixed(1)}万 交易` },
{ icon: 'broadcast', text: `创业沙龙「AI 一人公司实战课」报名开启` },
{ icon: 'robot', text: `${pick(MODEL_NAMES)}」模型完成一次微调任务` },
{ icon: 'flask', text: `在孵项目完成实验验证 · 进入加速评审` },
];
const e = pick(pool);
return { id: Date.now() + Math.random(), icon: e.icon, text: e.text, time: nowTime() };
}
function initFeed() {
return [
{ id: 1, icon: 'bolt', text: '云南派音AI 完成音频向量嵌入任务 · 消耗 12,480 tokensDeepSeek-V3', time: '09:02:14' },
{ id: 2, icon: 'building', text: '「启元人工智能科技」通过评审 · 正式入驻 OPC 创业空间', time: '09:05:41' },
{ id: 3, icon: 'wrench', text: '工具「文档生成」被调用 36 次', time: '09:11:03' },
{ id: 4, icon: 'coin', text: '园区企业完成一笔 ¥3.6万 交易', time: '09:17:26' },
{ id: 5, icon: 'users', text: '「昆明舒护安养老服务」新增招聘岗位 2 个', time: '09:23:58' },
];
}
/* ---------- 模拟数据引擎(每 2.2s 跳动) ---------- */
function initSnapshot() {
return {
t: 0,
token: { today: 128.64, total: 12840, rate: 84.6, series: makeSeries(82, 0.012, 9) },
tools: { today: 3568, total: 365204, success: 98.7 },
projects: { inPark: 158, cum: 208, todayNew: 2 },
jobs: { total: 2186, todayNew: 3 },
revenue: { today: 38.6, total: 20800, growth: 8.2, series: makeSeries(30, 0.006, 4) },
park: { devices: 98.6, energy: 386, people: 127, desk: 76, meeting: 3, nodes: 12 },
feed: initFeed(),
};
}
function nextSnapshot(s) {
const r = Math.random;
const tDeltaWan = +(0.26 + r() * 0.34).toFixed(2); // 万 tokens
const token = {
today: +(s.token.today + tDeltaWan).toFixed(2),
total: +(s.token.total + tDeltaWan).toFixed(2),
rate: +(76 + r() * 20).toFixed(1),
};
const toolDelta = Math.round(13 + r() * 22);
const tools = {
today: s.tools.today + toolDelta,
total: s.tools.total + toolDelta,
success: +(98.1 + r() * 1.2).toFixed(1),
};
const revDelta = +(0.6 + r() * 1.7).toFixed(1);
const revenue = {
...s.revenue,
today: +(s.revenue.today + revDelta).toFixed(1),
total: +(s.revenue.total + revDelta).toFixed(1),
growth: +(7.2 + r() * 2.2).toFixed(1),
};
const park = {
devices: +(97.6 + r() * 1.6).toFixed(1),
energy: Math.round(320 + r() * 130),
people: Math.round(80 + r() * 95),
desk: Math.round(62 + r() * 24),
meeting: Math.round(2 + r() * 4),
nodes: 12,
};
const projects = { ...s.projects };
if (r() < 0.055) projects.todayNew += 1;
if (r() < 0.035) { projects.inPark += 1; projects.cum += 1; }
const jobs = { ...s.jobs };
if (r() < 0.08) jobs.todayNew += 1;
if (r() < 0.05) jobs.total += 1;
// 30 日曲线:今日点持续爬升,每 12 个 tick 滚动一天
let tSeries = s.token.series.slice();
tSeries[tSeries.length - 1] = +(tSeries[tSeries.length - 1] + tDeltaWan).toFixed(2);
if (s.t % 12 === 11) tSeries = [...tSeries.slice(1), +(72 + r() * 30).toFixed(2)];
token.series = tSeries;
let rSeries = s.revenue.series.slice();
rSeries[rSeries.length - 1] = +(rSeries[rSeries.length - 1] + revDelta).toFixed(1);
if (s.t % 12 === 11) rSeries = [...rSeries.slice(1), +(26 + r() * 9).toFixed(1)];
revenue.series = rSeries;
const feed = s.t % 3 === 2 ? [genEvent(), ...s.feed].slice(0, 7) : s.feed;
return { ...s, t: s.t + 1, token, tools, revenue, park, projects, jobs, feed };
}
function useParkSim() {
const [snap, setSnap] = useState(initSnapshot);
useEffect(() => {
const iv = setInterval(() => setSnap((p) => nextSnapshot(p)), 2200);
return () => clearInterval(iv);
}, []);
return snap;
}
/* 数据源:后端 /api/dashboard/snapshot(离线时本地模拟兜底),见 utils/parkData.js */
/* ---------- 通用 Hooks ---------- */
+41
View File
@@ -292,6 +292,47 @@ export default function MediaScreen() {
}
}, []);
// MQTT 控制通道(后端 opc/display/command → window 事件)
useEffect(() => {
const handler = (e) => {
const msg = e.detail || {};
switch (msg.action) {
case 'play':
setPaused(false);
break;
case 'pause':
setPaused(true);
if (timerRef.current) clearTimeout(timerRef.current);
if (videoRef.current) videoRef.current.pause();
break;
case 'next':
setPaused(false);
skip(1);
break;
case 'prev':
setPaused(false);
skip(-1);
break;
case 'set_mode':
if (msg.params && msg.params.mode) setPlayMode(msg.params.mode);
break;
case 'minimize':
getCurrentWindow().minimize().catch(() => {});
break;
case 'playlist_changed':
reloadPlaylist();
break;
case 'settings_changed':
applySettings(msg.params || msg);
break;
default:
break;
}
};
window.addEventListener('dpm:media-control', handler);
return () => window.removeEventListener('dpm:media-control', handler);
}, [skip, reloadPlaylist, applySettings]);
const enableSound = useCallback(() => {
const video = videoRef.current;
if (!video) return;
+189
View File
@@ -2137,6 +2137,195 @@
.bd-msg { max-width: 94%; }
}
/* ============ 全局覆盖层(后端/AI 指令:通知 + 卡片) ============ */
.bd-overlay {
font-family: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif;
}
.bd-overlay-alert {
position: fixed;
top: 74px;
right: 18px;
z-index: 3000;
display: flex;
align-items: flex-start;
gap: 10px;
max-width: 360px;
padding: 12px 14px;
background: rgba(255,255,255,0.92);
border: 1px solid rgba(47,107,255,0.22);
border-radius: 12px;
box-shadow: 0 10px 30px rgba(47,107,255,0.16);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
animation: bdOverlayIn 0.3s ease;
}
.bd-overlay-alert-ico {
width: 28px;
height: 28px;
flex-shrink: 0;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
background: linear-gradient(140deg, var(--bd-blue), var(--bd-cyan));
}
.bd-overlay-alert-body {
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.bd-overlay-alert-body b {
font-size: 13px;
color: var(--bd-ink);
}
.bd-overlay-alert-body span {
font-size: 11.5px;
line-height: 1.5;
color: var(--bd-sub);
}
.bd-overlay-close {
width: 22px;
height: 22px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 6px;
background: rgba(120,160,220,0.12);
color: var(--bd-sub);
cursor: pointer;
transition: all 0.2s;
}
.bd-overlay-close:hover {
color: var(--bd-red);
background: rgba(240,68,56,0.1);
}
@keyframes bdOverlayIn {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
.bd-overlay-mask {
position: fixed;
inset: 0;
z-index: 2500;
display: flex;
align-items: center;
justify-content: center;
background: rgba(29,44,68,0.28);
backdrop-filter: blur(3px);
-webkit-backdrop-filter: blur(3px);
animation: bdOverlayIn 0.25s ease;
}
.bd-overlay-card {
width: min(680px, 86vw);
max-height: 78vh;
overflow: hidden;
display: flex;
flex-direction: column;
background: rgba(255,255,255,0.94);
border: 1px solid rgba(120,160,220,0.25);
border-radius: 16px;
box-shadow: 0 24px 60px rgba(29,44,68,0.22);
}
.bd-overlay-card-head {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 10px;
padding: 14px 18px;
background: linear-gradient(180deg, rgba(76,194,255,0.08), rgba(255,255,255,0));
border-bottom: 1px solid rgba(120,160,220,0.14);
}
.bd-overlay-card-ico {
width: 30px;
height: 30px;
flex-shrink: 0;
border-radius: 9px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
background: linear-gradient(140deg, var(--bd-blue), var(--bd-cyan));
}
.bd-overlay-card-head b {
flex: 1;
min-width: 0;
font-size: 15px;
color: var(--bd-ink);
}
.bd-overlay-card-body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 16px 18px;
}
.bd-overlay-card-group {
margin-bottom: 14px;
}
.bd-overlay-card-group-title {
font-size: 12px;
font-weight: 700;
color: var(--bd-sub);
margin-bottom: 8px;
letter-spacing: 0.06em;
}
.bd-overlay-card-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.bd-overlay-card-chip {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 6px 11px;
font-size: 11.5px;
color: #33415c;
background: rgba(255,255,255,0.85);
border: 1px solid rgba(120,160,220,0.18);
border-radius: 9px;
}
.bd-overlay-card-chip b {
font-family: "JetBrains Mono", monospace;
font-size: 10.5px;
font-weight: 700;
border: 1px solid;
border-radius: 5px;
padding: 1px 6px;
}
.bd-overlay-card-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
}
.bd-overlay-card-stat {
padding: 10px 12px;
background: rgba(255,255,255,0.85);
border: 1px solid rgba(120,160,220,0.16);
border-radius: 10px;
display: flex;
flex-direction: column;
gap: 4px;
}
.bd-overlay-card-stat span {
font-size: 10.5px;
color: var(--bd-sub);
}
.bd-overlay-card-stat b {
font-size: 16px;
color: var(--bd-blue);
font-family: "JetBrains Mono", monospace;
}
.bd-overlay-card-text {
font-size: 13px;
line-height: 1.7;
color: #33415c;
white-space: pre-line;
}
/* 页眉左右切换按钮(← 左页 · 右页 →,可点击) */
.bd-nav-keys {
display: inline-flex;
+3 -12
View File
@@ -1,15 +1,6 @@
// API 基地址
// 同源请求(hostname=localhost/127.0.0.1):`''` 相对路径,开发模式或 macOS Tauri
// Windows Taurihostname 含 "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 = {}) {
+82
View File
@@ -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 HookMQTT 连接状态 */
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
View File
@@ -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;
}
+95
View File
@@ -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;
}