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 注入) */} +
+ {/* 媒体播放器 */} +
+ + {/* 页脚:园区概览(与数据大屏一致) */} + + + {/* 登录弹窗 */} +
{ 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; +}