feat: 数据真实化、企业展示墙、全局手势识别与 3D 模型完善

- 数据大屏改用真实数据(xls 统计表 + 宣传册口径);TOKEN/工具调用基于真实锚点测算
- 新增企业展示墙页(/wall,39 家全量档案 + MQTT/admin/ESP32 控制文档)
- 新增全局手势识别(GlobalVision 全局挂载 + dpm:gesture 事件分发)
- 3D 模型墙面逐间修复、去除门/过道顶棚/调试标签、房间资产不穿墙、会议室多小桌
- 侧栏企业分布扩展至 39 家完整信息,分区按官方四大孵化区域
- 语音页多轮对话展示优化、去除页脚与检测横条、背景对齐
This commit is contained in:
Pine
2026-08-18 05:48:56 +08:00
parent 1581b62154
commit 0af159a175
21 changed files with 1690 additions and 588 deletions
+11 -35
View File
@@ -8,8 +8,6 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { S2sWsRealtimeClient } from '../voice/s2s-ws-client.js';
import useVisionDetection, { VISION_DWELL_MS } from '../voice/useVisionDetection.js';
import ParkOverviewStrip from '../components/ParkOverviewStrip';
import { useParkSim } from '../utils/parkData';
import { API_BASE } from '../config';
import '../styles/datascreen.css';
import './voice-original.css';
@@ -97,14 +95,14 @@ const icons = { micOn: <svg className="mic-on" viewBox="0 0 24 24" fill="none"
};
// 单条气泡:挂载后 rAF 加 `.in` 触发淡入(复刻原版 _spawnBubble 的进场动画)
function Bubble({ m }) {
function Bubble({ m, latest }) {
const [show, setShow] = useState(false);
useEffect(() => {
const raf = requestAnimationFrame(() => setShow(true));
return () => cancelAnimationFrame(raf);
}, []);
return (
<div className={`bubble ${m.role}${show ? ' in' : ''}${m.leaving ? ' out' : ''}`}>
<div className={`bubble ${m.role}${show ? ' in' : ''}${latest ? ' latest' : ' history'}`}>
<div className="bubble-role">{m.role === 'user' ? 'You' : 'Assistant'}</div>
<div className={`bubble-body${m.partial ? ' partial' : ''}`}>{m.text}</div>
</div>
@@ -118,8 +116,6 @@ export default function VoiceAssistant() {
const [errorMsg, setErrorMsg] = useState('');
// 全局人物识别开关(MQTT vision_set 控制)
const [visionEnabled, setVisionEnabled] = useState(true);
// 园区数据(页脚概览条;后端 /api/dashboard/snapshot,离线本地模拟兜底)
const d = useParkSim();
const clientRef = useRef(null);
const audioCtxRef = useRef(null);
@@ -203,32 +199,18 @@ export default function VoiceAssistant() {
const key = role === 'user' ? 'user' : 'assistant';
let next = [...messagesRef.current];
const last = next.length ? next[next.length - 1] : null;
if (partial && last && last.role === key && !last.leaving) {
// 同一条逐字流:更新文本 + 续期 4s(气泡保持显示直到说完)
scheduleBubbleDismiss(last.id);
if (partial && last && last.role === key) {
// 同一条逐字流:更新文本,持续展示不消失
next[next.length - 1] = { ...last, text, partial: true };
} else {
const id = `${Date.now()}-${Math.random()}`;
next.push({ id, role: key, text, partial });
// 上限 8 条:最旧优先淡出(保留正在更新的用户气泡
const visible = next.filter((m) => !m.leaving);
if (visible.length > 8) {
const activeUser = [...visible].reverse().find((m) => m.role === 'user' && m.partial);
const victim = visible.find((m) => m !== activeUser) ?? visible[0];
if (victim) {
next = next.map((m) => (m.id === victim.id ? { ...m, leaving: true } : m));
const remove = setTimeout(() => {
setMessages((prev) => prev.filter((x) => x.id !== victim.id));
bubbleTimersRef.current.delete(victim.id);
}, 400);
bubbleTimersRef.current.set(victim.id, { remove });
}
}
scheduleBubbleDismiss(id);
// 最多保留 4 条(最新大字 + 向上历史小字;正在逐字更新的消息位于末位,必然保留
next = next.slice(-4);
}
messagesRef.current = next;
setMessages(next);
}, [scheduleBubbleDismiss, markLeaving]);
}, []);
// ── 工具调用执行器:LLM 请求工具 → 转发后端 /api/tools/exec → 结果回传 ──
const onToolCall = useCallback(async (e) => {
@@ -596,12 +578,11 @@ export default function VoiceAssistant() {
<p id="circle-subcaption" className="circle-subcaption" hidden />
</main>
{/* 页脚:园区概览条(与数据大屏 / 媒体播放页一致) */}
<ParkOverviewStrip d={d} />
{/* 右下角气泡(复刻原版:进场 in / 4s 自动淡出 out / 阶梯字号) */}
<div id="bubble-stack" className="bubble-stack" aria-live="polite" aria-atomic="false">
{messages.map((m) => <Bubble key={m.id} m={m} />)}
{messages.slice(-4).map((m, i, arr) => (
<Bubble key={m.id} m={m} latest={i === arr.length - 1} />
))}
</div>
{/* 视觉识别:摄像头默认开启;video 始终渲染(ref 先于启动存在),未开启时隐藏 */}
@@ -621,12 +602,7 @@ export default function VoiceAssistant() {
</div>
</div>
{vision.status === 'error' && !vision.cameraOn && (
<div className="voice-vision-status" style={{ bottom: 62 }}> {visionErrorText}</div>
)}
{vision.status === 'facing' && (
<div className="voice-vision-status">
检测到 <b>{vision.faces} </b>正对摄像头 <b>{(vision.dwellMs / 1000).toFixed(1)}s</b>停留 {VISION_DWELL_MS / 1000}s 后自动问候
</div>
<div className="voice-vision-status"> {visionErrorText}</div>
)}
</div>
);