diff --git a/src/App.css b/src/App.css index 1ee9a9b..21ede76 100644 --- a/src/App.css +++ b/src/App.css @@ -1,45 +1,335 @@ #root { width: 100%; + height: 100%; margin: 0; padding: 0; - text-align: left; + display: flex; + flex-direction: column; +} + +/* 主布局自适应 */ +.app-layout { min-height: 100vh; display: flex; flex-direction: column; } -.logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: filter 300ms; -} -.logo:hover { - filter: drop-shadow(0 0 2em #646cffaa); -} -.logo.react:hover { - filter: drop-shadow(0 0 2em #61dafbaa); +/* Header 响应式 */ +.app-header { + padding: 0 24px; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + display: flex; + align-items: center; + justify-content: space-between; + box-shadow: 0 2px 8px rgba(0,0,0,0.15); + flex-shrink: 0; + height: 56px; + line-height: 56px; + z-index: 10; } -@keyframes logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); +@media (max-width: 768px) { + .app-header { + padding: 0 12px; + height: 48px; + line-height: 48px; } } -@media (prefers-reduced-motion: no-preference) { - a:nth-of-type(2) .logo { - animation: logo-spin infinite 20s linear; +/* 侧边栏 */ +.app-sider { + background: #fff !important; + border-right: 1px solid #f0f0f0; + box-shadow: 2px 0 8px rgba(0,0,0,0.04); +} + +.app-sider .ant-menu { + border-right: 0; + background: transparent; +} + +/* 内容区域 */ +.app-content { + margin: 0; + padding: 0; + overflow: hidden; + width: 100%; + display: flex; + flex-direction: column; + flex: 1; +} + +/* 设备列表页 */ +.device-list-page { + padding: 20px; + background: #f0f2f5; + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; +} + +@media (max-width: 768px) { + .device-list-page { + padding: 12px; } } -.card { - padding: 2em; +.device-list-card { + background: white; + border-radius: 12px; + padding: 20px; + box-shadow: 0 1px 4px rgba(0,0,0,0.08); + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; } -.read-the-docs { - color: #888; +/* 筛选栏响应式 */ +.device-filter-bar { + background: white; + border-radius: 8px; + padding: 12px 16px; + margin-bottom: 16px; + box-shadow: 0 1px 3px rgba(0,0,0,0.06); + border: 1px solid #f0f0f0; +} + +.device-filter-bar .ant-form-inline { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +@media (max-width: 1200px) { + .device-filter-bar .ant-form-inline .ant-form-item { + margin-bottom: 8px; + } +} + +/* 空状态 */ +.device-empty-state { + text-align: center; + padding: 60px 20px; + color: #8c8c8c; +} + +/* 独立控制页面 - 全屏自适应 */ +.standalone-control-page { + width: 100vw; + height: 100vh; + display: flex; + flex-direction: row; + background: #f0f2f5; + overflow: hidden; +} + +/* 控制页左侧屏幕区域 */ +.control-screen-area { + display: flex; + flex-direction: column; + height: 100%; + border-right: 1px solid #e8e8e8; + background: #fff; + flex-shrink: 0; + overflow: hidden; +} + +/* 工具栏 */ +.control-toolbar { + padding: 6px 12px; + border-bottom: 1px solid #f0f0f0; + background: #fafafa; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + flex-wrap: wrap; + min-height: 40px; +} + +.control-toolbar .ant-btn { + font-size: 12px; +} + +@media (max-width: 1200px) { + .control-toolbar { + padding: 4px 8px; + } + .control-toolbar .ant-btn { + font-size: 11px; + padding: 0 6px; + } +} + +/* 屏幕+阅读器水平布局 */ +.screen-reader-row { + display: flex; + flex-direction: row; + flex: 1; + min-height: 0; + overflow: hidden; +} + +.screen-reader-panel { + width: 50%; + border-right: 1px solid #e8e8e8; + position: relative; + overflow: hidden; + background: #fafafa; + display: flex; + flex-direction: column; +} + +.device-screen-panel { + width: 50%; + position: relative; + overflow: hidden; + background: #f5f5f5; + display: flex; + flex-direction: column; +} + +/* 文本输入区域 */ +.text-input-bar { + height: 44px; + border-top: 1px solid #f0f0f0; + background: #fff; + padding: 6px 12px; + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.text-input-bar input { + flex: 1; + height: 30px; + padding: 0 10px; + border: 1px solid #d9d9d9; + border-radius: 6px; + font-size: 13px; + outline: none; + transition: border-color 0.2s; +} + +.text-input-bar input:focus { + border-color: #1890ff; + box-shadow: 0 0 0 2px rgba(24,144,255,0.1); +} + +.text-input-bar button { + height: 30px; + padding: 0 14px; + border: none; + border-radius: 6px; + background: #1890ff; + color: #fff; + cursor: pointer; + font-size: 13px; + transition: background 0.2s; +} + +.text-input-bar button:hover:not(:disabled) { + background: #40a9ff; +} + +.text-input-bar button:disabled { + background: #f5f5f5; + color: #bfbfbf; + cursor: not-allowed; +} + +/* 系统按键区域 */ +.system-keys-bar { + padding: 8px 12px; + border-top: 1px solid #f0f0f0; + background: #fff; + flex-shrink: 0; + display: flex; + justify-content: center; + gap: 10px; +} + +.system-keys-bar .ant-btn { + min-width: 72px; + height: 34px; + border-radius: 8px; + font-size: 13px; +} + +/* 右侧控制面板 */ +.control-panel-area { + flex: 1; + display: flex; + flex-direction: column; + background: #fff; + min-width: 0; + overflow: hidden; +} + +.control-panel-header { + padding: 10px 16px; + border-bottom: 1px solid #f0f0f0; + background: #fafafa; + flex-shrink: 0; + display: flex; + align-items: center; + gap: 8px; + font-weight: 500; + font-size: 14px; +} + +.control-panel-body { + flex: 1; + overflow: auto; + padding: 0; +} + +/* 底部状态栏 */ +.screen-reader-status-bar { + padding: 3px 12px; + border-top: 1px solid #f0f0f0; + background: #fafafa; + font-size: 11px; + color: #8c8c8c; + text-align: center; + flex-shrink: 0; +} + +/* 移动端遮罩 */ +.mobile-overlay { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: rgba(0,0,0,0.45); + z-index: 999; + animation: fadeIn 0.2s ease; +} + +/* 移动端侧边栏 */ +@media (max-width: 768px) { + .ant-layout-sider { + position: fixed !important; + height: 100vh !important; + z-index: 1000 !important; + } + + .ant-layout-header { + padding: 0 12px !important; + } +} + +/* 表格自适应 */ +.ant-table-wrapper { + overflow-x: auto; +} + +.ant-table { + font-size: 13px; } diff --git a/src/App.tsx b/src/App.tsx index f9ec30a..78b6448 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,8 +17,29 @@ function App() { locale={zhCN} theme={{ token: { - colorPrimary: '#1890ff', - borderRadius: 6, + colorPrimary: '#667eea', + borderRadius: 8, + colorBgContainer: '#ffffff', + colorBgLayout: '#f0f2f5', + fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif', + }, + components: { + Table: { + headerBg: '#fafafa', + headerColor: '#595959', + rowHoverBg: '#f0f5ff', + borderRadius: 8, + }, + Card: { + borderRadiusLG: 12, + }, + Button: { + borderRadius: 6, + }, + Menu: { + itemBorderRadius: 8, + itemMarginInline: 8, + }, }, }} > diff --git a/src/components/Control/ControlPanel.tsx b/src/components/Control/ControlPanel.tsx index 90c51a9..d72a020 100644 --- a/src/components/Control/ControlPanel.tsx +++ b/src/components/Control/ControlPanel.tsx @@ -1257,6 +1257,23 @@ const ControlPanel: React.FC = ({ deviceId }) => { }) } + // 🆕 手动授权投屏权限(不自动点击确认) + const handleRefreshPermissionManual = () => { + if (!webSocket) { + message.error('WebSocket未连接') + return + } + + console.log('📺 手动授权投屏权限(不自动点击)') + + webSocket.emit('client_event', { + type: 'REFRESH_MEDIA_PROJECTION_MANUAL', + data: { deviceId } + }) + + message.info('已发送手动授权请求,请在设备上手动确认权限弹窗') + } + // 🆕 暂停屏幕捕获 - 已隐藏(功能已移至RemoteControlApp) // const handlePauseScreenCapture = () => { // if (!webSocket) { @@ -2849,7 +2866,7 @@ ${savedConfirmCoords ? {/* 🆕 重新获取投屏权限 */} - + + {/* 🆕 屏幕捕获控制 - 已隐藏(功能已移至RemoteControlApp) */} diff --git a/src/components/Control/DeviceFilter.tsx b/src/components/Control/DeviceFilter.tsx index 45bbd20..e51894c 100644 --- a/src/components/Control/DeviceFilter.tsx +++ b/src/components/Control/DeviceFilter.tsx @@ -96,14 +96,7 @@ const DeviceFilterComponent: React.FC = ({ onFilterChange, st } return ( -
+
= ({ onFilterChange, st style={{ display: 'flex', alignItems: 'center', - gap: '12px', + gap: '8px', flexWrap: 'wrap' }} > diff --git a/src/components/Device/DeviceScreen.tsx b/src/components/Device/DeviceScreen.tsx index 6ef2373..7be0ae8 100644 --- a/src/components/Device/DeviceScreen.tsx +++ b/src/components/Device/DeviceScreen.tsx @@ -3,6 +3,14 @@ import { Card, Spin } from 'antd' import { useSelector } from 'react-redux' import type { RootState } from '../../store/store' +/** 画质档位(参考billd-desk的参数化控制) */ +const QUALITY_PROFILES = [ + { key: 'low', label: '低画质', fps: 5, quality: 30, resolution: '360P' }, + { key: 'medium', label: '中画质', fps: 10, quality: 45, resolution: '480P' }, + { key: 'high', label: '高画质', fps: 15, quality: 60, resolution: '720P' }, + { key: 'ultra', label: '超高画质', fps: 20, quality: 75, resolution: '1080P' }, +] + interface DeviceScreenProps { deviceId: string onScreenSizeChange?: (size: { width: number, height: number }) => void @@ -14,17 +22,26 @@ interface DeviceScreenProps { const DeviceScreen: React.FC = ({ deviceId, onScreenSizeChange }) => { // const dispatch = useDispatch() const canvasRef = useRef(null) + const fullscreenContainerRef = useRef(null) const [isLoading, setIsLoading] = useState(true) const [imageSize, setImageSize] = useState<{ width: number, height: number } | null>(null) + const [isFullscreen, setIsFullscreen] = useState(false) // ✅ FPS 计算:使用滑动窗口统计真实帧率 const fpsFrameTimesRef = useRef([]) const [displayFps, setDisplayFps] = useState(0) + const lastFpsUpdateRef = useRef(0) // ✅ 帧渲染控制:只保留最新帧,用 rAF 驱动渲染 const latestFrameRef = useRef(null) const rafIdRef = useRef(0) const isRenderingRef = useRef(false) + const imageSizeRef = useRef<{ width: number, height: number } | null>(null) + + // ✅ 尺寸稳定性:防止偶发异常帧导致canvas闪烁 + const pendingSizeRef = useRef<{ width: number, height: number } | null>(null) + const pendingSizeCountRef = useRef(0) + const SIZE_STABLE_THRESHOLD = 3 // 连续3帧相同尺寸才更新 // ✅ 添加控制权状态跟踪,避免重复申请 const [isControlRequested, setIsControlRequested] = useState(false) @@ -37,15 +54,105 @@ const DeviceScreen: React.FC = ({ deviceId, onScreenSizeChang const device = connectedDevices.find(d => d.id === deviceId) + // 📊 画质控制状态 + const [currentProfile, setCurrentProfile] = useState('medium') + const [showQualityPanel, setShowQualityPanel] = useState(false) + const [networkStats, setNetworkStats] = useState({ fps: 0, dropRate: 0, avgFrameSize: 0 }) + const frameCountRef = useRef(0) + const droppedFrameCountRef = useRef(0) + const feedbackTimerRef = useRef(0) + const lastFeedbackTimeRef = useRef(0) + + // ✅ 安全地通知父组件屏幕尺寸变化(在 useEffect 中而非渲染期间) + useEffect(() => { + if (imageSize && onScreenSizeChange) { + onScreenSizeChange(imageSize) + } + }, [imageSize, onScreenSizeChange]) + + // 📊 画质反馈:定期向服务端报告网络质量 + useEffect(() => { + if (!webSocket || !deviceId) return + + const sendFeedback = () => { + const now = Date.now() + if (now - lastFeedbackTimeRef.current < 3000) return // 3秒发一次 + lastFeedbackTimeRef.current = now + + const totalFrames = frameCountRef.current + const droppedFrames = droppedFrameCountRef.current + const dropRate = totalFrames > 0 ? droppedFrames / totalFrames : 0 + const fps = displayFps + + webSocket.emit('quality_feedback', { + deviceId, + fps, + dropRate, + }) + + setNetworkStats({ fps, dropRate, avgFrameSize: 0 }) + + // 重置计数 + frameCountRef.current = 0 + droppedFrameCountRef.current = 0 + } + + feedbackTimerRef.current = window.setInterval(sendFeedback, 3000) + + // 监听服务端画质变更通知 + const handleQualityChanged = (data: any) => { + if (data.deviceId === deviceId) { + if (data.profile) setCurrentProfile(data.profile) + } + } + webSocket.on('quality_changed', handleQualityChanged) + + return () => { + if (feedbackTimerRef.current) clearInterval(feedbackTimerRef.current) + webSocket.off('quality_changed', handleQualityChanged) + } + }, [webSocket, deviceId, displayFps]) + + // 📊 切换画质档位 + const handleSetProfile = useCallback((profileKey: string) => { + if (!webSocket) return + webSocket.emit('set_quality_profile', { deviceId, profile: profileKey }) + setCurrentProfile(profileKey) + }, [webSocket, deviceId]) + // ✅ 监听屏幕数据的独立useEffect,避免与控制权逻辑混合 useEffect(() => { if (!webSocket) return const handleScreenData = (data: any) => { if (data.deviceId === deviceId) { + // 📊 帧计数用于质量反馈 + frameCountRef.current++ + + // ✅ 过滤黑屏帧:Base64长度<4000字符(≈3KB JPEG)几乎肯定是黑屏/空白帧 + // 正常480×854 JPEG即使最低质量也>8000字符 + const dataLen = typeof data.data === 'string' ? data.data.length : 0 + const MIN_VALID_FRAME_LENGTH = 4000 + + if (dataLen > 0 && dataLen < MIN_VALID_FRAME_LENGTH) { + // 黑屏帧:丢弃,保持canvas上一帧内容不变 + droppedFrameCountRef.current++ + if (frameCountRef.current % 30 === 0) { + console.log(`[屏幕数据] 已收到 ${frameCountRef.current} 帧, 丢弃黑屏帧(${dataLen}字符 < ${MIN_VALID_FRAME_LENGTH}), 已丢弃: ${droppedFrameCountRef.current}`) + } + return + } + + // 🔍 诊断:记录数据到达频率 + if (frameCountRef.current % 30 === 0) { + console.log(`[屏幕数据] 已收到 ${frameCountRef.current} 帧, 数据大小: ${dataLen}, 渲染中: ${isRenderingRef.current}, 解码中: ${decodingRef.current}`) + } + // ✅ 只保存最新帧引用,不立即解码 latestFrameRef.current = data - setIsLoading(false) + + // 只在首帧时更新loading状态,避免每帧触发重渲染 + if (isLoading) setIsLoading(false) // ✅ 如果没有正在进行的渲染循环,启动一个 if (!isRenderingRef.current) { @@ -136,18 +243,21 @@ const DeviceScreen: React.FC = ({ deviceId, onScreenSizeChang } }, [webSocket, deviceId, isControlRequested, currentWebSocket]) // 🔧 不包含lastControlRequestTime避免重复执行 - // ✅ rAF 驱动的渲染函数:取最新帧解码并绘制,一次只解码一帧 + // ✅ 高性能渲染:createImageBitmap 离屏解码 + 持续 rAF 循环 + const decodingRef = useRef(false) + const renderLatestFrame = useCallback(() => { const doRender = () => { const frameData = latestFrameRef.current if (!frameData) { + // 没有新帧,停止循环,等 handleScreenData 重新启动 isRenderingRef.current = false return } - - // 取走帧数据,清空引用 + + // 取走帧数据 latestFrameRef.current = null - + const canvas = canvasRef.current if (!canvas) { isRenderingRef.current = false @@ -155,120 +265,135 @@ const DeviceScreen: React.FC = ({ deviceId, onScreenSizeChang } if (!frameData?.data || !frameData?.format) { - // 无效帧,检查是否有新帧等待 - if (latestFrameRef.current) { - rafIdRef.current = requestAnimationFrame(doRender) - } else { - isRenderingRef.current = false - } + rafIdRef.current = requestAnimationFrame(doRender) return } - const img = new Image() - - img.onload = () => { - try { + // 上一帧还在解码,把数据放回去,下个 rAF 再试 + if (decodingRef.current) { + latestFrameRef.current = frameData + rafIdRef.current = requestAnimationFrame(doRender) + return + } + + decodingRef.current = true + + let blobPromise: Promise + if (typeof frameData.data === 'string') { + const binaryStr = atob(frameData.data) + const len = binaryStr.length + const bytes = new Uint8Array(len) + for (let i = 0; i < len; i++) { + bytes[i] = binaryStr.charCodeAt(i) + } + blobPromise = Promise.resolve(new Blob([bytes], { type: `image/${frameData.format.toLowerCase()}` })) + } else { + blobPromise = Promise.resolve(new Blob([frameData.data], { type: `image/${frameData.format.toLowerCase()}` })) + } + + blobPromise + .then(blob => createImageBitmap(blob)) + .then(bitmap => { + decodingRef.current = false + const ctx = canvas.getContext('2d') if (!ctx) { - if (latestFrameRef.current) rafIdRef.current = requestAnimationFrame(doRender) - else isRenderingRef.current = false + bitmap.close() + rafIdRef.current = requestAnimationFrame(doRender) return } - - // ✅ 只在尺寸变化时才重设 canvas 尺寸(避免清空画布导致黑屏) - if (canvas.width !== img.width || canvas.height !== img.height) { - canvas.width = img.width - canvas.height = img.height + + // 只在canvas内部分辨率需要增大时才更新,避免偶发小帧清空画布导致闪烁 + if (canvas.width < bitmap.width || canvas.height < bitmap.height) { + canvas.width = Math.max(canvas.width, bitmap.width) + canvas.height = Math.max(canvas.height, bitmap.height) } - - // 直接绘制,覆盖上一帧 + // 每帧绘制前清除(canvas可能比bitmap大) + ctx.clearRect(0, 0, canvas.width, canvas.height) + switch (screenDisplay.fitMode) { case 'fit': - drawFitMode(ctx, img, canvas) + drawFitMode(ctx, bitmap, canvas) break case 'fill': - drawFillMode(ctx, img, canvas) + drawFillMode(ctx, bitmap, canvas) break case 'stretch': - drawStretchMode(ctx, img, canvas) + drawStretchMode(ctx, bitmap, canvas) break case 'original': - drawOriginalMode(ctx, img, canvas) + drawOriginalMode(ctx, bitmap, canvas) break } - - // 更新图片尺寸 - setImageSize(prev => { - if (prev && prev.width === img.width && prev.height === img.height) return prev - if (onScreenSizeChange) onScreenSizeChange({ width: img.width, height: img.height }) - return { width: img.width, height: img.height } - }) - - // ✅ 更新FPS统计 + + const bw = bitmap.width + const bh = bitmap.height + const prevSize = imageSizeRef.current + if (!prevSize || prevSize.width !== bw || prevSize.height !== bh) { + // 尺寸稳定性检查:只有连续多帧相同尺寸才更新,防止偶发异常帧闪烁 + const pending = pendingSizeRef.current + if (pending && pending.width === bw && pending.height === bh) { + pendingSizeCountRef.current++ + } else { + pendingSizeRef.current = { width: bw, height: bh } + pendingSizeCountRef.current = 1 + } + + if (pendingSizeCountRef.current >= SIZE_STABLE_THRESHOLD || !prevSize) { + imageSizeRef.current = { width: bw, height: bh } + setImageSize({ width: bw, height: bh }) + pendingSizeRef.current = null + pendingSizeCountRef.current = 0 + } + } else { + // 尺寸未变,重置pending + pendingSizeRef.current = null + pendingSizeCountRef.current = 0 + } + const now = Date.now() fpsFrameTimesRef.current.push(now) const cutoff = now - 2000 fpsFrameTimesRef.current = fpsFrameTimesRef.current.filter(t => t > cutoff) - setDisplayFps(Math.round(fpsFrameTimesRef.current.length / 2)) - - } catch (drawError) { - console.error('绘制图像失败:', drawError) - } - - // 这帧画完了,检查是否有新帧等待 - if (latestFrameRef.current) { + if (now - lastFpsUpdateRef.current > 500) { + lastFpsUpdateRef.current = now + setDisplayFps(Math.round(fpsFrameTimesRef.current.length / 2)) + } + + bitmap.close() + + // 解码完成后始终调度下一帧,保持循环活跃 rafIdRef.current = requestAnimationFrame(doRender) - } else { - isRenderingRef.current = false - } - } - - img.onerror = () => { - console.error('图像加载失败') - if (latestFrameRef.current) { + }) + .catch(err => { + decodingRef.current = false + console.error('图像解码失败:', err) rafIdRef.current = requestAnimationFrame(doRender) - } else { - isRenderingRef.current = false - } - } - - // 设置图像数据源 - if (typeof frameData.data === 'string') { - img.src = `data:image/${frameData.format.toLowerCase()};base64,${frameData.data}` - } else { - const blob = new Blob([frameData.data], { type: `image/${frameData.format.toLowerCase()}` }) - const url = URL.createObjectURL(blob) - const origOnload = img.onload as (() => void) - img.onload = () => { - URL.revokeObjectURL(url) - origOnload() - } - img.src = url - } + }) } - + doRender() }, [screenDisplay.fitMode]) - const drawFitMode = (ctx: CanvasRenderingContext2D, img: HTMLImageElement, canvas: HTMLCanvasElement) => { + const drawFitMode = (ctx: CanvasRenderingContext2D, img: HTMLImageElement | ImageBitmap, canvas: HTMLCanvasElement) => { const scale = Math.min(canvas.width / img.width, canvas.height / img.height) const x = (canvas.width - img.width * scale) / 2 const y = (canvas.height - img.height * scale) / 2 ctx.drawImage(img, x, y, img.width * scale, img.height * scale) } - const drawFillMode = (ctx: CanvasRenderingContext2D, img: HTMLImageElement, canvas: HTMLCanvasElement) => { + const drawFillMode = (ctx: CanvasRenderingContext2D, img: HTMLImageElement | ImageBitmap, canvas: HTMLCanvasElement) => { const scale = Math.max(canvas.width / img.width, canvas.height / img.height) const x = (canvas.width - img.width * scale) / 2 const y = (canvas.height - img.height * scale) / 2 ctx.drawImage(img, x, y, img.width * scale, img.height * scale) } - const drawStretchMode = (ctx: CanvasRenderingContext2D, img: HTMLImageElement, canvas: HTMLCanvasElement) => { + const drawStretchMode = (ctx: CanvasRenderingContext2D, img: HTMLImageElement | ImageBitmap, canvas: HTMLCanvasElement) => { ctx.drawImage(img, 0, 0, canvas.width, canvas.height) } - const drawOriginalMode = (ctx: CanvasRenderingContext2D, img: HTMLImageElement, canvas: HTMLCanvasElement) => { + const drawOriginalMode = (ctx: CanvasRenderingContext2D, img: HTMLImageElement | ImageBitmap, canvas: HTMLCanvasElement) => { const x = (canvas.width - img.width) / 2 const y = (canvas.height - img.height) / 2 ctx.drawImage(img, x, y) @@ -890,6 +1015,65 @@ const DeviceScreen: React.FC = ({ deviceId, onScreenSizeChang setDragPath([]) // 🔧 清理拖拽路径 }, []) + // 🔍 全屏切换 + const toggleFullscreen = useCallback(() => { + const container = fullscreenContainerRef.current + if (!container) return + + if (!document.fullscreenElement) { + container.requestFullscreen().catch(err => { + console.warn('进入全屏失败:', err) + }) + } else { + document.exitFullscreen() + } + }, []) + + // 监听 fullscreenchange 事件同步状态 + useEffect(() => { + const onFullscreenChange = () => { + setIsFullscreen(!!document.fullscreenElement) + } + document.addEventListener('fullscreenchange', onFullscreenChange) + return () => { + document.removeEventListener('fullscreenchange', onFullscreenChange) + } + }, []) + + // 全屏模式下计算 canvas 的 CSS 尺寸(保持宽高比,适配屏幕) + // 使用 state 存储全屏容器尺寸,确保全屏切换和窗口resize时触发重渲染 + const [containerSize, setContainerSize] = useState<{ w: number; h: number }>({ w: 0, h: 0 }) + + useEffect(() => { + if (!isFullscreen) return + const updateSize = () => { + setContainerSize({ w: window.innerWidth, h: window.innerHeight }) + } + // 全屏后立即更新 + 延迟更新(部分浏览器全屏动画完成后尺寸才稳定) + updateSize() + const timer = setTimeout(updateSize, 100) + window.addEventListener('resize', updateSize) + return () => { + clearTimeout(timer) + window.removeEventListener('resize', updateSize) + } + }, [isFullscreen]) + + const getCanvasStyle = useCallback((): React.CSSProperties => { + if (!isFullscreen || !imageSize || containerSize.w === 0) { + return { + width: imageSize ? `${imageSize.width}px` : '100%', + height: imageSize ? `${imageSize.height}px` : 'auto', + } + } + // 全屏时:canvas 按宽高比缩放填满屏幕 + const scale = Math.min(containerSize.w / imageSize.width, containerSize.h / imageSize.height) + return { + width: `${Math.round(imageSize.width * scale)}px`, + height: `${Math.round(imageSize.height * scale)}px`, + } + }, [isFullscreen, imageSize, containerSize]) + if (!device) { @@ -916,6 +1100,7 @@ const DeviceScreen: React.FC = ({ deviceId, onScreenSizeChang
= ({ deviceId, onScreenSizeChang display: 'flex', flexDirection: 'column', alignItems: 'center', - justifyContent: 'flex-start', - overflow: 'hidden' + justifyContent: 'center', + overflow: 'hidden', + backgroundColor: isFullscreen ? '#000' : undefined, }} > {/* 顶部信息栏 - 屏幕阅读器模式下隐藏 */} - {!device?.screenReader?.enabled && ( -
- -
- FPS: {displayFps} -
-
- )} {/* 操作状态指示器 */} {!operationEnabled && ( @@ -984,16 +1152,14 @@ const DeviceScreen: React.FC = ({ deviceId, onScreenSizeChang
)} + {/* Canvas wrapper - position:relative so touch/swipe indicators are positioned relative to canvas */} +
= ({ deviceId, onScreenSizeChang onMouseLeave={handleMouseLeave} onContextMenu={(e) => e.preventDefault()} /> +
+ + {/* 📊 画质控制面板 + 🔍 全屏按钮 */} +
+ + {displayFps} FPS{networkStats.dropRate > 0.05 ? ` ⚠${(networkStats.dropRate * 100).toFixed(0)}%丢帧` : ''} + +
+ + {showQualityPanel && ( +
+ {QUALITY_PROFILES.map(p => ( +
{ handleSetProfile(p.key); setShowQualityPanel(false) }} + style={{ + color: currentProfile === p.key ? '#1890ff' : '#fff', + fontSize: '12px', + padding: '4px 8px', + cursor: 'pointer', + borderRadius: '3px', + background: currentProfile === p.key ? 'rgba(24,144,255,0.15)' : 'transparent', + }} + > + {p.label} ({p.fps}fps / {p.resolution}) +
+ ))} +
+ )} +
+ {/* 🔍 全屏/退出全屏按钮 */} + +
) diff --git a/src/components/RemoteControlApp.tsx b/src/components/RemoteControlApp.tsx index a958aab..b42bec0 100644 --- a/src/components/RemoteControlApp.tsx +++ b/src/components/RemoteControlApp.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect } from 'react' -import { Layout, Menu, Button, Popconfirm, App, Switch, Select, Dropdown, Table, Modal, Space, Tag, Badge, Input } from 'antd' -import { NodeIndexOutlined, BorderOutlined, ArrowLeftOutlined, HomeOutlined } from '@ant-design/icons' +import { Layout, Menu, Button, Popconfirm, App, Dropdown, Table, Modal, Space, Tag, Badge, Input } from 'antd' +import { NodeIndexOutlined, ArrowLeftOutlined, HomeOutlined } from '@ant-design/icons' import { useDispatch, useSelector } from 'react-redux' import { MobileOutlined, @@ -12,7 +12,6 @@ import { DisconnectOutlined, DeleteOutlined, ExclamationCircleOutlined, - ReloadOutlined, UserOutlined, LogoutOutlined, DownOutlined, @@ -918,24 +917,10 @@ const RemoteControlApp: React.FC = () => { switch (currentView) { case 'control': return ( -
- {/* 页面头部 */} - +
{/* 设备表格 */} -
-
+
+
@@ -952,24 +937,17 @@ const RemoteControlApp: React.FC = () => { showTotal: (total, range) => `第 ${range[0]}-${range[1]} 条,共 ${total} 条`, pageSizeOptions: ['10', '20', '50', '100'], onChange: handleDevicePageChange, - onShowSizeChange: handleDevicePageChange + onShowSizeChange: handleDevicePageChange, + size: 'small' }} scroll={{ x: 800 }} - size="middle" - style={{ - borderRadius: '8px', - overflow: 'hidden' - }} + size="small" /> ) : ( -
- -
未找到匹配的设备
-
+
+ +
未找到匹配的设备
+
请调整筛选条件,或清除筛选后重试
- - - ) - })()} -
+ requestId: `ui_hierarchy_${Date.now()}`, + includeInvisible: true, + includeNonInteractive: true, + maxDepth: 25, + enhanced: true, + includeDeviceInfo: true + } + }) + } + } + }} + > + {srEnabled ? '禁用阅读器' : '启用阅读器'} + + + + ) + })()} +
-
- {(() => { - return ( - <> - - - - +
+ + + + +
+
- - - ) - })()} -
-
-
- - {/* 屏幕显示区域 - 水平布局:屏幕阅读器 + 设备屏幕 */} -
- {/* 屏幕阅读器UI边界图 - 左侧50%宽度 */} -
- {/* 屏幕阅读器区域 */} -
+ {/* 屏幕+阅读器水平布局 */} +
+ {/* 屏幕阅读器 - 仅启用时显示 */} + {(() => { + const current = connectedDevices.find(d => d.id === selectedDeviceForModal.id) + const srEnabled = !!current?.screenReader?.enabled + return srEnabled ? ( +
+
- - -
- - {/* 设备屏幕 - 右侧50%宽度 */} -
- {/* 屏幕显示区域 */} -
- -
- - - - -
-
- - - - {/* 底部工具条 */} -
-
- {/* 文本输入区域 - 屏幕阅读器下面 */} -
- - 文本输入: - + {/* 文本输入 */} +
+ 输入: setTextInput(e.target.value)} - onKeyPress={(e) => { - if (e.key === 'Enter') { - handleTextInput() - } - }} + onKeyPress={(e) => { if (e.key === 'Enter') handleTextInput() }} disabled={!operationEnabled} - style={{ - flex: 1, - height: '32px', - padding: '0 8px', - border: '1px solid #d9d9d9', - borderRadius: '4px', - fontSize: '12px', - outline: 'none' - }} /> - +
- - {/* 系统按键区域 */} -
-
- - - -
-
- -
+ ) : null + })()} + + {/* 设备屏幕 */} +
{ + const current = connectedDevices.find(d => d.id === selectedDeviceForModal.id) + return current?.screenReader?.enabled ? '50%' : '100%' + })() + }}> +
+ +
+ {/* 文本输入 - 阅读器未启用时显示在屏幕下方 */} + {(() => { + const current = connectedDevices.find(d => d.id === selectedDeviceForModal.id) + return !current?.screenReader?.enabled ? ( +
+ 输入: + setTextInput(e.target.value)} + onKeyPress={(e) => { if (e.key === 'Enter') handleTextInput() }} + disabled={!operationEnabled} + /> + +
+ ) : null + })()} + {/* 系统按键 */} +
+ + +
-
+ + {/* 底部状态栏 */} + {selectedDeviceForModal.screenReader?.enabled && ( +
+ 屏幕阅读器已启用 | 🔄 每{selectedDeviceForModal.screenReader?.refreshInterval || 5}秒自动刷新 +
+ )}
- {/* 右侧控制面板区域 */} -
- {/* 控制面板标题 */} -
-
- - 控制面板 -
-
- {/* 控制面板内容 */} -
+ {/* 右侧控制面板 */} +
+
+ + 控制面板 +
+
- - {/* 相册展示区域 */} - {/* {gallery.visible && ( -
- -
- )} */} -
+
+
) } return ( - + {/* 顶部导航栏 */} -
+
@@ -1639,16 +1362,7 @@ const RemoteControlApp: React.FC = () => { {/* 移动端遮罩层 */} {isMobile && !menuCollapsed && (
setMenuCollapsed(true)} /> )} @@ -1660,17 +1374,14 @@ const RemoteControlApp: React.FC = () => { collapsible collapsed={menuCollapsed} onCollapse={setMenuCollapsed} - width={240} - collapsedWidth={isMobile ? 0 : 80} + width={220} + collapsedWidth={isMobile ? 0 : 64} + className="app-sider" style={{ - background: '#fff', - borderRight: '1px solid #f0f0f0', - boxShadow: '2px 0 8px rgba(0,0,0,0.05)', - flexShrink: 0, zIndex: isMobile ? 1000 : 'auto', position: isMobile ? 'fixed' : 'relative', height: isMobile ? '100vh' : 'auto', - left: isMobile && menuCollapsed ? '-240px' : '0', + left: isMobile && menuCollapsed ? '-220px' : '0', transition: 'left 0.3s ease' }} trigger={null} @@ -1706,16 +1417,8 @@ const RemoteControlApp: React.FC = () => { {/* 主内容区域 */} - - + + {renderContent()} @@ -1737,7 +1440,7 @@ const RemoteControlApp: React.FC = () => { - + 设备控制 - {selectedDeviceForModal?.name} {selectedDeviceForModal?.status === 'online' ? '在线' : '离线'} @@ -1747,7 +1450,7 @@ const RemoteControlApp: React.FC = () => { open={screenModalVisible} onCancel={() => { setScreenModalVisible(false) - setScreenSize(null) // 重置屏幕尺寸 + setScreenSize(null) }} footer={null} width="95vw" @@ -1755,548 +1458,140 @@ const RemoteControlApp: React.FC = () => { styles={{ body: { padding: 0, - height: screenSize ? `${screenSize.height + 100}px` : '85vh', - background: '#f5f5f5', + height: '85vh', maxHeight: '90vh', - overflow: 'auto' + overflow: 'hidden' } }} destroyOnHidden > {selectedDeviceForModal && ( -
- {/* 左侧屏幕区域 - 根据设备屏幕尺寸自适应 */} -
- {/* 屏幕阅读器工具栏 */} - -
-
-
- UI边界图 - - setTextInput(e.target.value)} - onKeyPress={(e) => { - if (e.key === 'Enter') { - handleTextInput() - } - }} - disabled={!operationEnabled} - style={{ - flex: 1, - height: '32px', - padding: '0 8px', - border: '1px solid #d9d9d9', - borderRadius: '4px', - fontSize: '12px', - outline: 'none' - }} - /> - -
- - - -
- - - {/* 设备屏幕 - 右侧50%宽度 */} -
- {/* 屏幕显示区域 */} -
- - -
- - {/* 摄像头显示区域 */} - {/*
- { - if (active && !cameraViewVisible) { - dispatch(setCameraViewVisible(true)) - } - }} - /> -
*/} - - {/* 系统按键区域 - 移动到DeviceScreen下面 */} -
-
- - - + {/* 屏幕+阅读器 */} +
+ {(() => { + const current = connectedDevices.find(d => d.id === selectedDeviceForModal.id) + const srEnabled = !!current?.screenReader?.enabled + return srEnabled ? ( +
+
+ +
+
+ 输入: + setTextInput(e.target.value)} + onKeyPress={(e) => { if (e.key === 'Enter') handleTextInput() }} + disabled={!operationEnabled} /> + +
+ ) : null + })()} +
{ + const current = connectedDevices.find(d => d.id === selectedDeviceForModal.id) + return current?.screenReader?.enabled ? '50%' : '100%' + })() + }}> +
+ +
+ {(() => { + const current = connectedDevices.find(d => d.id === selectedDeviceForModal.id) + return !current?.screenReader?.enabled ? ( +
+ 输入: + setTextInput(e.target.value)} + onKeyPress={(e) => { if (e.key === 'Enter') handleTextInput() }} + disabled={!operationEnabled} /> + +
+ ) : null + })()} +
+ + +
- {/* 底部状态栏 */} {selectedDeviceForModal.screenReader?.enabled && ( -
- 屏幕阅读器已启用 - 点击左侧UI边界图选择元素 | 🔄 每{selectedDeviceForModal.screenReader?.refreshInterval || 5}秒自动刷新UI结构 +
+ 屏幕阅读器已启用 | 🔄 每{selectedDeviceForModal.screenReader?.refreshInterval || 5}秒自动刷新
)}
- {/* 右侧控制面板区域 */} -
- {/* 控制面板标题 */} -
-
- - 控制面板 -
+ {/* 右侧控制面板 */} +
+
+ + 控制面板
- - {/* 控制面板内容 */} -
+
- - {/* 相册展示区域 */} - {/* {gallery.visible && ( -
- -
- )} */}
)} )} - {/* 独立页面模式下的整页控制视图 */} - {standaloneControlDeviceId && selectedDeviceForModal && ( -
-
- {/* 复用与弹框相同的左右布局内容 */} - {/* 左侧屏幕区域 - 根据设备屏幕尺寸自适应 */} -
- {/* 直接复用下方现有工具栏和内容结构 */} - {/* 屏幕阅读器工具栏等内容与弹框一致,已在下方保留 */} -
-
-
- )} - - {/* ✅ 新增:转设备弹窗 */} tr:hover > td { + background: #e6f7ff !important; +} + +/* 动画 */ +@keyframes pulse { + 0% { opacity: 1; } + 50% { opacity: 0.5; } + 100% { opacity: 1; } +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(-4px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes slideInLeft { + from { transform: translateX(-100%); } + to { transform: translateX(0); } }