feat: Refactor DpmOverlays to use ShowCard for rendering cards and add ToolStatusToast for operation status notifications
- Moved card rendering logic from DpmOverlays to a new ShowCard component for better reusability. - Introduced ToolStatusToast to display real-time operation statuses in the top right corner. - Updated PageHeader to conditionally render credits based on the current path. - Modified PromptPanel to change tool names and update prompt titles. - Enhanced ScreenLayout to include ToolStatusToast. - Updated styles for new components and adjusted existing styles for consistency. - Implemented statusBus utility for dispatching tool status events. - Updated useMqttControl to integrate tool status notifications during navigation and card display actions.
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import Icon from './Icons';
|
||||
import ShowCard from './ShowCard';
|
||||
import { getApiBase as API_BASE } from '../config';
|
||||
import { Sfx } from '../utils/sounds';
|
||||
import { getMqttStatus } from '../utils/mqtt';
|
||||
import { aiStatus } from '../utils/statusBus';
|
||||
import { renderMarkdown } from '../voice/markdown.jsx';
|
||||
|
||||
/* =========================================================
|
||||
@@ -41,19 +43,49 @@ const nextId = () => ++msgId;
|
||||
|
||||
/** 执行后端返回的工具调用。
|
||||
后端已通过 MQTT 广播(发布成功时所有大屏都会收到,本地跳过避免重复);
|
||||
仅当 MQTT 发布失败 / 后端不可达时本地执行,保证当前屏幕也能响应 */
|
||||
仅当 MQTT 发布失败 / 后端不可达时本地执行,保证当前屏幕也能响应。
|
||||
show_card 除外:卡片已内联渲染进对话气泡,不再走全局弹窗。 */
|
||||
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') {
|
||||
if (type === 'navigate' || type === 'control' || type === 'alert') {
|
||||
if (type === 'navigate') aiStatus('正在切换页面', { icon: 'page' });
|
||||
else if (type === 'control') aiStatus('正在控制媒体播放', { icon: 'tool' });
|
||||
// alert:通知本身即展示,不再加状态提示
|
||||
window.dispatchEvent(new CustomEvent('dpm:mqtt-local', { detail: { action: type, params } }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 由 /api/ai/chat 响应生成「状态芯片」:知识库检索 + 工具/技能调用。 */
|
||||
function buildChips(data) {
|
||||
const chips = [];
|
||||
if (data && data.retrieved) chips.push({ icon: 'database', text: '知识库检索' });
|
||||
const labels = {
|
||||
navigate: '切换页面', control: '媒体控制', show_card: '展示卡片', alert: '发送通知',
|
||||
};
|
||||
const icons = { navigate: 'compass', control: 'player-play', show_card: 'sparkles', alert: 'broadcast' };
|
||||
(data?.tool_names || []).forEach((name) => {
|
||||
chips.push({ icon: icons[name] || 'wrench', text: `调用·${labels[name] || name}` });
|
||||
});
|
||||
return chips;
|
||||
}
|
||||
|
||||
/** 由 /api/ai/chat 响应提取「内联卡片」(show_card 工具 → 可视化卡片数据)。 */
|
||||
function buildCards(data) {
|
||||
const cards = [];
|
||||
(data?.tools || []).forEach((t) => {
|
||||
if (t && t.type === 'show_card') {
|
||||
const p = t.params || {};
|
||||
cards.push({ card: p.card || 'custom', title: p.title || '', content: p.content || '' });
|
||||
}
|
||||
});
|
||||
return cards;
|
||||
}
|
||||
|
||||
export default function AiChatPanel() {
|
||||
const [msgs, setMsgs] = useState(() => [{ id: nextId(), role: 'ai', text: WELCOME_TEXT }]);
|
||||
const [input, setInput] = useState('');
|
||||
@@ -111,8 +143,15 @@ export default function AiChatPanel() {
|
||||
setMsgs((prev) => [...prev, { id: nextId(), role: 'me', text, voice: viaVoice }]);
|
||||
setTyping(true);
|
||||
|
||||
// 右上角状态提示:检索 → 查询 → 语义检索(进行中)
|
||||
let s1, s2;
|
||||
aiStatus('正在检索知识库');
|
||||
s1 = setTimeout(() => aiStatus('正在查询相关信息'), 350);
|
||||
s2 = setTimeout(() => aiStatus('正在构建语义检索'), 800);
|
||||
|
||||
let reply = null;
|
||||
let tools = [];
|
||||
let data = null; // 提升到函数作用域,供 chips/cards 使用
|
||||
try {
|
||||
// 多轮:将历史对话(去掉系统首条问候)作为 messages 上下文
|
||||
const history = msgsRef.current
|
||||
@@ -125,16 +164,34 @@ export default function AiChatPanel() {
|
||||
body: JSON.stringify({ messages: history }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
data = await res.json();
|
||||
reply = data.reply;
|
||||
tools = data.tools || [];
|
||||
}
|
||||
} catch { /* 后端不可用 */ }
|
||||
|
||||
if (!reply) reply = pick(FALLBACK_REPLIES);
|
||||
clearTimeout(s1); clearTimeout(s2);
|
||||
setTyping(false);
|
||||
setMsgs((prev) => [...prev, { id: nextId(), role: 'ai', text: reply }]);
|
||||
setMsgs((prev) => [...prev, {
|
||||
id: nextId(), role: 'ai', text: reply,
|
||||
chips: buildChips(data), cards: buildCards(data),
|
||||
}]);
|
||||
Sfx.notify();
|
||||
|
||||
// 右上角状态:工具 / 检索 / 完成(通知 alert 除外——通知本身即展示)
|
||||
const statusTools = tools.filter((t) => t.type !== 'alert');
|
||||
if (statusTools.length) {
|
||||
const lbl = statusTools
|
||||
.map((t) => ({ navigate: '切换页面', control: '媒体控制', show_card: '展示卡片' }[t.type] || t.type))
|
||||
.filter(Boolean).join('、');
|
||||
aiStatus(`正在调用工具:${lbl}`, { icon: 'tool' });
|
||||
setTimeout(() => aiStatus('操作完成', { state: 'done', icon: 'check' }), 700);
|
||||
} else if (data?.retrieved) {
|
||||
aiStatus('已完成知识检索', { state: 'done', icon: 'check' });
|
||||
} else {
|
||||
aiStatus('已回复', { state: 'done', icon: 'check' });
|
||||
}
|
||||
executeLocalTools(tools);
|
||||
resetIdleTimer(); // 有提问 → 重置 1 分钟空闲计时
|
||||
}, [typing, resetIdleTimer]);
|
||||
@@ -336,6 +393,22 @@ export default function AiChatPanel() {
|
||||
<div className="bd-msg-bubble">
|
||||
{m.voice && <span className="bd-msg-voice-tag"><Icon name="microphone" size={10} />语音</span>}
|
||||
{m.role === 'ai' ? renderMarkdown(m.text) : m.text}
|
||||
{m.chips && m.chips.length > 0 && (
|
||||
<div className="bd-msg-chips">
|
||||
{m.chips.map((c, i) => (
|
||||
<span className="bd-msg-chip" key={i}><Icon name={c.icon} size={11} />{c.text}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{m.cards && m.cards.length > 0 && m.cards.map((card, i) => (
|
||||
<div className="bd-msg-card" key={i}>
|
||||
<div className="bd-msg-card-head">
|
||||
<span className="bd-msg-card-ico"><Icon name="sparkles" size={12} /></span>
|
||||
<b>{card.title || '信息卡片'}</b>
|
||||
</div>
|
||||
<ShowCard card={card} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
+44
-101
@@ -1,110 +1,39 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import Icon from './Icons';
|
||||
import { getApiBase as API_BASE } from '../config';
|
||||
import ShowCard from './ShowCard';
|
||||
import { Sfx } from '../utils/sounds';
|
||||
|
||||
/* =========================================================
|
||||
DpmOverlays —— 后端/AI 指令驱动的全局覆盖层
|
||||
· alert → 右上角通知(自动消失)
|
||||
· show_card → 中央信息卡片(企业分布 / 分区 / 园区总览 / 自定义)
|
||||
卡片渲染复用 ShowCard(也可内联进对话气泡)
|
||||
遵循 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 / 未知类型:标题 + 内容
|
||||
/* 关闭按钮图标:内联 SVG,避免依赖图标库中不存在的 "x" 名称导致空白按钮 */
|
||||
function CloseIcon({ size = 14 }) {
|
||||
return (
|
||||
<div className="bd-ov-card-body">
|
||||
<div className="bd-ov-card-text">{content || '(无内容)'}</div>
|
||||
</div>
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M6 6l12 12M18 6L6 18" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/* AI 对话页(/ai):卡片已内联进对话气泡,不再以全局弹窗弹出;其余页面保留弹窗 */
|
||||
const isAiChatPage = () =>
|
||||
typeof window !== 'undefined' && window.location.pathname.startsWith('/ai');
|
||||
|
||||
export default function DpmOverlays() {
|
||||
const [alert, setAlert] = useState(null); // {title, content, id}
|
||||
const [card, setCard] = useState(null); // {card, title, content, id}
|
||||
@@ -137,24 +66,38 @@ export default function DpmOverlays() {
|
||||
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 className="bd-overlay bd-overlay-alert" key={alert.id} role="alert">
|
||||
<div className="bd-alert-ico"><Icon name="broadcast" size={16} /></div>
|
||||
<div className="bd-alert-main">
|
||||
<div className="bd-alert-title">{alert.title}</div>
|
||||
{alert.content && <div className="bd-alert-text">{alert.content}</div>}
|
||||
</div>
|
||||
<button className="bd-overlay-close" onClick={() => setAlert(null)}><Icon name="x" size={13} /></button>
|
||||
<button
|
||||
className="bd-overlay-close"
|
||||
aria-label="关闭通知"
|
||||
title="关闭"
|
||||
onClick={() => setAlert(null)}
|
||||
>
|
||||
<CloseIcon size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{card && (
|
||||
{card && !isAiChatPage() && (
|
||||
<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>
|
||||
<button
|
||||
className="bd-overlay-close"
|
||||
aria-label="关闭卡片"
|
||||
title="关闭"
|
||||
onClick={() => setCard(null)}
|
||||
>
|
||||
<CloseIcon size={15} />
|
||||
</button>
|
||||
</div>
|
||||
<CardContent card={card} />
|
||||
<ShowCard card={card} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -148,13 +148,15 @@ export default function PageHeader() {
|
||||
<h1 className="bd-header-title">昆明市大学生创业园 <span className="bd-header-title-accent">· OPC 智能园区数字运营中心</span></h1>
|
||||
<div className="bd-header-sub">云南省首家政府主办大学生创业孵化园区 · <span className="bd-header-content—highlights">空间+孵化+融资+政策+资源+AI赋能+综合服务</span></div>
|
||||
</div>
|
||||
<span className="bd-credit">
|
||||
<Icon name="copyright" size={13} />
|
||||
<span className="bd-credit-text">
|
||||
<span className="bd-credit-name">云南派音人工智能科技有限公司</span>
|
||||
<span className="bd-credit-dev">开发</span>
|
||||
{location.pathname !== '/screen' && (
|
||||
<span className="bd-credit">
|
||||
<Icon name="copyright" size={13} />
|
||||
<span className="bd-credit-text">
|
||||
<span className="bd-credit-name">云南派音人工智能科技有限公司</span>
|
||||
<span className="bd-credit-dev">开发</span>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="bd-header-right">
|
||||
{/* 媒体轮播页:页眉内嵌播放控制 */}
|
||||
|
||||
@@ -33,7 +33,7 @@ export default function PromptPanel({ onSend }) {
|
||||
{ title: 'OPC 概念', questions: ['什么是 OPC?', 'OPC 常用的人工智能工具有哪些?'] },
|
||||
{ title: '园区企业介绍', questions: ['介绍一下园区入驻企业', '介绍一下云南派音人工智能科技'] },
|
||||
]);
|
||||
setTools(['DeepSeek-V3', '通义千问', '豆包', '剪映']);
|
||||
setTools(['DeepSeek', '通义千问', '豆包', '剪映']);
|
||||
}
|
||||
}
|
||||
})();
|
||||
@@ -51,8 +51,8 @@ export default function PromptPanel({ onSend }) {
|
||||
<aside className="bd-prompt-side">
|
||||
<div className="bd-prompt-head">
|
||||
<Icon name="bulb" size={15} />
|
||||
<b>您可以这样问</b>
|
||||
<span>预设问题 · 点击即向 AI 提问</span>
|
||||
<b>提问</b>
|
||||
<span>点击即向 AI 提问</span>
|
||||
</div>
|
||||
|
||||
<div className="bd-prompt-groups">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import PageHeader from './PageHeader';
|
||||
import DpmOverlays from './DpmOverlays';
|
||||
import ToolStatusToast from './ToolStatusToast';
|
||||
import GlobalVision from './GlobalVision';
|
||||
import { usePageNav } from '../utils/pageNav';
|
||||
import { useMqttControl } from '../utils/useMqttControl';
|
||||
@@ -27,6 +28,7 @@ export default function ScreenLayout() {
|
||||
{/* 全局覆盖层(通知 / 卡片 / 手势识别徽标) */}
|
||||
<GlobalVision />
|
||||
<DpmOverlays />
|
||||
<ToolStatusToast />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getApiBase as API_BASE } from '../config';
|
||||
|
||||
/* =========================================================
|
||||
ShowCard —— 信息卡片(企业分布 / 分区介绍 / 园区总览 / 自定义)
|
||||
复用 bd-ov-card-* 样式,既可用于 DpmOverlays 全局弹窗,
|
||||
也可内联渲染进对话气泡(AiChatPanel / VoiceAssistant)。
|
||||
card = { card: 'companies'|'zones'|'overview'|'custom', title?, content? }
|
||||
========================================================= */
|
||||
|
||||
export default function ShowCard({ card }) {
|
||||
const [data, setData] = useState(null);
|
||||
const { card: type, 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import Icon from './Icons';
|
||||
|
||||
/* =========================================================
|
||||
ToolStatusToast —— 右上角「操作进行中」状态提示
|
||||
监听 dpm:tool-status 事件,展示 AI 检索/查询/调用工具等实时状态。
|
||||
state: working(转圈) | done(绿色对勾) | error(红色)
|
||||
========================================================= */
|
||||
|
||||
const ICON_MAP = {
|
||||
search: 'database', // 检索知识库
|
||||
info: 'activity', // 查询/语义检索
|
||||
tool: 'wrench', // 调用工具
|
||||
page: 'compass', // 切换页面
|
||||
check: 'check',
|
||||
error: 'info',
|
||||
};
|
||||
|
||||
export default function ToolStatusToast() {
|
||||
const [toast, setToast] = useState(null);
|
||||
const timerRef = useRef(null);
|
||||
const idRef = useRef(0);
|
||||
|
||||
const show = useCallback((d = {}) => {
|
||||
const id = ++idRef.current;
|
||||
const state = d.state || 'working';
|
||||
setToast({ id, text: d.text || '', state, icon: ICON_MAP[d.icon] || 'activity' });
|
||||
clearTimeout(timerRef.current);
|
||||
const dur = d.duration != null ? d.duration : (state === 'working' ? 4200 : 2400);
|
||||
timerRef.current = setTimeout(() => setToast((t) => (t && t.id === id ? null : t)), dur);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const on = (e) => show(e.detail || {});
|
||||
window.addEventListener('dpm:tool-status', on);
|
||||
return () => {
|
||||
window.removeEventListener('dpm:tool-status', on);
|
||||
clearTimeout(timerRef.current);
|
||||
};
|
||||
}, [show]);
|
||||
|
||||
if (!toast) return null;
|
||||
return (
|
||||
<div className="ts-toast" key={toast.id} role="status">
|
||||
<span className={`ts-ico ${toast.state}`}><Icon name={toast.icon} size={14} /></span>
|
||||
<span className="ts-text">{toast.text}</span>
|
||||
{toast.state === 'working' && <span className="ts-spinner" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -25,7 +25,7 @@ import { useParkSim } from '../utils/parkData';
|
||||
|
||||
/* ---------- 模型占比(TOKEN 构成演示分布) ---------- */
|
||||
const MODELS = [
|
||||
{ name: 'DeepSeek-V3', value: 38, color: '#4c8dff' },
|
||||
{ name: 'DeepSeek', value: 38, color: '#4c8dff' },
|
||||
{ name: '通义千问', value: 21, color: '#22d3ee' },
|
||||
{ name: '智谱 GLM-4', value: 16, color: '#34d399' },
|
||||
{ name: '豆包', value: 12, color: '#fbbf24' },
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import Icon from '../components/Icons';
|
||||
import * as api from '../utils/api';
|
||||
import { getApiBase } from '../config';
|
||||
import { Sfx } from '../utils/sounds';
|
||||
@@ -557,6 +558,12 @@ export default function MediaScreen() {
|
||||
)}
|
||||
|
||||
{/* 播放控制已上移页眉(PageHeader 内渲染,媒体区保持纯净全屏) */}
|
||||
|
||||
{/* 左下角:版权/开发 玻璃质感芯片(顶部已移除) */}
|
||||
<div className="ms-credit-chip">
|
||||
<Icon name="copyright" size={12} />
|
||||
<span>云南派音人工智能科技有限公司 · 开发</span>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* 页脚:园区概览(与数据大屏一致) */}
|
||||
|
||||
@@ -9,6 +9,7 @@ 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 { getApiBase as API_BASE, getVoiceWsUrl } from '../config';
|
||||
import { aiStatus } from '../utils/statusBus';
|
||||
import '../styles/datascreen.css';
|
||||
import './voice-original.css';
|
||||
import './voice-overrides.css';
|
||||
@@ -115,8 +116,11 @@ export default function VoiceAssistant() {
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [muted, setMuted] = useState(false);
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
// 全局人物识别开关(MQTT vision_set 控制)
|
||||
// 摄像头常开:左下角视频预览保持显示(识别到人也不会自动问候,问候由 greetEnabled 控制)
|
||||
const [visionEnabled, setVisionEnabled] = useState(true);
|
||||
// 主动问候开关:默认关闭「识别到人 → 主动问候」,但摄像头/视频仍常开。
|
||||
// 需要恢复自动问候时改回 true,或用 MQTT 下发开启。
|
||||
const [greetEnabled, setGreetEnabled] = useState(false);
|
||||
|
||||
const clientRef = useRef(null);
|
||||
const audioCtxRef = useRef(null);
|
||||
@@ -133,6 +137,7 @@ export default function VoiceAssistant() {
|
||||
|
||||
const live = LIVE_STATES.has(state);
|
||||
|
||||
|
||||
// ── 自动问候生命周期状态(须在 start 之前定义:start 依赖数组引用 sendGreeting)──
|
||||
const GREETING_TEXT = '您好呀,需要我的帮助吗?';
|
||||
const GREETING_WAIT_MS = 25000; // 问候播完后等待用户回答的超时
|
||||
@@ -224,6 +229,14 @@ export default function VoiceAssistant() {
|
||||
try { args = JSON.parse(argsStr || '{}'); } catch { /* 忽略非法参数 */ }
|
||||
console.warn(`[tools] LLM 调用工具: ${name}`, args);
|
||||
pushMessage('assistant', `🔧 调用工具:${name}`, false);
|
||||
// 右上角状态提示
|
||||
const toolLabel = {
|
||||
control_display: args?.action === 'switch_page' ? '切换页面' : '控制大屏',
|
||||
get_park_overview: '查询园区数据',
|
||||
query_companies: '查询企业名录',
|
||||
get_time: '查询时间',
|
||||
}[name] || name;
|
||||
aiStatus(`正在调用工具:${toolLabel}`, { icon: name === 'control_display' && args?.action === 'switch_page' ? 'page' : 'tool' });
|
||||
let output;
|
||||
try {
|
||||
const res = await fetch(`${API_BASE()}/api/tools/exec`, {
|
||||
@@ -301,10 +314,23 @@ export default function VoiceAssistant() {
|
||||
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
|
||||
});
|
||||
streamRef.current = stream;
|
||||
if (!audioCtxRef.current) audioCtxRef.current = new AudioContext();
|
||||
if (audioCtxRef.current.state === 'suspended') await audioCtxRef.current.resume();
|
||||
// 复用一个仍在运行的上下文;若已关闭(此前被误关)或未创建,则重建。
|
||||
// 注意:client.close() 不再关闭共享上下文,故会话结束(含手势重启)后仍可复用。
|
||||
let _ctx = audioCtxRef.current;
|
||||
if (!_ctx || _ctx.state === 'closed') {
|
||||
_ctx = new AudioContext();
|
||||
audioCtxRef.current = _ctx;
|
||||
}
|
||||
if (_ctx.state === 'suspended') {
|
||||
try { await _ctx.resume(); } catch { /* ignore */ }
|
||||
// 无用户激活(手势)时 resume 可能异步生效,稍等再试一次
|
||||
if (_ctx.state === 'suspended') {
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
try { await _ctx.resume(); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
// 基础提示词:从后端拉取(配置 + 园区知识库已由服务端组装);失败用极简兜底
|
||||
// 基础提示词:从后端拉取(配置 + 园区知识库已由服务端组装);失败用极简兜底。
|
||||
let instructions = FALLBACK_INSTRUCTIONS;
|
||||
try {
|
||||
const insRes = await fetch(`${API_BASE()}/api/s2s/instructions`, { cache: 'no-store' });
|
||||
@@ -386,12 +412,20 @@ export default function VoiceAssistant() {
|
||||
if (c) { try { await c.close(); } catch { /* noop */ } }
|
||||
streamRef.current?.getTracks().forEach((t) => t.stop());
|
||||
streamRef.current = null;
|
||||
// 结束会话后丢弃 AudioContext 引用:下次启动一律新建,避免复用可能被
|
||||
// Chromium 自动挂起/关闭的旧上下文(否则麦克风不处理 → 一直「聆听」)。
|
||||
audioCtxRef.current = null;
|
||||
setMuted(false);
|
||||
setErrorMsg('');
|
||||
dismissAllBubbles();
|
||||
setState('idle');
|
||||
}, [dismissAllBubbles]);
|
||||
|
||||
// 离开语音对话页时强制停止会话与麦克风,避免残留触发/占用
|
||||
useEffect(() => {
|
||||
return () => { void stop(); };
|
||||
}, [stop]);
|
||||
|
||||
const toggleMute = useCallback(() => {
|
||||
const c = clientRef.current;
|
||||
if (!c) return;
|
||||
@@ -446,37 +480,18 @@ export default function VoiceAssistant() {
|
||||
});
|
||||
|
||||
// 手势 → 对话控制:举手 toggle —— 无对话则开始,正在对话则结束
|
||||
const onGesture = useCallback((g) => {
|
||||
if (g !== 'raise') return;
|
||||
if (clientRef.current) {
|
||||
void stop();
|
||||
} else {
|
||||
void start();
|
||||
}
|
||||
}, [start, stop]);
|
||||
// 【已按需求关闭】视觉手势识别误判会触发右上角弹窗/误开对话,故这里不再响应手势。
|
||||
const onGesture = useCallback(() => {
|
||||
// 视觉手势已关闭:识别到人脸/手势不再自动开始/结束对话
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
gestureRef.current = onGesture;
|
||||
}, [onGesture]);
|
||||
|
||||
const onVisionTrigger = useCallback(() => {
|
||||
// 仅 idle 允许自动问候:对话中/连接中/其他状态一律不打扰
|
||||
if (voiceStateRef.current !== 'idle') {
|
||||
console.warn('[vision] 自动问候跳过:当前状态', voiceStateRef.current);
|
||||
return;
|
||||
}
|
||||
pendingGreetingRef.current = true;
|
||||
if (clientRef.current) {
|
||||
sendGreeting('');
|
||||
} else {
|
||||
void start(); // start 连接成功后检查 pendingGreetingRef 再发问候
|
||||
}
|
||||
fetch(`${API_BASE()}/api/vision/event`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ event: 'triggered', faces: vision.faces, dwell_ms: VISION_DWELL_MS }),
|
||||
}).catch(() => { /* 上报失败不影响本地问候 */ });
|
||||
}, [vision.faces, start, sendGreeting]);
|
||||
// 主动问候已彻底关闭:识别到人(含停留 10 秒)不再自动问候/开始对话,也不弹任何提示
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
visionTriggerRef.current = onVisionTrigger;
|
||||
|
||||
+224
-28
@@ -2149,71 +2149,267 @@
|
||||
.bd-msg { max-width: 94%; }
|
||||
}
|
||||
|
||||
/* ---- AI 消息内联:状态芯片 + 可视化卡片(随气泡潜入对话) ---- */
|
||||
.bd-msg.ai { max-width: 92%; }
|
||||
.bd-msg-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px dashed rgba(120,160,220,0.28);
|
||||
}
|
||||
.bd-msg-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 9px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
border-radius: 20px;
|
||||
color: var(--bd-blue-deep);
|
||||
background: rgba(47,107,255,0.08);
|
||||
border: 1px solid rgba(47,107,255,0.18);
|
||||
}
|
||||
.bd-msg-chip .appica-ico { color: var(--bd-blue); }
|
||||
.bd-msg-card {
|
||||
margin-top: 10px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
background: #f7fafd;
|
||||
border: 1px solid var(--bd-line);
|
||||
}
|
||||
.bd-msg-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.bd-msg-card-head b {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--bd-ink);
|
||||
}
|
||||
.bd-msg-card-ico {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 7px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
background: linear-gradient(140deg, var(--bd-blue), var(--bd-cyan));
|
||||
}
|
||||
/* ---- 右上角:工具 / 操作进行中状态提示 ---- */
|
||||
.ts-toast {
|
||||
position: fixed;
|
||||
top: 84px;
|
||||
right: 22px;
|
||||
z-index: 3100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
max-width: 320px;
|
||||
padding: 11px 14px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--bd-line);
|
||||
border-left: 3px solid var(--bd-blue);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 12px 30px rgba(29, 44, 68, 0.14);
|
||||
animation: bdOverlayIn 0.25s ease;
|
||||
}
|
||||
.ts-ico {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
background: linear-gradient(140deg, var(--bd-blue), var(--bd-cyan));
|
||||
}
|
||||
.ts-ico.done { background: linear-gradient(140deg, var(--bd-green), #4ade80); }
|
||||
.ts-ico.error { background: linear-gradient(140deg, var(--bd-red), #f87171); }
|
||||
.ts-text {
|
||||
font-size: 12.5px;
|
||||
line-height: 1.4;
|
||||
color: var(--bd-ink);
|
||||
word-break: break-word;
|
||||
}
|
||||
.ts-spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
border: 2px solid rgba(47, 107, 255, 0.2);
|
||||
border-top-color: var(--bd-blue);
|
||||
border-radius: 50%;
|
||||
animation: tsSpin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes tsSpin { to { transform: rotate(360deg); } }
|
||||
|
||||
.bd-msg-card .bd-ov-card-body { padding: 0; max-height: 40vh; }
|
||||
|
||||
/* ---- 信息卡片内部布局(ShowCard:企业 / 分区 / 总览 / 自定义;弹窗与内联共用) ---- */
|
||||
.bd-ov-card-body {
|
||||
max-height: 46vh;
|
||||
overflow-y: auto;
|
||||
padding: 4px 2px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.bd-ov-card-group-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--bd-sub);
|
||||
margin-bottom: 9px;
|
||||
}
|
||||
.bd-ov-card-group-title::before {
|
||||
content: "";
|
||||
width: 4px;
|
||||
height: 13px;
|
||||
border-radius: 2px;
|
||||
background: linear-gradient(180deg, var(--bd-blue), var(--bd-cyan));
|
||||
}
|
||||
.bd-ov-card-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.bd-ov-card-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
color: var(--bd-ink);
|
||||
background: #fff;
|
||||
border: 1px solid var(--bd-line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.bd-ov-card-chip b {
|
||||
font-weight: 700;
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.bd-ov-card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.bd-msg-card .bd-ov-card-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.bd-ov-card-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
padding: 10px 12px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--bd-line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.bd-ov-card-stat span {
|
||||
font-size: 10.5px;
|
||||
color: var(--bd-sub);
|
||||
}
|
||||
.bd-ov-card-stat b {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--bd-ink);
|
||||
}
|
||||
.bd-ov-card-text {
|
||||
font-size: 12.5px;
|
||||
line-height: 1.7;
|
||||
color: var(--bd-ink);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* ============ 全局覆盖层(后端/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;
|
||||
top: 84px;
|
||||
right: 22px;
|
||||
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);
|
||||
gap: 12px;
|
||||
min-width: 280px;
|
||||
max-width: 380px;
|
||||
padding: 14px 14px 14px 12px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--bd-line);
|
||||
border-left: 3px solid var(--bd-blue);
|
||||
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;
|
||||
box-shadow: 0 12px 32px rgba(29, 44, 68, 0.14);
|
||||
animation: bdOverlayIn 0.28s ease;
|
||||
}
|
||||
.bd-overlay-alert-ico {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
.bd-alert-ico {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 8px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
background: linear-gradient(140deg, var(--bd-blue), var(--bd-cyan));
|
||||
background: linear-gradient(140deg, var(--bd-blue) 0%, var(--bd-cyan) 100%);
|
||||
box-shadow: 0 4px 10px rgba(47, 107, 255, 0.28);
|
||||
}
|
||||
.bd-overlay-alert-body {
|
||||
.bd-alert-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
gap: 4px;
|
||||
padding-top: 1px;
|
||||
}
|
||||
.bd-overlay-alert-body b {
|
||||
font-size: 13px;
|
||||
.bd-alert-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--bd-ink);
|
||||
line-height: 1.35;
|
||||
}
|
||||
.bd-overlay-alert-body span {
|
||||
font-size: 11.5px;
|
||||
line-height: 1.5;
|
||||
.bd-alert-text {
|
||||
font-size: 12.5px;
|
||||
line-height: 1.6;
|
||||
color: var(--bd-sub);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.bd-overlay-close {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: rgba(120,160,220,0.12);
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
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);
|
||||
background: rgba(240, 68, 56, 0.12);
|
||||
}
|
||||
.bd-overlay-close:active {
|
||||
transform: scale(0.92);
|
||||
}
|
||||
@keyframes bdOverlayIn {
|
||||
from { opacity: 0; transform: translateY(-8px); }
|
||||
|
||||
@@ -67,6 +67,29 @@
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
/* 左下角:版权/开发 玻璃质感芯片 */
|
||||
.ms-credit-chip {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
bottom: 14px;
|
||||
z-index: 8;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
border-radius: 20px;
|
||||
background: rgba(29, 44, 68, 0.32);
|
||||
border: 1px solid rgba(255, 255, 255, 0.22);
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18);
|
||||
backdrop-filter: blur(10px) saturate(140%);
|
||||
-webkit-backdrop-filter: blur(10px) saturate(140%);
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
.ms-credit-chip .appica-ico { color: rgba(255, 255, 255, 0.85); }
|
||||
|
||||
/* 媒体播放器(填满整个媒体区,等比裁切铺满) */
|
||||
.ms-media {
|
||||
position: absolute;
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/* =========================================================
|
||||
工具 / 操作进行中状态提示总线
|
||||
统一 dispatch `dpm:tool-status` 事件,由 ToolStatusToast 监听显示在右上角。
|
||||
用法:
|
||||
aiStatus('正在检索知识库') // 进行中(带转圈)
|
||||
aiStatus('正在调用工具:切换页面', { icon: 'tool' })
|
||||
aiStatus('操作完成', { state: 'done', icon: 'check' }) // 完成态
|
||||
aiStatus('出错了', { state: 'error', icon: 'error' })
|
||||
========================================================= */
|
||||
export function aiStatus(text, opts = {}) {
|
||||
window.dispatchEvent(new CustomEvent('dpm:tool-status', { detail: { text, ...opts } }));
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { onMqttMessage, connectMqtt, publishMqtt, getClientId, useMqttStatus } f
|
||||
import { MQTT_TOPIC_HEARTBEAT } from '../config';
|
||||
import { Sfx } from './sounds';
|
||||
import { PAGE_ORDER } from './pageNav';
|
||||
import { aiStatus } from './statusBus';
|
||||
|
||||
/* =========================================================
|
||||
MQTT 控制 —— 后端通过 opc/display/command 控制大屏
|
||||
@@ -72,7 +73,7 @@ export function useMqttControl() {
|
||||
switch (cmd.action) {
|
||||
case 'navigate': {
|
||||
const path = resolvePage(cmd.params?.page);
|
||||
if (path) { Sfx.page(); navigate(path); }
|
||||
if (path) { Sfx.page(); aiStatus('正在切换页面', { icon: 'page' }); navigate(path); }
|
||||
break;
|
||||
}
|
||||
case 'navigate_rel': {
|
||||
@@ -80,14 +81,17 @@ export function useMqttControl() {
|
||||
const idx = PAGE_ORDER.findIndex((p) => p.path === location.pathname);
|
||||
if (idx >= 0) {
|
||||
const n = PAGE_ORDER.length;
|
||||
aiStatus('正在切换页面', { icon: 'page' });
|
||||
navigate(PAGE_ORDER[(idx + delta + n) % n].path);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'alert':
|
||||
// 通知本身即展示,无需再弹「正在发送通知」状态提示
|
||||
window.dispatchEvent(new CustomEvent('dpm:alert', { detail: cmd.params || {} }));
|
||||
break;
|
||||
case 'show_card':
|
||||
aiStatus('正在展示信息卡片', { icon: 'tool' });
|
||||
window.dispatchEvent(new CustomEvent('dpm:show-card', { detail: cmd.params || {} }));
|
||||
break;
|
||||
case 'play':
|
||||
@@ -155,12 +159,15 @@ export function useMqttControl() {
|
||||
const { action, params } = e.detail || {};
|
||||
if (action === 'navigate') {
|
||||
const path = resolvePage(params?.page);
|
||||
if (path) navigate(path);
|
||||
if (path) { aiStatus('正在切换页面', { icon: 'page' }); navigate(path); }
|
||||
} else if (action === 'control') {
|
||||
aiStatus('正在控制媒体播放', { icon: 'tool' });
|
||||
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') {
|
||||
aiStatus('正在展示信息卡片', { icon: 'tool' });
|
||||
window.dispatchEvent(new CustomEvent('dpm:show-card', { detail: params || {} }));
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user