Files
park-desktop/src/components/PageHeader.jsx
T
Pine daf6889580 feat(大屏): 园区登录 + 多租户 — 动态页眉名称/两段简介 + 租户 MQTT/数据
- 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 通过。
2026-08-24 18:15:11 +08:00

248 lines
10 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/eventsMQTT 走 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>
);
}