daf6889580
- utils/tenant.js:账密→长效 tenant token(localStorage 持久),login/logout/tenantFetch(附 tenant_id+token)。 - main.jsx 门禁:未登录显示 TenantLogin(登录页),登录后(App)。一次登录持久保持。 - PageHeader:名称+两段式简介改由 getTenantInfo()(后端 /park/auth/login 返回) 驱动,回退默认。 - mqtt.js:订阅 command/<tid>/<clientId> 与 tick/<tid>(租户频道);useMqttControl 心跳带 tenant_id。 - parkData.js:snapshot 走 tenantFetch(带 tenant_id+token),按租户取数。 - vite build 通过。
248 lines
10 KiB
React
248 lines
10 KiB
React
import { useEffect, useState, useRef } from 'react';
|
||
import { useNavigate, useLocation } from 'react-router-dom';
|
||
import Icon from './Icons';
|
||
import MediaHeaderControls from './MediaHeaderControls';
|
||
import AboutDialog from './AboutDialog';
|
||
import { PAGE_ORDER, usePageIndex } from '../utils/pageNav';
|
||
import { Avatar, AvatarImage, AvatarFallback } from '@appica/ui-react/avatar'
|
||
import { Sfx } from '../utils/sounds';
|
||
import { getApiBase as API_BASE } from '../config';
|
||
import { getTenantInfo } from '../utils/tenant';
|
||
|
||
/* =========================================================
|
||
共享页眉 —— 所有大屏页面统一(由 ScreenLayout 注入)
|
||
状态文案按当前页面自动识别;左右键循环 + 可点击按钮切换
|
||
· 音量面板:媒体音量 + 音效音量(写回后端设置并 MQTT 广播同步)
|
||
========================================================= */
|
||
|
||
const STATUS_BY_PATH = {
|
||
'/': '数据实时',
|
||
'/twin': '3D 实时',
|
||
'/ai': 'AI 助手',
|
||
'/screen': '媒体播放',
|
||
};
|
||
|
||
export default function PageHeader() {
|
||
const navigate = useNavigate();
|
||
const location = useLocation();
|
||
// 多租户:页眉名称 + 两段式简介取自园区登录信息(后端可配置),回退默认
|
||
const tInfo = getTenantInfo() || {};
|
||
const parkName = tInfo.name || '昆明市大学生创业园';
|
||
const parkSuffix = 'OPC 智能园区数字运营中心';
|
||
const intro0 = (tInfo.intro && tInfo.intro[0]) || '云南省首家政府主办大学生创业孵化园区';
|
||
const intro1 = (tInfo.intro && tInfo.intro[1]) || '空间+孵化+融资+政策+资源+AI赋能+综合服务';
|
||
const [now, setNow] = useState(new Date());
|
||
const [aboutOpen, setAboutOpen] = useState(false);
|
||
const [gestureHit, setGestureHit] = useState(false); // 识别到手势 → 惊喜图标 3s
|
||
const [vision, setVision] = useState({ status: 'off', cameraOn: false, gesture: null }); // 视觉识别状态
|
||
const [volOpen, setVolOpen] = useState(false);
|
||
const [mediaVol, setMediaVol] = useState(80); // 媒体音量 0-100
|
||
const [sfxVol, setSfxVol] = useState(Sfx.getVolume()); // 音效音量 0-100
|
||
const pageIdx = usePageIndex();
|
||
const gestureTimer = useRef(null);
|
||
|
||
// 启动时拉取后端设置(媒体/音效音量),并监听 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);
|
||
};
|
||
}, []);
|
||
|
||
// 点击音量容器(按钮+面板)外部 → 关闭面板;
|
||
// 用「目标是否在容器内」判断,避免面板内滑块交互(pointerdown)误关弹窗
|
||
const volWrapRef = useRef(null);
|
||
useEffect(() => {
|
||
if (!volOpen) return undefined;
|
||
const close = (e) => {
|
||
if (volWrapRef.current && !volWrapRef.current.contains(e.target)) {
|
||
setVolOpen(false);
|
||
}
|
||
};
|
||
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(() => {});
|
||
};
|
||
|
||
useEffect(() => {
|
||
const iv = setInterval(() => setNow(new Date()), 1000);
|
||
return () => clearInterval(iv);
|
||
}, []);
|
||
|
||
// 全局手势事件 → 惊喜图标 3s
|
||
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);
|
||
};
|
||
}, []);
|
||
|
||
// 视觉识别状态(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';
|
||
|
||
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 });
|
||
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;
|
||
|
||
return (
|
||
<header className="bd-header">
|
||
<div className="bd-header-left">
|
||
{/* <span className="bd-logo">
|
||
<Image src="/logo.png" alt="Logo" />
|
||
</span> */}
|
||
<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>
|
||
<div className="bd-header-titles">
|
||
<h1 className="bd-header-title">{parkName} <span className="bd-header-title-accent">· {parkSuffix}</span></h1>
|
||
<div className="bd-header-sub">{intro0} · <span className="bd-header-content—highlights">{intro1}</span></div>
|
||
</div>
|
||
{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>
|
||
)}
|
||
</div>
|
||
<div className="bd-header-right">
|
||
{/* 媒体轮播页:页眉内嵌播放控制 */}
|
||
{location.pathname === '/screen' && <MediaHeaderControls />}
|
||
{/* 音量控制(媒体 + 音效) */}
|
||
<div className="bd-vol-wrap" ref={volWrapRef}>
|
||
<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>
|
||
<div className="bd-nav-keys" title="左右方向键或点击切换页面">
|
||
{left && (
|
||
<button className="bd-nav-key" onClick={() => { Sfx.page(); navigate(left.path); }}>
|
||
<Icon name="chevron-left" size={13} />
|
||
{left.label}
|
||
</button>
|
||
)}
|
||
<span className="bd-nav-dot">·</span>
|
||
{right && (
|
||
<button className="bd-nav-key" onClick={() => { Sfx.page(); navigate(right.path); }}>
|
||
{right.label}
|
||
<Icon name="chevron-right" size={13} />
|
||
</button>
|
||
)}
|
||
</div>
|
||
<div className="bd-live">
|
||
<span className="bd-live-dot" />
|
||
{status}
|
||
</div>
|
||
<button
|
||
className={`bd-about-btn${gestureHit ? ' gesture-hit' : visionActive ? ' vision-active' : visionError ? ' vision-error' : ''}`}
|
||
type="button"
|
||
title={gestureHit ? '识别到手势' : visionActive ? `视觉识别中(${vision.status})` : visionError ? '视觉识别异常' : '关于 PineSound'}
|
||
aria-label="关于 PineSound"
|
||
onClick={() => setAboutOpen(true)}
|
||
>
|
||
<Icon name={gestureHit ? 'surprise' : 'explore'} size={15} />
|
||
</button>
|
||
<div className="bd-clock">
|
||
<div className="bd-clock-time">{timeStr}</div>
|
||
<div className="bd-clock-date">{dateStr} 周{week}</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 关于 PineSound */}
|
||
<AboutDialog open={aboutOpen} onClose={() => setAboutOpen(false)} />
|
||
</header>
|
||
);
|
||
}
|