feat: 数据真实化、企业展示墙、全局手势识别与 3D 模型完善
- 数据大屏改用真实数据(xls 统计表 + 宣传册口径);TOKEN/工具调用基于真实锚点测算 - 新增企业展示墙页(/wall,39 家全量档案 + MQTT/admin/ESP32 控制文档) - 新增全局手势识别(GlobalVision 全局挂载 + dpm:gesture 事件分发) - 3D 模型墙面逐间修复、去除门/过道顶棚/调试标签、房间资产不穿墙、会议室多小桌 - 侧栏企业分布扩展至 39 家完整信息,分区按官方四大孵化区域 - 语音页多轮对话展示优化、去除页脚与检测横条、背景对齐
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user