2026-08-18 06:21:55 +08:00
|
|
|
|
import { useEffect, useState, useRef } from 'react';
|
2026-08-17 19:50:50 +08:00
|
|
|
|
import { useNavigate, useLocation } from 'react-router-dom';
|
2026-08-17 18:37:21 +08:00
|
|
|
|
import Icon from './Icons';
|
2026-08-17 22:45:46 +08:00
|
|
|
|
import MediaHeaderControls from './MediaHeaderControls';
|
2026-08-18 01:59:34 +08:00
|
|
|
|
import AboutDialog from './AboutDialog';
|
2026-08-17 19:50:50 +08:00
|
|
|
|
import { PAGE_ORDER, usePageIndex } from '../utils/pageNav';
|
|
|
|
|
|
import { Avatar, AvatarImage, AvatarFallback } from '@appica/ui-react/avatar'
|
2026-08-18 07:14:13 +08:00
|
|
|
|
import { Sfx } from '../utils/sounds';
|
|
|
|
|
|
import { getApiBase as API_BASE } from '../config';
|
2026-08-17 18:37:21 +08:00
|
|
|
|
|
|
|
|
|
|
/* =========================================================
|
2026-08-17 19:50:50 +08:00
|
|
|
|
共享页眉 —— 所有大屏页面统一(由 ScreenLayout 注入)
|
|
|
|
|
|
状态文案按当前页面自动识别;左右键循环 + 可点击按钮切换
|
2026-08-18 07:14:13 +08:00
|
|
|
|
· 音量面板:媒体音量 + 音效音量(写回后端设置并 MQTT 广播同步)
|
2026-08-17 18:37:21 +08:00
|
|
|
|
========================================================= */
|
|
|
|
|
|
|
2026-08-17 19:50:50 +08:00
|
|
|
|
const STATUS_BY_PATH = {
|
|
|
|
|
|
'/': '数据实时',
|
|
|
|
|
|
'/twin': '3D 实时',
|
2026-08-17 20:26:16 +08:00
|
|
|
|
'/ai': 'AI 助手',
|
2026-08-17 19:50:50 +08:00
|
|
|
|
'/screen': '媒体播放',
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export default function PageHeader() {
|
2026-08-17 18:37:21 +08:00
|
|
|
|
const navigate = useNavigate();
|
2026-08-17 19:50:50 +08:00
|
|
|
|
const location = useLocation();
|
2026-08-17 18:37:21 +08:00
|
|
|
|
const [now, setNow] = useState(new Date());
|
2026-08-18 01:59:34 +08:00
|
|
|
|
const [aboutOpen, setAboutOpen] = useState(false);
|
2026-08-18 06:21:55 +08:00
|
|
|
|
const [gestureHit, setGestureHit] = useState(false); // 识别到手势 → 惊喜图标 3s
|
2026-08-18 06:24:26 +08:00
|
|
|
|
const [vision, setVision] = useState({ status: 'off', cameraOn: false, gesture: null }); // 视觉识别状态
|
2026-08-18 07:14:13 +08:00
|
|
|
|
const [volOpen, setVolOpen] = useState(false);
|
|
|
|
|
|
const [mediaVol, setMediaVol] = useState(80); // 媒体音量 0-100
|
|
|
|
|
|
const [sfxVol, setSfxVol] = useState(Sfx.getVolume()); // 音效音量 0-100
|
2026-08-17 19:50:50 +08:00
|
|
|
|
const pageIdx = usePageIndex();
|
2026-08-18 06:21:55 +08:00
|
|
|
|
const gestureTimer = useRef(null);
|
2026-08-17 18:37:21 +08:00
|
|
|
|
|
2026-08-18 07:14:13 +08:00
|
|
|
|
// 启动时拉取后端设置(媒体/音效音量),并监听 MQTT settings_changed 同步
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
fetch(`${API_BASE()}/api/settings`, { cache: 'no-store' })
|
|
|
|
|
|
.then((r) => r.json())
|
|
|
|
|
|
.then((s) => {
|
|
|
|
|
|
if (typeof s.volume === 'number') setMediaVol(s.volume);
|
|
|
|
|
|
if (typeof s.sfx_volume === 'number') {
|
|
|
|
|
|
setSfxVol(s.sfx_volume);
|
|
|
|
|
|
Sfx.setVolume(s.sfx_volume);
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch(() => {});
|
|
|
|
|
|
const onSettings = (e) => {
|
|
|
|
|
|
const p = (e.detail && (e.detail.params || e.detail)) || {};
|
|
|
|
|
|
if (typeof p.volume === 'number') setMediaVol(p.volume);
|
|
|
|
|
|
if (typeof p.sfx_volume === 'number') {
|
|
|
|
|
|
setSfxVol(p.sfx_volume);
|
|
|
|
|
|
Sfx.setVolume(p.sfx_volume);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
// 后端/MQTT settings_changed → 同步音量(SSE 走 api/events,MQTT 走 dpm:media-control)
|
|
|
|
|
|
window.addEventListener('dpm:mqtt-local', onSettings);
|
|
|
|
|
|
window.addEventListener('dpm:media-control', onSettings);
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
window.removeEventListener('dpm:mqtt-local', onSettings);
|
|
|
|
|
|
window.removeEventListener('dpm:media-control', onSettings);
|
|
|
|
|
|
};
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-08-18 07:50:00 +08:00
|
|
|
|
// 点击音量容器(按钮+面板)外部 → 关闭面板;
|
|
|
|
|
|
// 用「目标是否在容器内」判断,避免面板内滑块交互(pointerdown)误关弹窗
|
|
|
|
|
|
const volWrapRef = useRef(null);
|
2026-08-18 07:14:13 +08:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!volOpen) return undefined;
|
2026-08-18 07:50:00 +08:00
|
|
|
|
const close = (e) => {
|
|
|
|
|
|
if (volWrapRef.current && !volWrapRef.current.contains(e.target)) {
|
|
|
|
|
|
setVolOpen(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
2026-08-18 07:14:13 +08:00
|
|
|
|
document.addEventListener('pointerdown', close);
|
|
|
|
|
|
return () => document.removeEventListener('pointerdown', close);
|
|
|
|
|
|
}, [volOpen]);
|
|
|
|
|
|
|
|
|
|
|
|
// 音量修改 → 写回后端(MQTT settings_changed 广播给所有大屏)
|
|
|
|
|
|
const changeVolume = (key, value) => {
|
|
|
|
|
|
const v = Math.max(0, Math.min(100, Number(value) || 0));
|
|
|
|
|
|
if (key === 'sfx_volume') {
|
|
|
|
|
|
setSfxVol(v);
|
|
|
|
|
|
Sfx.setVolume(v);
|
|
|
|
|
|
Sfx.notify();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setMediaVol(v);
|
|
|
|
|
|
}
|
|
|
|
|
|
fetch(`${API_BASE()}/api/settings`, {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
|
body: JSON.stringify({ [key]: v }),
|
|
|
|
|
|
}).catch(() => {});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-08-17 18:37:21 +08:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const iv = setInterval(() => setNow(new Date()), 1000);
|
|
|
|
|
|
return () => clearInterval(iv);
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-08-18 06:24:26 +08:00
|
|
|
|
// 全局手势事件 → 惊喜图标 3s
|
2026-08-18 06:21:55 +08:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const onGesture = (e) => {
|
|
|
|
|
|
if (!e.detail || !e.detail.type) return;
|
|
|
|
|
|
setGestureHit(true);
|
|
|
|
|
|
if (gestureTimer.current) clearTimeout(gestureTimer.current);
|
|
|
|
|
|
gestureTimer.current = setTimeout(() => setGestureHit(false), 3000);
|
|
|
|
|
|
};
|
|
|
|
|
|
window.addEventListener('dpm:gesture', onGesture);
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
window.removeEventListener('dpm:gesture', onGesture);
|
|
|
|
|
|
if (gestureTimer.current) clearTimeout(gestureTimer.current);
|
|
|
|
|
|
};
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-08-18 06:24:26 +08:00
|
|
|
|
// 视觉识别状态(GlobalVision 广播):off/loading/detecting/facing/triggered/silent/error
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const onStatus = (e) => setVision(e.detail || {});
|
|
|
|
|
|
window.addEventListener('dpm:vision-status', onStatus);
|
|
|
|
|
|
return () => window.removeEventListener('dpm:vision-status', onStatus);
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
// 图标状态:手势(绿) > 视觉识别中(浅蓝) > 默认(灰)
|
|
|
|
|
|
const visionActive = vision.cameraOn && vision.status !== 'error' && vision.status !== 'off';
|
|
|
|
|
|
const visionError = vision.status === 'error';
|
|
|
|
|
|
|
2026-08-17 18:37:21 +08:00
|
|
|
|
const dateStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
|
|
|
|
|
const week = ['日', '一', '二', '三', '四', '五', '六'][now.getDay()];
|
|
|
|
|
|
const timeStr = now.toLocaleTimeString('zh-CN', { hour12: false });
|
2026-08-17 19:50:50 +08:00
|
|
|
|
const status = STATUS_BY_PATH[location.pathname] || '在线';
|
|
|
|
|
|
|
|
|
|
|
|
// 循环中的相邻页面(左/右)
|
|
|
|
|
|
const n = PAGE_ORDER.length;
|
|
|
|
|
|
const left = pageIdx >= 0 ? PAGE_ORDER[(pageIdx - 1 + n) % n] : null;
|
|
|
|
|
|
const right = pageIdx >= 0 ? PAGE_ORDER[(pageIdx + 1) % n] : null;
|
2026-08-17 18:37:21 +08:00
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<header className="bd-header">
|
|
|
|
|
|
<div className="bd-header-left">
|
2026-08-17 19:50:50 +08:00
|
|
|
|
{/* <span className="bd-logo">
|
|
|
|
|
|
<Image src="/logo.png" alt="Logo" />
|
|
|
|
|
|
</span> */}
|
2026-08-19 13:14:00 +08:00
|
|
|
|
<span
|
|
|
|
|
|
className="bd-logo-update"
|
|
|
|
|
|
role="button"
|
|
|
|
|
|
tabIndex={0}
|
|
|
|
|
|
title="点击检测更新"
|
|
|
|
|
|
aria-label="点击检测更新"
|
|
|
|
|
|
onClick={() => window.dispatchEvent(new CustomEvent('dpm:check-update'))}
|
|
|
|
|
|
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') window.dispatchEvent(new CustomEvent('dpm:check-update')); }}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Avatar size="lg">
|
|
|
|
|
|
<AvatarImage src="/logo.png" alt="" />
|
|
|
|
|
|
</Avatar>
|
|
|
|
|
|
</span>
|
2026-08-17 18:37:21 +08:00
|
|
|
|
<div className="bd-header-titles">
|
|
|
|
|
|
<h1 className="bd-header-title">昆明市大学生创业园 <span className="bd-header-title-accent">· OPC 智能园区数字运营中心</span></h1>
|
2026-08-18 06:17:57 +08:00
|
|
|
|
<div className="bd-header-sub">云南省首家政府主办大学生创业孵化园区 · <span className="bd-header-content—highlights">空间+孵化+融资+政策+资源+AI赋能+综合服务</span></div>
|
2026-08-17 18:37:21 +08:00
|
|
|
|
</div>
|
2026-08-19 12:28:35 +08:00
|
|
|
|
{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>
|
2026-08-18 06:17:57 +08:00
|
|
|
|
</span>
|
2026-08-19 12:28:35 +08:00
|
|
|
|
)}
|
2026-08-17 18:37:21 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="bd-header-right">
|
2026-08-17 22:45:46 +08:00
|
|
|
|
{/* 媒体轮播页:页眉内嵌播放控制 */}
|
|
|
|
|
|
{location.pathname === '/screen' && <MediaHeaderControls />}
|
2026-08-18 07:14:13 +08:00
|
|
|
|
{/* 音量控制(媒体 + 音效) */}
|
2026-08-18 07:50:00 +08:00
|
|
|
|
<div className="bd-vol-wrap" ref={volWrapRef}>
|
2026-08-18 07:14:13 +08:00
|
|
|
|
<button
|
|
|
|
|
|
className={`bd-vol-btn${mediaVol === 0 ? ' muted' : ''}`}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
title="音量控制"
|
|
|
|
|
|
aria-label="音量控制"
|
|
|
|
|
|
onClick={() => { setVolOpen((v) => !v); Sfx.click(); }}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Icon name={mediaVol === 0 ? 'volume-off' : 'volume'} size={15} />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
{volOpen && (
|
|
|
|
|
|
<div className="bd-vol-panel" onClick={(e) => e.stopPropagation()}>
|
|
|
|
|
|
<div className="bd-vol-title"><Icon name="volume" size={13} /> 媒体音量</div>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="range" min="0" max="100" value={mediaVol}
|
|
|
|
|
|
onChange={(e) => changeVolume('volume', e.target.value)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<span className="bd-vol-val">{mediaVol}%</span>
|
|
|
|
|
|
<div className="bd-vol-title"><Icon name="sparkles" size={13} /> 音效音量</div>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="range" min="0" max="100" value={sfxVol}
|
|
|
|
|
|
onChange={(e) => changeVolume('sfx_volume', e.target.value)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<span className="bd-vol-val">{sfxVol}%</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2026-08-17 19:50:50 +08:00
|
|
|
|
<div className="bd-nav-keys" title="左右方向键或点击切换页面">
|
|
|
|
|
|
{left && (
|
2026-08-18 07:14:13 +08:00
|
|
|
|
<button className="bd-nav-key" onClick={() => { Sfx.page(); navigate(left.path); }}>
|
2026-08-17 19:50:50 +08:00
|
|
|
|
<Icon name="chevron-left" size={13} />
|
|
|
|
|
|
{left.label}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<span className="bd-nav-dot">·</span>
|
|
|
|
|
|
{right && (
|
2026-08-18 07:14:13 +08:00
|
|
|
|
<button className="bd-nav-key" onClick={() => { Sfx.page(); navigate(right.path); }}>
|
2026-08-17 19:50:50 +08:00
|
|
|
|
{right.label}
|
|
|
|
|
|
<Icon name="chevron-right" size={13} />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2026-08-17 18:37:21 +08:00
|
|
|
|
<div className="bd-live">
|
|
|
|
|
|
<span className="bd-live-dot" />
|
|
|
|
|
|
{status}
|
|
|
|
|
|
</div>
|
2026-08-18 01:59:34 +08:00
|
|
|
|
<button
|
2026-08-18 06:24:26 +08:00
|
|
|
|
className={`bd-about-btn${gestureHit ? ' gesture-hit' : visionActive ? ' vision-active' : visionError ? ' vision-error' : ''}`}
|
2026-08-18 01:59:34 +08:00
|
|
|
|
type="button"
|
2026-08-18 06:24:26 +08:00
|
|
|
|
title={gestureHit ? '识别到手势' : visionActive ? `视觉识别中(${vision.status})` : visionError ? '视觉识别异常' : '关于 PineSound'}
|
2026-08-18 01:59:34 +08:00
|
|
|
|
aria-label="关于 PineSound"
|
|
|
|
|
|
onClick={() => setAboutOpen(true)}
|
|
|
|
|
|
>
|
2026-08-18 06:21:55 +08:00
|
|
|
|
<Icon name={gestureHit ? 'surprise' : 'explore'} size={15} />
|
2026-08-18 01:59:34 +08:00
|
|
|
|
</button>
|
2026-08-17 18:37:21 +08:00
|
|
|
|
<div className="bd-clock">
|
|
|
|
|
|
<div className="bd-clock-time">{timeStr}</div>
|
|
|
|
|
|
<div className="bd-clock-date">{dateStr} 周{week}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-08-18 01:59:34 +08:00
|
|
|
|
|
|
|
|
|
|
{/* 关于 PineSound */}
|
|
|
|
|
|
<AboutDialog open={aboutOpen} onClose={() => setAboutOpen(false)} />
|
2026-08-17 18:37:21 +08:00
|
|
|
|
</header>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|