diff --git a/src/App.jsx b/src/App.jsx
index e00e0b5..e9cc11e 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -1,19 +1,24 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import Admin from './pages/Admin';
-import Screen from './pages/Screen';
+import MediaScreen from './pages/MediaScreen';
import DataScreen from './pages/DataScreen';
import DigitalTwin from './pages/DigitalTwin';
+import ScreenLayout from './components/ScreenLayout';
import './styles/tokens.css';
export default function App() {
return (
+ {/* 大屏页面:统一页眉布局注入 */}
+ }>
+ } />
+ } />
+ } />
+
+ {/* 管理后台:独立布局(不参与大屏循环) */}
} />
- } />
- } />
- } />
- } />
+ } />
);
diff --git a/src/components/Icons.jsx b/src/components/Icons.jsx
index 13a8fc9..9bb1255 100644
--- a/src/components/Icons.jsx
+++ b/src/components/Icons.jsx
@@ -9,6 +9,7 @@ import {
Check, Gauge, Cpu, Server, Database, Sparkles, Bulb, Settings,
Heart, Pill, Dna, Microscope, Atom, MapPin, Flag, Award, Medal,
History, Drone, Plane, Rocket, Satellite, Wallet, UserPlus, Activity,
+ Volume, VolumeOff, ChevronLeft, ChevronRight, PlayerPlay, PlayerPause,
} from '@appica/icons-react';
/* 名称 → 图标组件映射(推荐的调用方式:icon="bolt") */
@@ -64,6 +65,14 @@ const NAME_MAP = {
wallet: Wallet,
'user-plus': UserPlus,
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,
Heart, Pill, Dna, Microscope, Atom, MapPin, Flag, Award, Medal,
History, Drone, Plane, Rocket, Satellite, Wallet, UserPlus, Activity,
+ Volume, VolumeOff, ChevronLeft, ChevronRight, PlayerPlay, PlayerPause,
};
diff --git a/src/components/ParkOverviewStrip.jsx b/src/components/ParkOverviewStrip.jsx
new file mode 100644
index 0000000..e7545c1
--- /dev/null
+++ b/src/components/ParkOverviewStrip.jsx
@@ -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 (
+
+ );
+}
diff --git a/src/components/ScreenLayout.jsx b/src/components/ScreenLayout.jsx
new file mode 100644
index 0000000..c2cd6c2
--- /dev/null
+++ b/src/components/ScreenLayout.jsx
@@ -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 (
+
+ );
+}
diff --git a/src/pages/Admin.jsx b/src/pages/Admin.jsx
index 134c91b..91410f5 100644
--- a/src/pages/Admin.jsx
+++ b/src/pages/Admin.jsx
@@ -436,7 +436,7 @@ export default function Admin() {
{fromScreen && (
)}
-
+
diff --git a/src/pages/DataScreen.jsx b/src/pages/DataScreen.jsx
index c5420f5..4cf059e 100644
--- a/src/pages/DataScreen.jsx
+++ b/src/pages/DataScreen.jsx
@@ -1,8 +1,6 @@
import { useState, useEffect, useRef } from 'react';
-import { useNavigate } from 'react-router-dom';
import '../styles/datascreen.css';
import Icon from '../components/Icons';
-import PageHeader from '../components/PageHeader';
/* =========================================================
昆明市大学生创业园 · OPC 智能园区数字运营中心
@@ -662,7 +660,7 @@ function MiddleRotator({ d }) {
const views = [
{
key: 'ai',
- label: 'AI 智能应用',
+ label: 'OPC 智能资源',
icon: 'cpu',
node: (
@@ -732,26 +730,9 @@ function LiveFeed({ feed, limit = 7 }) {
========================================================= */
export default function DataScreen() {
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 (
- {/* 顶栏(共享组件,与数字孪生页风格一致) */}
-
-
{/* 园区今日概览(五个指标一行 + 标题右侧运行状态) */}
diff --git a/src/pages/DigitalTwin.jsx b/src/pages/DigitalTwin.jsx
index e51943d..6a89c28 100644
--- a/src/pages/DigitalTwin.jsx
+++ b/src/pages/DigitalTwin.jsx
@@ -1,8 +1,6 @@
import { useState, useEffect, lazy, Suspense } from 'react';
-import { useNavigate } from 'react-router-dom';
import '../styles/datascreen.css';
import Icon from '../components/Icons';
-import PageHeader from '../components/PageHeader';
/* 3D 园区建筑懒加载 */
const Building3D = lazy(() => import('../components/Building3D'));
@@ -10,7 +8,7 @@ const Building3D = lazy(() => import('../components/Building3D'));
/* =========================================================
园区数字孪生 · 独立页面(/twin)
全屏 3D 建筑模型 + 实时动态 + 企业分布
- 左右方向键:← 返回数据大屏 · → 进入管理后台
+ 页眉由 ScreenLayout 统一注入,左右方向键切换页面
========================================================= */
/* ---------- 企业数据(名称 / 区域 / 房间号) ---------- */
@@ -45,7 +43,6 @@ const COMPANIES = [
const ZONES = ['加速区', '国际区', '成长区'];
export default function DigitalTwin() {
- const navigate = useNavigate();
const [feed, setFeed] = useState(initFeed());
const [activeZone, setActiveZone] = useState('加速区');
const [selected, setSelected] = useState(null);
@@ -58,28 +55,12 @@ export default function DigitalTwin() {
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 zoneColors = { 加速区: '#4c8dff', 国际区: '#22d3ee', 成长区: '#34d399' };
return (
- {/* 顶栏(共享组件,与数据大屏风格一致) */}
-
-
- {/* 主体:3D 为主 + 侧边栏 */}
+ {/* 主体:3D 为主 + 侧边栏(页眉由 ScreenLayout 统一注入) */}
{/* 3D 场景 */}
diff --git a/src/pages/MediaScreen.jsx b/src/pages/MediaScreen.jsx
new file mode 100644
index 0000000..0ef6548
--- /dev/null
+++ b/src/pages/MediaScreen.jsx
@@ -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('播放列表为空,前往管理后台添加');
+ 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('播放列表已清空,前往管理后台添加');
+ 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 (
+
+ {/* 背景光晕 */}
+
+
+
+ {/* 主体:媒体播放器 + 控制层(页眉由 ScreenLayout 注入) */}
+
+ {/* 媒体播放器 */}
+
+
+
+ {/* 加载中 */}
+ {!loaded && (
+
+ )}
+
+ {/* 中央播放控制(悬浮层) */}
+
+ {/* 播放/暂停 */}
+
+ {/* 上一段 / 下一段 */}
+
+
+
+ {currentItem ? `${index + 1}` : '0'}
+ / {list.length}
+
+
+
+ {/* 当前播放名 + LIVE(点击进入后台登录) */}
+
+
+ {currentItem ? currentItem.name : '等待播放…'}
+
+ {/* 声音 */}
+
+
+ 点击开启声音
+
+
+
+
+ {/* 页脚:园区概览(与数据大屏一致) */}
+
+
+ {/* 登录弹窗 */}
+
{ if (e.target === e.currentTarget) handleCloseLogin(); }}>
+
e.stopPropagation()}>
+
+
+
+
+ { setLoginUsername(e.target.value); setLoginError(''); }}
+ onKeyDown={handleLoginKeyDown}
+ placeholder="请输入账号"
+ autoComplete="username"
+ />
+
+
+
+ { setLoginPassword(e.target.value); setLoginError(''); }}
+ onKeyDown={handleLoginKeyDown}
+ placeholder="请输入密码"
+ autoComplete="current-password"
+ />
+
+ {loginError &&
{loginError}
}
+
+
+
+
+ );
+}
diff --git a/src/styles/mediascreen.css b/src/styles/mediascreen.css
new file mode 100644
index 0000000..6e70913
--- /dev/null
+++ b/src/styles/mediascreen.css
@@ -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);
+}
diff --git a/src/utils/pageNav.js b/src/utils/pageNav.js
new file mode 100644
index 0000000..d1bf049
--- /dev/null
+++ b/src/utils/pageNav.js
@@ -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);
+}
diff --git a/src/utils/parkData.js b/src/utils/parkData.js
new file mode 100644
index 0000000..48e1ab3
--- /dev/null
+++ b/src/utils/parkData.js
@@ -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;
+}