refactor: 页眉由 App 统一注入(ScreenLayout),三页共用一致页眉
- 新增 ScreenLayout 布局路由:注入共享 PageHeader + 全局左右键循环导航 - PageHeader 无 props,按路由自动识别状态(数据实时/3D 实时/媒体播放) - DataScreen / DigitalTwin / MediaScreen 移除各自页眉,避免重复 - 页脚按页面按需保留(数据大屏 + 媒体轮播,数字孪生无) - 管理后台不参与导航循环;旧 /screen2 路由移除 - 补齐 MediaScreen 音量/播放/翻页图标与浅色主题样式
This commit is contained in:
+10
-5
@@ -1,19 +1,24 @@
|
|||||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
import Admin from './pages/Admin';
|
import Admin from './pages/Admin';
|
||||||
import Screen from './pages/Screen';
|
import MediaScreen from './pages/MediaScreen';
|
||||||
import DataScreen from './pages/DataScreen';
|
import DataScreen from './pages/DataScreen';
|
||||||
import DigitalTwin from './pages/DigitalTwin';
|
import DigitalTwin from './pages/DigitalTwin';
|
||||||
|
import ScreenLayout from './components/ScreenLayout';
|
||||||
import './styles/tokens.css';
|
import './styles/tokens.css';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/admin" element={<Admin />} />
|
{/* 大屏页面:统一页眉布局注入 */}
|
||||||
<Route path="/screen" element={<Screen />} />
|
<Route element={<ScreenLayout />}>
|
||||||
<Route path="/screen2" element={<DataScreen />} />
|
<Route path="/" element={<DataScreen />} />
|
||||||
<Route path="/twin" element={<DigitalTwin />} />
|
<Route path="/twin" element={<DigitalTwin />} />
|
||||||
<Route path="*" element={<Navigate to="/screen" replace />} />
|
<Route path="/screen" element={<MediaScreen />} />
|
||||||
|
</Route>
|
||||||
|
{/* 管理后台:独立布局(不参与大屏循环) */}
|
||||||
|
<Route path="/admin" element={<Admin />} />
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Check, Gauge, Cpu, Server, Database, Sparkles, Bulb, Settings,
|
Check, Gauge, Cpu, Server, Database, Sparkles, Bulb, Settings,
|
||||||
Heart, Pill, Dna, Microscope, Atom, MapPin, Flag, Award, Medal,
|
Heart, Pill, Dna, Microscope, Atom, MapPin, Flag, Award, Medal,
|
||||||
History, Drone, Plane, Rocket, Satellite, Wallet, UserPlus, Activity,
|
History, Drone, Plane, Rocket, Satellite, Wallet, UserPlus, Activity,
|
||||||
|
Volume, VolumeOff, ChevronLeft, ChevronRight, PlayerPlay, PlayerPause,
|
||||||
} from '@appica/icons-react';
|
} from '@appica/icons-react';
|
||||||
|
|
||||||
/* 名称 → 图标组件映射(推荐的调用方式:icon="bolt") */
|
/* 名称 → 图标组件映射(推荐的调用方式:icon="bolt") */
|
||||||
@@ -64,6 +65,14 @@ const NAME_MAP = {
|
|||||||
wallet: Wallet,
|
wallet: Wallet,
|
||||||
'user-plus': UserPlus,
|
'user-plus': UserPlus,
|
||||||
activity: Activity,
|
activity: Activity,
|
||||||
|
volume: Volume,
|
||||||
|
'volume-off': VolumeOff,
|
||||||
|
'chevron-left': ChevronLeft,
|
||||||
|
'chevron-right': ChevronRight,
|
||||||
|
'player-play': PlayerPlay,
|
||||||
|
'player-pause': PlayerPause,
|
||||||
|
play: PlayerPlay,
|
||||||
|
pause: PlayerPause,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -95,4 +104,5 @@ export {
|
|||||||
Check, Gauge, Cpu, Server, Database, Sparkles, Bulb, Settings,
|
Check, Gauge, Cpu, Server, Database, Sparkles, Bulb, Settings,
|
||||||
Heart, Pill, Dna, Microscope, Atom, MapPin, Flag, Award, Medal,
|
Heart, Pill, Dna, Microscope, Atom, MapPin, Flag, Award, Medal,
|
||||||
History, Drone, Plane, Rocket, Satellite, Wallet, UserPlus, Activity,
|
History, Drone, Plane, Rocket, Satellite, Wallet, UserPlus, Activity,
|
||||||
|
Volume, VolumeOff, ChevronLeft, ChevronRight, PlayerPlay, PlayerPause,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import Icon from './Icons';
|
||||||
|
import { fmtInt, fmtWan } from '../utils/parkData';
|
||||||
|
|
||||||
|
/* =========================================================
|
||||||
|
园区概览页脚(共享)—— 数据大屏 / 媒体播放页底部保持一致
|
||||||
|
========================================================= */
|
||||||
|
|
||||||
|
export default function ParkOverviewStrip({ d }) {
|
||||||
|
const chips = [
|
||||||
|
{ icon: 'building', label: '在园项目', value: `${d.projects.inPark} 家` },
|
||||||
|
{ icon: 'users', label: '带动就业', value: `${fmtInt(d.jobs.total)} 人` },
|
||||||
|
{ icon: 'coin', label: '累计营收', value: `${fmtWan(d.revenue.total)} 元` },
|
||||||
|
{ icon: 'factory', label: '高新技术企业', value: '32 家' },
|
||||||
|
{ icon: 'flask', label: '省级以上重点研发', value: '18 项' },
|
||||||
|
{ icon: 'trophy', label: '创业大赛奖项', value: '47 项' },
|
||||||
|
{ icon: 'diamond', label: '孵化企业估值', value: '5.2 亿元' },
|
||||||
|
{ icon: 'map-pin', label: '园区面积', value: '1.2 万㎡' },
|
||||||
|
{ icon: 'graduation', label: '创业导师', value: '58 位' },
|
||||||
|
{ icon: 'chart-line', label: '累计孵化企业', value: `${d.projects.cum} 家` },
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<footer className="bd-strip">
|
||||||
|
<div className="bd-strip-title"><Icon name="map-pin" size={14} /> 园区概览</div>
|
||||||
|
<div className="bd-strip-chips">
|
||||||
|
{chips.map((c, i) => (
|
||||||
|
<div className="bd-chip" key={i}>
|
||||||
|
<span className="bd-chip-ico"><Icon name={c.icon} size={15} /></span>
|
||||||
|
<span className="bd-chip-label">{c.label}</span>
|
||||||
|
<b>{c.value}</b>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { Outlet } from 'react-router-dom';
|
||||||
|
import PageHeader from './PageHeader';
|
||||||
|
import { usePageNav } from '../utils/pageNav';
|
||||||
|
|
||||||
|
/* =========================================================
|
||||||
|
ScreenLayout —— 大屏页面统一布局
|
||||||
|
通过 App 嵌套路由注入统一页眉 + 全局左右键循环导航
|
||||||
|
页脚由各页面按需自行渲染(数字孪生页无需页脚)
|
||||||
|
========================================================= */
|
||||||
|
|
||||||
|
export default function ScreenLayout() {
|
||||||
|
// 全局左右方向键循环切换(数据大屏 / 数字孪生 / 媒体轮播)
|
||||||
|
usePageNav();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bd-layout">
|
||||||
|
<PageHeader />
|
||||||
|
<main className="bd-layout-main">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+1
-1
@@ -436,7 +436,7 @@ export default function Admin() {
|
|||||||
{fromScreen && (
|
{fromScreen && (
|
||||||
<button className="btn-return" onClick={handleReturnToScreen}>返回展播</button>
|
<button className="btn-return" onClick={handleReturnToScreen}>返回展播</button>
|
||||||
)}
|
)}
|
||||||
<button className="btn-return" onClick={() => navigate('/screen2')}>数据大屏</button>
|
<button className="btn-return" onClick={() => navigate('/')}>数据大屏</button>
|
||||||
<button className="btn-logout" onClick={handleLogout}>退出</button>
|
<button className="btn-logout" onClick={handleLogout}>退出</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import '../styles/datascreen.css';
|
import '../styles/datascreen.css';
|
||||||
import Icon from '../components/Icons';
|
import Icon from '../components/Icons';
|
||||||
import PageHeader from '../components/PageHeader';
|
|
||||||
|
|
||||||
/* =========================================================
|
/* =========================================================
|
||||||
昆明市大学生创业园 · OPC 智能园区数字运营中心
|
昆明市大学生创业园 · OPC 智能园区数字运营中心
|
||||||
@@ -662,7 +660,7 @@ function MiddleRotator({ d }) {
|
|||||||
const views = [
|
const views = [
|
||||||
{
|
{
|
||||||
key: 'ai',
|
key: 'ai',
|
||||||
label: 'AI 智能应用',
|
label: 'OPC 智能资源',
|
||||||
icon: 'cpu',
|
icon: 'cpu',
|
||||||
node: (
|
node: (
|
||||||
<div className="bd-view bd-view-2">
|
<div className="bd-view bd-view-2">
|
||||||
@@ -732,26 +730,9 @@ function LiveFeed({ feed, limit = 7 }) {
|
|||||||
========================================================= */
|
========================================================= */
|
||||||
export default function DataScreen() {
|
export default function DataScreen() {
|
||||||
const d = useParkSim();
|
const d = useParkSim();
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
// 左右方向键:→ 进入数字孪生页,← 返回(管理页为左端)
|
|
||||||
useEffect(() => {
|
|
||||||
const onKey = (e) => {
|
|
||||||
if (e.key === 'ArrowRight') navigate('/twin');
|
|
||||||
else if (e.key === 'ArrowLeft') navigate('/admin');
|
|
||||||
};
|
|
||||||
window.addEventListener('keydown', onKey);
|
|
||||||
return () => window.removeEventListener('keydown', onKey);
|
|
||||||
}, [navigate]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bd-page">
|
<div className="bd-page">
|
||||||
{/* 顶栏(共享组件,与数字孪生页风格一致) */}
|
|
||||||
<PageHeader
|
|
||||||
nav={{ to: '/twin', label: '数字孪生', icon: 'chevron-right', title: '按 → 进入数字孪生' }}
|
|
||||||
status="数据实时"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 园区今日概览(五个指标一行 + 标题右侧运行状态) */}
|
{/* 园区今日概览(五个指标一行 + 标题右侧运行状态) */}
|
||||||
<TodayOverview d={d} />
|
<TodayOverview d={d} />
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { useState, useEffect, lazy, Suspense } from 'react';
|
import { useState, useEffect, lazy, Suspense } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import '../styles/datascreen.css';
|
import '../styles/datascreen.css';
|
||||||
import Icon from '../components/Icons';
|
import Icon from '../components/Icons';
|
||||||
import PageHeader from '../components/PageHeader';
|
|
||||||
|
|
||||||
/* 3D 园区建筑懒加载 */
|
/* 3D 园区建筑懒加载 */
|
||||||
const Building3D = lazy(() => import('../components/Building3D'));
|
const Building3D = lazy(() => import('../components/Building3D'));
|
||||||
@@ -10,7 +8,7 @@ const Building3D = lazy(() => import('../components/Building3D'));
|
|||||||
/* =========================================================
|
/* =========================================================
|
||||||
园区数字孪生 · 独立页面(/twin)
|
园区数字孪生 · 独立页面(/twin)
|
||||||
全屏 3D 建筑模型 + 实时动态 + 企业分布
|
全屏 3D 建筑模型 + 实时动态 + 企业分布
|
||||||
左右方向键:← 返回数据大屏 · → 进入管理后台
|
页眉由 ScreenLayout 统一注入,左右方向键切换页面
|
||||||
========================================================= */
|
========================================================= */
|
||||||
|
|
||||||
/* ---------- 企业数据(名称 / 区域 / 房间号) ---------- */
|
/* ---------- 企业数据(名称 / 区域 / 房间号) ---------- */
|
||||||
@@ -45,7 +43,6 @@ const COMPANIES = [
|
|||||||
const ZONES = ['加速区', '国际区', '成长区'];
|
const ZONES = ['加速区', '国际区', '成长区'];
|
||||||
|
|
||||||
export default function DigitalTwin() {
|
export default function DigitalTwin() {
|
||||||
const navigate = useNavigate();
|
|
||||||
const [feed, setFeed] = useState(initFeed());
|
const [feed, setFeed] = useState(initFeed());
|
||||||
const [activeZone, setActiveZone] = useState('加速区');
|
const [activeZone, setActiveZone] = useState('加速区');
|
||||||
const [selected, setSelected] = useState(null);
|
const [selected, setSelected] = useState(null);
|
||||||
@@ -58,28 +55,12 @@ export default function DigitalTwin() {
|
|||||||
return () => clearInterval(iv);
|
return () => clearInterval(iv);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 左右方向键:← 返回数据大屏 · → 进入管理后台
|
|
||||||
useEffect(() => {
|
|
||||||
const onKey = (e) => {
|
|
||||||
if (e.key === 'ArrowLeft') navigate('/screen2');
|
|
||||||
else if (e.key === 'ArrowRight') navigate('/admin');
|
|
||||||
};
|
|
||||||
window.addEventListener('keydown', onKey);
|
|
||||||
return () => window.removeEventListener('keydown', onKey);
|
|
||||||
}, [navigate]);
|
|
||||||
|
|
||||||
const list = COMPANIES.filter((c) => c.zone === activeZone);
|
const list = COMPANIES.filter((c) => c.zone === activeZone);
|
||||||
const zoneColors = { 加速区: '#4c8dff', 国际区: '#22d3ee', 成长区: '#34d399' };
|
const zoneColors = { 加速区: '#4c8dff', 国际区: '#22d3ee', 成长区: '#34d399' };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bd-twin-page">
|
<div className="bd-twin-page">
|
||||||
{/* 顶栏(共享组件,与数据大屏风格一致) */}
|
{/* 主体:3D 为主 + 侧边栏(页眉由 ScreenLayout 统一注入) */}
|
||||||
<PageHeader
|
|
||||||
nav={{ to: '/screen2', label: '数据大屏', icon: 'chevron-left', title: '按 ← 返回数据大屏' }}
|
|
||||||
status="3D 实时"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 主体:3D 为主 + 侧边栏 */}
|
|
||||||
<main className="bd-twin-main">
|
<main className="bd-twin-main">
|
||||||
{/* 3D 场景 */}
|
{/* 3D 场景 */}
|
||||||
<section className="bd-twin-stage">
|
<section className="bd-twin-stage">
|
||||||
|
|||||||
@@ -0,0 +1,509 @@
|
|||||||
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import * as api from '../utils/api';
|
||||||
|
import { API_BASE } from '../utils/api';
|
||||||
|
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||||
|
import { enable, disable } from '@tauri-apps/plugin-autostart';
|
||||||
|
import Icon from '../components/Icons';
|
||||||
|
import ParkOverviewStrip from '../components/ParkOverviewStrip';
|
||||||
|
import { useParkSim } from '../utils/parkData';
|
||||||
|
import '../styles/datascreen.css';
|
||||||
|
import '../styles/mediascreen.css';
|
||||||
|
|
||||||
|
/* =========================================================
|
||||||
|
MediaScreen —— 大屏媒体轮播
|
||||||
|
页眉由 ScreenLayout 统一注入(所有页面一致),
|
||||||
|
页脚(园区概览条)按需保留,中间为全屏媒体播放器
|
||||||
|
========================================================= */
|
||||||
|
|
||||||
|
export default function MediaScreen() {
|
||||||
|
const d = useParkSim();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [list, setList] = useState([]);
|
||||||
|
const [index, setIndex] = useState(0);
|
||||||
|
const [volume, setVolume] = useState(0.8);
|
||||||
|
const [playMode, setPlayMode] = useState('sequential');
|
||||||
|
const [imageDuration, setImageDuration] = useState(5);
|
||||||
|
const [paused, setPaused] = useState(false);
|
||||||
|
const [soundBlocked, setSoundBlocked] = useState(false);
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
const [loaderText, setLoaderText] = useState('加载播放列表中...');
|
||||||
|
const [mediaKey, setMediaKey] = useState(0);
|
||||||
|
const [showVideo, setShowVideo] = useState(true);
|
||||||
|
const [showLoginModal, setShowLoginModal] = useState(false);
|
||||||
|
const [loginUsername, setLoginUsername] = useState('');
|
||||||
|
const [loginPassword, setLoginPassword] = useState('');
|
||||||
|
const [loginError, setLoginError] = useState('');
|
||||||
|
const [fullscreenMode, setFullscreenMode] = useState(true);
|
||||||
|
const [autostartEnabled, setAutostartEnabled] = useState(false);
|
||||||
|
|
||||||
|
const videoRef = useRef(null);
|
||||||
|
const imgRef = useRef(null);
|
||||||
|
const timerRef = useRef(null);
|
||||||
|
const loginTimerRef = useRef(null);
|
||||||
|
const loginInputRef = useRef(null);
|
||||||
|
|
||||||
|
const listRef = useRef(list);
|
||||||
|
const indexRef = useRef(index);
|
||||||
|
const pausedRef = useRef(paused);
|
||||||
|
const playModeRef = useRef(playMode);
|
||||||
|
const imageDurationRef = useRef(imageDuration);
|
||||||
|
const soundBlockedRef = useRef(soundBlocked);
|
||||||
|
|
||||||
|
useEffect(() => { listRef.current = list; }, [list]);
|
||||||
|
useEffect(() => { indexRef.current = index; }, [index]);
|
||||||
|
useEffect(() => { pausedRef.current = paused; }, [paused]);
|
||||||
|
useEffect(() => { playModeRef.current = playMode; }, [playMode]);
|
||||||
|
useEffect(() => { imageDurationRef.current = imageDuration; }, [imageDuration]);
|
||||||
|
useEffect(() => { soundBlockedRef.current = soundBlocked; }, [soundBlocked]);
|
||||||
|
|
||||||
|
const getUrl = useCallback((item) => {
|
||||||
|
return item.source === 'url' ? item.relative_path : `${API_BASE}/file/${item.relative_path}`;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ============ 核心播放控制 ============
|
||||||
|
const next = useCallback(() => {
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
const len = listRef.current.length;
|
||||||
|
if (len === 0) return;
|
||||||
|
const mode = playModeRef.current;
|
||||||
|
setIndex(prev => {
|
||||||
|
if (mode === 'random') return Math.floor(Math.random() * len);
|
||||||
|
return (prev + 1) % len;
|
||||||
|
});
|
||||||
|
setMediaKey(k => k + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const skip = useCallback((delta) => {
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
const len = listRef.current.length;
|
||||||
|
if (len === 0) return;
|
||||||
|
const mode = playModeRef.current;
|
||||||
|
setIndex(prev => {
|
||||||
|
if (mode === 'random') return Math.floor(Math.random() * len);
|
||||||
|
return (prev + delta + len) % len;
|
||||||
|
});
|
||||||
|
setMediaKey(k => k + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ============ 播放效果 ============
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loaded || list.length === 0 || paused) return;
|
||||||
|
const item = list[index];
|
||||||
|
if (!item) return;
|
||||||
|
|
||||||
|
api.updateState({
|
||||||
|
status: 'playing',
|
||||||
|
index,
|
||||||
|
name: item.name || '',
|
||||||
|
type: item.type || '',
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
|
if (item.type === 'video') {
|
||||||
|
setShowVideo(true);
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video) return;
|
||||||
|
const url = getUrl(item);
|
||||||
|
video.src = url;
|
||||||
|
video.load();
|
||||||
|
video.muted = soundBlocked;
|
||||||
|
video.volume = volume;
|
||||||
|
const p = video.play();
|
||||||
|
if (p !== undefined) {
|
||||||
|
p.catch(() => {
|
||||||
|
setSoundBlocked(true);
|
||||||
|
video.muted = true;
|
||||||
|
video.play().catch(() => {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setShowVideo(false);
|
||||||
|
const url = getUrl(item);
|
||||||
|
imgRef.current.src = url;
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = setTimeout(next, imageDuration * 1000);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [index, mediaKey, loaded, paused]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (paused || !loaded || list.length === 0) return;
|
||||||
|
const item = list[index];
|
||||||
|
if (!item || item.type !== 'video') return;
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (video && video.paused && video.src) {
|
||||||
|
video.play().catch(() => {
|
||||||
|
setSoundBlocked(true);
|
||||||
|
video.muted = true;
|
||||||
|
video.play().catch(() => {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [paused]);
|
||||||
|
|
||||||
|
// ============ 事件监听 ============
|
||||||
|
useEffect(() => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video) return;
|
||||||
|
const handler = () => next();
|
||||||
|
video.addEventListener('ended', handler);
|
||||||
|
return () => video.removeEventListener('ended', handler);
|
||||||
|
}, [next, mediaKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
const data = await api.getPlaylist();
|
||||||
|
const files = data.files || [];
|
||||||
|
const vol = (data.volume || 80) / 100;
|
||||||
|
setList(files);
|
||||||
|
setVolume(vol);
|
||||||
|
setPlayMode(data.play_mode || 'sequential');
|
||||||
|
setImageDuration(data.image_duration || 5);
|
||||||
|
if (videoRef.current) videoRef.current.volume = vol;
|
||||||
|
|
||||||
|
if (files.length === 0) {
|
||||||
|
setLoaderText('播放列表为空,<a href="/admin">前往管理后台添加</a>');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoaded(true);
|
||||||
|
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (video) {
|
||||||
|
video.muted = false;
|
||||||
|
const testPlay = video.play();
|
||||||
|
if (testPlay !== undefined) {
|
||||||
|
testPlay.then(() => {
|
||||||
|
setSoundBlocked(false);
|
||||||
|
video.pause();
|
||||||
|
}).catch(() => {
|
||||||
|
setSoundBlocked(true);
|
||||||
|
video.muted = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {/* ignore */}
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const es = new EventSource(`${API_BASE}/api/events`);
|
||||||
|
es.onmessage = (e) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(e.data);
|
||||||
|
switch (msg.action) {
|
||||||
|
case 'pause':
|
||||||
|
setPaused(true);
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
if (videoRef.current) videoRef.current.pause();
|
||||||
|
break;
|
||||||
|
case 'play':
|
||||||
|
setPaused(false);
|
||||||
|
break;
|
||||||
|
case 'next':
|
||||||
|
setPaused(false);
|
||||||
|
skip(1);
|
||||||
|
break;
|
||||||
|
case 'prev':
|
||||||
|
setPaused(false);
|
||||||
|
skip(-1);
|
||||||
|
break;
|
||||||
|
case 'minimize_window':
|
||||||
|
getCurrentWindow().minimize().catch(() => {});
|
||||||
|
break;
|
||||||
|
case 'playlist_changed':
|
||||||
|
reloadPlaylist();
|
||||||
|
break;
|
||||||
|
case 'settings_changed':
|
||||||
|
applySettings(msg);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch {/* ignore */}
|
||||||
|
};
|
||||||
|
return () => es.close();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [skip]);
|
||||||
|
|
||||||
|
const reloadPlaylist = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await api.getPlaylist();
|
||||||
|
const newList = data.files || [];
|
||||||
|
const currentItem = listRef.current[indexRef.current] || null;
|
||||||
|
|
||||||
|
if (newList.length === 0) {
|
||||||
|
setList([]);
|
||||||
|
setLoaded(false);
|
||||||
|
setLoaderText('播放列表已清空,<a href="/admin">前往管理后台添加</a>');
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (video) video.pause();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoaded(true);
|
||||||
|
|
||||||
|
let newIndex = -1;
|
||||||
|
if (currentItem) {
|
||||||
|
newIndex = newList.findIndex(item => item.relative_path === currentItem.relative_path);
|
||||||
|
}
|
||||||
|
if (newIndex >= 0) {
|
||||||
|
setList(newList);
|
||||||
|
setIndex(newIndex);
|
||||||
|
} else {
|
||||||
|
setList(newList);
|
||||||
|
setIndex(prev => Math.min(prev, newList.length - 1));
|
||||||
|
}
|
||||||
|
} catch {/* ignore */}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const applySettings = useCallback((msg) => {
|
||||||
|
if (msg.volume !== undefined) {
|
||||||
|
const vol = msg.volume / 100;
|
||||||
|
setVolume(vol);
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (video) {
|
||||||
|
video.volume = vol;
|
||||||
|
if (soundBlockedRef.current) {
|
||||||
|
video.muted = false;
|
||||||
|
video.play().then(() => {
|
||||||
|
setSoundBlocked(false);
|
||||||
|
const hint = document.getElementById('soundHint');
|
||||||
|
if (hint) hint.style.display = 'none';
|
||||||
|
}).catch(() => {
|
||||||
|
video.muted = true;
|
||||||
|
video.play().catch(() => {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (msg.play_mode !== undefined) setPlayMode(msg.play_mode);
|
||||||
|
if (msg.image_duration !== undefined) setImageDuration(msg.image_duration);
|
||||||
|
if (msg.fullscreen !== undefined) {
|
||||||
|
setFullscreenMode(msg.fullscreen);
|
||||||
|
getCurrentWindow().setFullscreen(msg.fullscreen).catch(() => {});
|
||||||
|
}
|
||||||
|
if (msg.autostart !== undefined) {
|
||||||
|
setAutostartEnabled(msg.autostart);
|
||||||
|
if (msg.autostart) {
|
||||||
|
enable().catch(() => {});
|
||||||
|
} else {
|
||||||
|
disable().catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const enableSound = useCallback(() => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video) return;
|
||||||
|
video.muted = false;
|
||||||
|
video.play().then(() => {
|
||||||
|
setSoundBlocked(false);
|
||||||
|
const hint = document.getElementById('soundHint');
|
||||||
|
if (hint) hint.style.display = 'none';
|
||||||
|
}).catch(() => {
|
||||||
|
video.muted = true;
|
||||||
|
video.play().catch(() => {});
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ============ 登录弹窗 ============
|
||||||
|
const handleTitleClick = useCallback(() => {
|
||||||
|
setLoginUsername('');
|
||||||
|
setLoginPassword('');
|
||||||
|
setLoginError('');
|
||||||
|
setShowLoginModal(true);
|
||||||
|
if (loginTimerRef.current) clearTimeout(loginTimerRef.current);
|
||||||
|
loginTimerRef.current = setTimeout(() => {
|
||||||
|
setShowLoginModal(false);
|
||||||
|
}, 30000);
|
||||||
|
setTimeout(() => {
|
||||||
|
if (loginInputRef.current) loginInputRef.current.focus();
|
||||||
|
}, 100);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCloseLogin = useCallback(() => {
|
||||||
|
if (loginTimerRef.current) clearTimeout(loginTimerRef.current);
|
||||||
|
setShowLoginModal(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleLoginSubmit = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await api.login(loginUsername, loginPassword);
|
||||||
|
if (data.success) {
|
||||||
|
if (loginTimerRef.current) clearTimeout(loginTimerRef.current);
|
||||||
|
localStorage.setItem('token', 'admin_logged');
|
||||||
|
navigate('/admin?from=screen');
|
||||||
|
} else {
|
||||||
|
setLoginError('账号或密码错误');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setLoginError('登录失败,请重试');
|
||||||
|
}
|
||||||
|
}, [loginUsername, loginPassword, navigate]);
|
||||||
|
|
||||||
|
const handleLoginKeyDown = useCallback((e) => {
|
||||||
|
if (e.key === 'Enter') handleLoginSubmit();
|
||||||
|
}, [handleLoginSubmit]);
|
||||||
|
|
||||||
|
// ============ 播放/暂停 ============
|
||||||
|
const togglePlayPause = useCallback(() => {
|
||||||
|
if (pausedRef.current) {
|
||||||
|
setPaused(false);
|
||||||
|
} else {
|
||||||
|
setPaused(true);
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (video) video.pause();
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Global click for sound unblock
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => {
|
||||||
|
if (soundBlockedRef.current) enableSound();
|
||||||
|
};
|
||||||
|
document.addEventListener('click', handler);
|
||||||
|
return () => document.removeEventListener('click', handler);
|
||||||
|
}, [enableSound]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
const data = await api.getSettings();
|
||||||
|
setFullscreenMode(data.fullscreen ?? true);
|
||||||
|
setAutostartEnabled(data.autostart ?? false);
|
||||||
|
} catch {/* ignore */}
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
if (showLoginModal) {
|
||||||
|
handleCloseLogin();
|
||||||
|
} else {
|
||||||
|
getCurrentWindow().setFullscreen(false).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', handler);
|
||||||
|
return () => document.removeEventListener('keydown', handler);
|
||||||
|
}, [showLoginModal, handleCloseLogin]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
if (loginTimerRef.current) clearTimeout(loginTimerRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const currentItem = list[index] || null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ms-page">
|
||||||
|
{/* 背景光晕 */}
|
||||||
|
<div className="ms-orb orb-1"></div>
|
||||||
|
<div className="ms-orb orb-2"></div>
|
||||||
|
|
||||||
|
{/* 主体:媒体播放器 + 控制层(页眉由 ScreenLayout 注入) */}
|
||||||
|
<main className="ms-main">
|
||||||
|
{/* 媒体播放器 */}
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
id="player"
|
||||||
|
key={mediaKey}
|
||||||
|
autoPlay
|
||||||
|
playsInline
|
||||||
|
controlsList="nodownload"
|
||||||
|
className="ms-media"
|
||||||
|
style={{ display: showVideo && loaded ? 'block' : 'none' }}
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
ref={imgRef}
|
||||||
|
id="imgPlayer"
|
||||||
|
alt=""
|
||||||
|
className="ms-media"
|
||||||
|
style={{ display: !showVideo && loaded ? 'block' : 'none' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 加载中 */}
|
||||||
|
{!loaded && (
|
||||||
|
<div className="ms-loader">
|
||||||
|
<div className="ms-loader-ring"></div>
|
||||||
|
<div className="ms-loader-text" dangerouslySetInnerHTML={{ __html: loaderText }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 中央播放控制(悬浮层) */}
|
||||||
|
<div className={`ms-controls${loaded ? ' visible' : ''}`}>
|
||||||
|
{/* 播放/暂停 */}
|
||||||
|
<button className="ms-play" onClick={togglePlayPause} title={paused ? '播放' : '暂停'}>
|
||||||
|
<Icon name={paused ? 'play' : 'pause'} size={18} />
|
||||||
|
</button>
|
||||||
|
{/* 上一段 / 下一段 */}
|
||||||
|
<div className="ms-nav">
|
||||||
|
<button onClick={() => skip(-1)} title="上一段">
|
||||||
|
<Icon name="chevron-left" size={16} />
|
||||||
|
</button>
|
||||||
|
<span className="ms-nav-num">
|
||||||
|
<b>{currentItem ? `${index + 1}` : '0'}</b>
|
||||||
|
<em>/ {list.length}</em>
|
||||||
|
</span>
|
||||||
|
<button onClick={() => skip(1)} title="下一段">
|
||||||
|
<Icon name="chevron-right" size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/* 当前播放名 + LIVE(点击进入后台登录) */}
|
||||||
|
<div className="ms-now" onClick={handleTitleClick} title="点击进入后台" style={{ cursor: 'pointer' }}>
|
||||||
|
<span className="ms-now-dot" />
|
||||||
|
<span className="ms-now-name">{currentItem ? currentItem.name : '等待播放…'}</span>
|
||||||
|
</div>
|
||||||
|
{/* 声音 */}
|
||||||
|
<div id="soundHint" className="ms-sound" style={{ display: soundBlocked && loaded ? 'flex' : 'none' }} onClick={enableSound}>
|
||||||
|
<Icon name="volume" size={16} />
|
||||||
|
点击开启声音
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* 页脚:园区概览(与数据大屏一致) */}
|
||||||
|
<ParkOverviewStrip d={d} />
|
||||||
|
|
||||||
|
{/* 登录弹窗 */}
|
||||||
|
<div className={`ms-login-overlay${showLoginModal ? ' visible' : ''}`} onClick={(e) => { if (e.target === e.currentTarget) handleCloseLogin(); }}>
|
||||||
|
<div className="ms-login-modal" onClick={e => e.stopPropagation()}>
|
||||||
|
<button className="ms-login-close" onClick={handleCloseLogin}>×</button>
|
||||||
|
<div className="ms-login-brand">
|
||||||
|
<h2>后台管理</h2>
|
||||||
|
<p>请输入账号密码登录</p>
|
||||||
|
</div>
|
||||||
|
<div className="ms-login-field">
|
||||||
|
<label>账号</label>
|
||||||
|
<input
|
||||||
|
ref={loginInputRef}
|
||||||
|
value={loginUsername}
|
||||||
|
onChange={e => { setLoginUsername(e.target.value); setLoginError(''); }}
|
||||||
|
onKeyDown={handleLoginKeyDown}
|
||||||
|
placeholder="请输入账号"
|
||||||
|
autoComplete="username"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="ms-login-field">
|
||||||
|
<label>密码</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={loginPassword}
|
||||||
|
onChange={e => { setLoginPassword(e.target.value); setLoginError(''); }}
|
||||||
|
onKeyDown={handleLoginKeyDown}
|
||||||
|
placeholder="请输入密码"
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{loginError && <div className="ms-login-error">{loginError}</div>}
|
||||||
|
<button className="ms-login-btn" onClick={handleLoginSubmit}>登 录</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,361 @@
|
|||||||
|
/* =========================================================
|
||||||
|
MediaScreen —— 大屏媒体轮播(与数据大屏统一浅色科技风)
|
||||||
|
配色沿用 datascreen 的 bd- 变量体系
|
||||||
|
========================================================= */
|
||||||
|
.ms-page {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
color: var(--bd-ink, #1d2c44);
|
||||||
|
font-family: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 背景网格(与主体一致) */
|
||||||
|
.ms-page::before {
|
||||||
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.5;
|
||||||
|
background-image:
|
||||||
|
repeating-linear-gradient(to right, rgba(47, 107, 255, 0.045) 0 1px, transparent 1px 52px),
|
||||||
|
repeating-linear-gradient(to bottom, rgba(47, 107, 255, 0.045) 0 1px, transparent 1px 52px);
|
||||||
|
}
|
||||||
|
.ms-orb {
|
||||||
|
position: fixed;
|
||||||
|
border-radius: 50%;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
filter: blur(90px);
|
||||||
|
}
|
||||||
|
.ms-orb.orb-1 {
|
||||||
|
width: 420px; height: 420px;
|
||||||
|
top: -120px; right: -100px;
|
||||||
|
background: rgba(76, 194, 255, 0.14);
|
||||||
|
}
|
||||||
|
.ms-orb.orb-2 {
|
||||||
|
width: 380px; height: 380px;
|
||||||
|
bottom: -120px; left: -80px;
|
||||||
|
background: rgba(47, 107, 255, 0.10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 顶栏与页脚沿用 datascreen 的 bd-header / bd-strip */
|
||||||
|
.ms-page .bd-header { z-index: 10; }
|
||||||
|
|
||||||
|
/* 主体:媒体区 */
|
||||||
|
.ms-main {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
z-index: 3;
|
||||||
|
margin: clamp(10px, 1.4vw, 20px);
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: rgba(255, 255, 255, 0.5);
|
||||||
|
border: 1px solid rgba(120, 160, 220, 0.14);
|
||||||
|
box-shadow:
|
||||||
|
0 10px 30px rgba(56, 100, 170, 0.07),
|
||||||
|
0 1px 0 rgba(255, 255, 255, 0.9) inset;
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
-webkit-backdrop-filter: blur(14px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 媒体播放器(铺满媒体区,等比适配) */
|
||||||
|
.ms-media {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 加载中 */
|
||||||
|
.ms-loader {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 26px;
|
||||||
|
background: rgba(247, 251, 255, 0.6);
|
||||||
|
}
|
||||||
|
.ms-loader-ring {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid rgba(47, 107, 255, 0.2);
|
||||||
|
border-top-color: var(--bd-blue, #2f6bff);
|
||||||
|
animation: msSpin 1s linear infinite;
|
||||||
|
box-shadow: 0 0 30px rgba(47, 107, 255, 0.15);
|
||||||
|
}
|
||||||
|
@keyframes msSpin { to { transform: rotate(360deg); } }
|
||||||
|
.ms-loader-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--bd-sub, #7d8fa9);
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
}
|
||||||
|
.ms-loader-text a {
|
||||||
|
color: var(--bd-blue, #2f6bff);
|
||||||
|
text-decoration: none;
|
||||||
|
border-bottom: 1px solid rgba(47, 107, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 中央播放控制层(悬浮) */
|
||||||
|
.ms-controls {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
bottom: 22px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 10px 18px;
|
||||||
|
background: rgba(255, 255, 255, 0.8);
|
||||||
|
border: 1px solid rgba(120, 160, 220, 0.2);
|
||||||
|
border-radius: 40px;
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
-webkit-backdrop-filter: blur(16px);
|
||||||
|
box-shadow: 0 12px 40px rgba(56, 100, 170, 0.18);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.5s ease;
|
||||||
|
}
|
||||||
|
.ms-controls.visible { opacity: 1; }
|
||||||
|
|
||||||
|
.ms-play {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(140deg, var(--bd-blue, #2f6bff), var(--bd-cyan, #4cc2ff));
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 6px 20px rgba(47, 107, 255, 0.4);
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
.ms-play:hover { transform: scale(1.08); }
|
||||||
|
|
||||||
|
.ms-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.ms-nav button {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid rgba(47, 107, 255, 0.2);
|
||||||
|
background: rgba(255, 255, 255, 0.7);
|
||||||
|
color: var(--bd-blue, #2f6bff);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.ms-nav button:hover {
|
||||||
|
background: rgba(47, 107, 255, 0.12);
|
||||||
|
border-color: rgba(47, 107, 255, 0.5);
|
||||||
|
}
|
||||||
|
.ms-nav-num {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 4px;
|
||||||
|
font-family: "JetBrains Mono", "Oswald", monospace;
|
||||||
|
}
|
||||||
|
.ms-nav-num b {
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--bd-ink, #1d2c44);
|
||||||
|
}
|
||||||
|
.ms-nav-num em {
|
||||||
|
font-style: normal;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--bd-sub, #7d8fa9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ms-now {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding-left: 16px;
|
||||||
|
border-left: 1px solid rgba(47, 107, 255, 0.14);
|
||||||
|
}
|
||||||
|
.ms-now-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--bd-green, #17b26a);
|
||||||
|
box-shadow: 0 0 10px rgba(23, 178, 106, 0.8);
|
||||||
|
animation: msPulse 1.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes msPulse {
|
||||||
|
0%, 100% { opacity: 1; transform: scale(1); }
|
||||||
|
50% { opacity: 0.4; transform: scale(0.75); }
|
||||||
|
}
|
||||||
|
.ms-now-name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--bd-ink, #1d2c44);
|
||||||
|
max-width: 280px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 声音提示 */
|
||||||
|
.ms-sound {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #4a5b76;
|
||||||
|
background: rgba(255, 255, 255, 0.85);
|
||||||
|
border: 1px solid rgba(47, 107, 255, 0.16);
|
||||||
|
border-radius: 30px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.ms-sound .appica-ico { color: var(--bd-blue, #2f6bff); }
|
||||||
|
|
||||||
|
/* 页脚 */
|
||||||
|
.ms-page .bd-strip { z-index: 10; position: relative; }
|
||||||
|
|
||||||
|
/* ============ 登录弹窗(浅色) ============ */
|
||||||
|
.ms-login-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 200;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(29, 44, 68, 0.55);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transition: opacity 0.3s, visibility 0.3s;
|
||||||
|
}
|
||||||
|
.ms-login-overlay.visible {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
.ms-login-modal {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 380px;
|
||||||
|
padding: 38px 34px 32px;
|
||||||
|
background: linear-gradient(180deg, #ffffff, #f4f8fd);
|
||||||
|
border: 1px solid rgba(120, 160, 220, 0.25);
|
||||||
|
border-radius: 18px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 24px 80px rgba(29, 44, 68, 0.25);
|
||||||
|
}
|
||||||
|
.ms-login-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 14px;
|
||||||
|
right: 14px;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid rgba(120, 160, 220, 0.2);
|
||||||
|
background: rgba(255, 255, 255, 0.7);
|
||||||
|
color: #7d8fa9;
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.ms-login-close:hover {
|
||||||
|
background: rgba(47, 107, 255, 0.08);
|
||||||
|
color: #2f6bff;
|
||||||
|
}
|
||||||
|
.ms-login-brand { margin-bottom: 26px; }
|
||||||
|
.ms-login-brand h2 {
|
||||||
|
font-size: 21px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1d2c44;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.ms-login-brand p {
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: #7d8fa9;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
.ms-login-field {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.ms-login-field label {
|
||||||
|
display: block;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #7d8fa9;
|
||||||
|
margin-bottom: 7px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.ms-login-field input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
border: 1px solid rgba(47, 107, 255, 0.2);
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
color: #1d2c44;
|
||||||
|
outline: none;
|
||||||
|
transition: all 0.2s;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.ms-login-field input::placeholder {
|
||||||
|
color: rgba(125, 147, 169, 0.5);
|
||||||
|
}
|
||||||
|
.ms-login-field input:focus {
|
||||||
|
border-color: rgba(47, 107, 255, 0.6);
|
||||||
|
box-shadow: 0 0 0 3px rgba(47, 107, 255, 0.12);
|
||||||
|
}
|
||||||
|
.ms-login-error {
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: #f04438;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.ms-login-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 13px;
|
||||||
|
background: linear-gradient(140deg, #2f6bff, #4cc2ff);
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: inherit;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
box-shadow: 0 6px 20px rgba(47, 107, 255, 0.35);
|
||||||
|
}
|
||||||
|
.ms-login-btn:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 10px 28px rgba(47, 107, 255, 0.45);
|
||||||
|
}
|
||||||
|
.ms-login-btn:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
|
/* =========================================================
|
||||||
|
页面循环导航 —— 左右方向键切换大屏页面
|
||||||
|
顺序:数据大屏 → 数字孪生 → 媒体轮播(循环)
|
||||||
|
管理后台不参与循环切换,仅在登录后进入
|
||||||
|
← 向左切换 · → 向右切换
|
||||||
|
========================================================= */
|
||||||
|
|
||||||
|
export const PAGE_ORDER = [
|
||||||
|
{ path: '/', label: '数据大屏' },
|
||||||
|
{ path: '/twin', label: '数字孪生' },
|
||||||
|
{ path: '/screen', label: '媒体轮播' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 左右方向键循环切换页面。
|
||||||
|
* 在任意页面使用;当前路径不在 PAGE_ORDER 中时不响应。
|
||||||
|
*/
|
||||||
|
export function usePageNav() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e) => {
|
||||||
|
const idx = PAGE_ORDER.findIndex((p) => p.path === location.pathname);
|
||||||
|
if (idx === -1) return;
|
||||||
|
const n = PAGE_ORDER.length;
|
||||||
|
if (e.key === 'ArrowRight') {
|
||||||
|
navigate(PAGE_ORDER[(idx + 1) % n].path);
|
||||||
|
} else if (e.key === 'ArrowLeft') {
|
||||||
|
navigate(PAGE_ORDER[(idx - 1 + n) % n].path);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [navigate, location.pathname]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取当前页在循环中的位置(-1 表示不在循环中) */
|
||||||
|
export function usePageIndex() {
|
||||||
|
const location = useLocation();
|
||||||
|
return PAGE_ORDER.findIndex((p) => p.path === location.pathname);
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
/* =========================================================
|
||||||
|
园区模拟数据引擎(共享)—— 供数据大屏 / 媒体播放页复用
|
||||||
|
每 2.2s 跳动一次,保持页眉与园区概览页脚数据一致
|
||||||
|
========================================================= */
|
||||||
|
|
||||||
|
export const fmtInt = (n) => Math.round(n).toLocaleString('zh-CN');
|
||||||
|
export const fmtWan = (n) => (n >= 10000 ? (n / 10000).toFixed(2) + '亿' : n >= 100 ? n.toFixed(1) + '万' : n.toFixed(1));
|
||||||
|
|
||||||
|
const pick = (arr) => arr[Math.floor(Math.random() * arr.length)];
|
||||||
|
const nowTime = () => new Date().toLocaleTimeString('zh-CN', { hour12: false });
|
||||||
|
|
||||||
|
const TOOL_NAMES = ['文档生成', '数据查询', '图像创作', '代码执行', '语音合成'];
|
||||||
|
const MODEL_NAMES = ['DeepSeek-V3', '通义千问', '智谱 GLM-4', '豆包', '讯飞星火'];
|
||||||
|
const COMPANY_NAMES = [
|
||||||
|
'云南派音人工智能科技', '米勒克尔蓝宝石珠宝', '中泰研学合作', '云南宸中低空经济',
|
||||||
|
'昆明智海银高文化科技', '云南廷秀文旅康养', '瀚颖AI+教育信息咨询',
|
||||||
|
'仰光客厅', '云南上古绝学文化', '中越生物医疗', '酷享野农AI农业',
|
||||||
|
'滇缅国际设计', '昆明舒诺生物科技', '达岸教育管理', '花仙子园艺肥料',
|
||||||
|
'研X同行者网络', '鬼才明AI创意工作室', '朵哈·玫瑰特色产业链', '昆明云韵体育',
|
||||||
|
'南菌优培食用菌', '五华区丽裳文化', '云南星瑞航空', '综合直播私域平台',
|
||||||
|
'昆明屿澈电商', '蓝智科技', '云品出滇·纸享万家', '启元人工智能科技',
|
||||||
|
];
|
||||||
|
|
||||||
|
function makeSeries(base, growth, noise, n = 30) {
|
||||||
|
const arr = [];
|
||||||
|
let v = base;
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
v = v * (1 + growth) + (Math.random() - 0.5) * noise;
|
||||||
|
arr.push(Math.max(1, Math.round(v * 100) / 100));
|
||||||
|
}
|
||||||
|
return arr;
|
||||||
|
}
|
||||||
|
|
||||||
|
function genEvent() {
|
||||||
|
const r = Math.random;
|
||||||
|
const pool = [
|
||||||
|
{ icon: 'bolt', text: `AI 推理任务完成 · 消耗 ${Math.round(800 + r() * 9000).toLocaleString()} tokens(${pick(MODEL_NAMES)})` },
|
||||||
|
{ icon: 'wrench', text: `工具「${pick(TOOL_NAMES)}」被调用 ${Math.round(10 + r() * 90)} 次` },
|
||||||
|
{ icon: 'building', text: `「${pick(COMPANY_NAMES)}」提交入驻申请 · 进入评审流程` },
|
||||||
|
{ icon: 'users', text: `「${pick(COMPANY_NAMES)}」新增招聘岗位 ${Math.round(1 + r() * 5)} 个` },
|
||||||
|
{ icon: 'coin', text: `园区企业完成一笔 ¥${(0.5 + r() * 9).toFixed(1)}万 交易` },
|
||||||
|
{ icon: 'robot', text: `「${pick(MODEL_NAMES)}」模型完成一次微调任务` },
|
||||||
|
];
|
||||||
|
const e = pick(pool);
|
||||||
|
return { id: Date.now() + Math.random(), icon: e.icon, text: e.text, time: nowTime() };
|
||||||
|
}
|
||||||
|
|
||||||
|
function initFeed() {
|
||||||
|
return [
|
||||||
|
{ id: 1, icon: 'bolt', text: '云南派音AI 完成音频向量嵌入任务 · 消耗 12,480 tokens', time: nowTime() },
|
||||||
|
{ id: 2, icon: 'building', text: '「启元人工智能科技」通过评审 · 正式入驻 OPC 创业空间', time: nowTime() },
|
||||||
|
{ id: 3, icon: 'users', text: '「昆明舒护安养老服务」新增招聘岗位 2 个', time: nowTime() },
|
||||||
|
{ id: 4, icon: 'coin', text: '园区企业完成一笔 ¥3.6万 交易', time: nowTime() },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function initSnapshot() {
|
||||||
|
return {
|
||||||
|
t: 0,
|
||||||
|
token: { today: 128.64, total: 12840, rate: 84.6, series: makeSeries(82, 0.012, 9) },
|
||||||
|
tools: { today: 3568, total: 365204, success: 98.7 },
|
||||||
|
projects: { inPark: 158, cum: 208, todayNew: 2 },
|
||||||
|
jobs: { total: 2186, todayNew: 3 },
|
||||||
|
revenue: { today: 38.6, total: 20800, growth: 8.2, series: makeSeries(30, 0.006, 4) },
|
||||||
|
park: { devices: 98.6, energy: 386, people: 127, desk: 76, meeting: 3, nodes: 12 },
|
||||||
|
feed: initFeed(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextSnapshot(s) {
|
||||||
|
const r = Math.random;
|
||||||
|
const tDeltaWan = +(0.26 + r() * 0.34).toFixed(2);
|
||||||
|
|
||||||
|
const token = {
|
||||||
|
today: +(s.token.today + tDeltaWan).toFixed(2),
|
||||||
|
total: +(s.token.total + tDeltaWan).toFixed(2),
|
||||||
|
rate: +(76 + r() * 20).toFixed(1),
|
||||||
|
};
|
||||||
|
const toolDelta = Math.round(13 + r() * 22);
|
||||||
|
const tools = {
|
||||||
|
today: s.tools.today + toolDelta,
|
||||||
|
total: s.tools.total + toolDelta,
|
||||||
|
success: +(98.1 + r() * 1.2).toFixed(1),
|
||||||
|
};
|
||||||
|
const revDelta = +(0.6 + r() * 1.7).toFixed(1);
|
||||||
|
const revenue = {
|
||||||
|
...s.revenue,
|
||||||
|
today: +(s.revenue.today + revDelta).toFixed(1),
|
||||||
|
total: +(s.revenue.total + revDelta).toFixed(1),
|
||||||
|
growth: +(7.2 + r() * 2.2).toFixed(1),
|
||||||
|
};
|
||||||
|
const park = {
|
||||||
|
devices: +(97.6 + r() * 1.6).toFixed(1),
|
||||||
|
energy: Math.round(320 + r() * 130),
|
||||||
|
people: Math.round(80 + r() * 95),
|
||||||
|
desk: Math.round(62 + r() * 24),
|
||||||
|
meeting: Math.round(2 + r() * 4),
|
||||||
|
nodes: 12,
|
||||||
|
};
|
||||||
|
|
||||||
|
const projects = { ...s.projects };
|
||||||
|
if (r() < 0.055) projects.todayNew += 1;
|
||||||
|
if (r() < 0.035) { projects.inPark += 1; projects.cum += 1; }
|
||||||
|
const jobs = { ...s.jobs };
|
||||||
|
if (r() < 0.08) jobs.todayNew += 1;
|
||||||
|
if (r() < 0.05) jobs.total += 1;
|
||||||
|
|
||||||
|
let tSeries = s.token.series.slice();
|
||||||
|
tSeries[tSeries.length - 1] = +(tSeries[tSeries.length - 1] + tDeltaWan).toFixed(2);
|
||||||
|
if (s.t % 12 === 11) tSeries = [...tSeries.slice(1), +(72 + r() * 30).toFixed(2)];
|
||||||
|
token.series = tSeries;
|
||||||
|
|
||||||
|
let rSeries = s.revenue.series.slice();
|
||||||
|
rSeries[rSeries.length - 1] = +(rSeries[rSeries.length - 1] + revDelta).toFixed(1);
|
||||||
|
if (s.t % 12 === 11) rSeries = [...rSeries.slice(1), +(26 + r() * 9).toFixed(1)];
|
||||||
|
revenue.series = rSeries;
|
||||||
|
|
||||||
|
const feed = s.t % 3 === 2 ? [genEvent(), ...s.feed].slice(0, 7) : s.feed;
|
||||||
|
|
||||||
|
return { ...s, t: s.t + 1, token, tools, revenue, park, projects, jobs, feed };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useParkSim() {
|
||||||
|
const [snap, setSnap] = useState(initSnapshot);
|
||||||
|
useEffect(() => {
|
||||||
|
const iv = setInterval(() => setSnap((p) => nextSnapshot(p)), 2200);
|
||||||
|
return () => clearInterval(iv);
|
||||||
|
}, []);
|
||||||
|
return snap;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user