Files
web-client/src/components/Device/DeviceScreen.tsx

1009 lines
34 KiB
TypeScript
Raw Normal View History

2026-02-09 16:33:52 +08:00
import React, { useRef, useEffect, useState, useCallback } from 'react'
import { Card, Spin } from 'antd'
import { useSelector } from 'react-redux'
import type { RootState } from '../../store/store'
interface DeviceScreenProps {
deviceId: string
onScreenSizeChange?: (size: { width: number, height: number }) => void
}
/**
*
*/
const DeviceScreen: React.FC<DeviceScreenProps> = ({ deviceId, onScreenSizeChange }) => {
// const dispatch = useDispatch<AppDispatch>()
const canvasRef = useRef<HTMLCanvasElement>(null)
const [isLoading, setIsLoading] = useState(true)
const [imageSize, setImageSize] = useState<{ width: number, height: number } | null>(null)
// ✅ FPS 计算:使用滑动窗口统计真实帧率
const fpsFrameTimesRef = useRef<number[]>([])
const [displayFps, setDisplayFps] = useState(0)
// ✅ 帧渲染控制:只保留最新帧,用 rAF 驱动渲染
const latestFrameRef = useRef<any>(null)
const rafIdRef = useRef<number>(0)
const isRenderingRef = useRef(false)
// ✅ 添加控制权状态跟踪,避免重复申请
const [isControlRequested, setIsControlRequested] = useState(false)
const [currentWebSocket, setCurrentWebSocket] = useState<any>(null)
const [lastControlRequestTime, setLastControlRequestTime] = useState<number>(0)
const { webSocket } = useSelector((state: RootState) => state.connection)
const { connectedDevices } = useSelector((state: RootState) => state.devices)
const { screenDisplay, operationEnabled } = useSelector((state: RootState) => state.ui)
const device = connectedDevices.find(d => d.id === deviceId)
// ✅ 监听屏幕数据的独立useEffect避免与控制权逻辑混合
useEffect(() => {
if (!webSocket) return
const handleScreenData = (data: any) => {
if (data.deviceId === deviceId) {
// ✅ 只保存最新帧引用,不立即解码
latestFrameRef.current = data
setIsLoading(false)
// ✅ 如果没有正在进行的渲染循环,启动一个
if (!isRenderingRef.current) {
isRenderingRef.current = true
renderLatestFrame()
}
}
}
webSocket.on('screen_data', handleScreenData)
return () => {
webSocket.off('screen_data', handleScreenData)
// 清理 rAF
if (rafIdRef.current) {
cancelAnimationFrame(rafIdRef.current)
rafIdRef.current = 0
}
isRenderingRef.current = false
}
}, [webSocket, deviceId])
// ✅ 控制权管理的独立useEffect只在必要时申请/释放
useEffect(() => {
if (!webSocket || !deviceId) return
// 如果WebSocket实例发生变化重置控制权状态
if (currentWebSocket !== webSocket) {
setIsControlRequested(false)
setCurrentWebSocket(webSocket)
}
// 只在未申请过控制权时申请,且防止重复发送
if (!isControlRequested) {
const now = Date.now()
// 🔧 防止短时间内重复发送请求2秒内只能发送一次
if (now - lastControlRequestTime < 2000) {
console.log('⚠️ 控制权请求过于频繁,跳过')
return
}
console.log('🎮 申请设备控制权:', deviceId)
setLastControlRequestTime(now)
webSocket.emit('client_event', {
type: 'REQUEST_DEVICE_CONTROL',
data: { deviceId }
})
setIsControlRequested(true)
}
// 监听控制权响应
const handleControlResponse = (data: any) => {
if (data.deviceId === deviceId) {
if (data.success) {
console.log('✅ 控制权获取成功:', deviceId)
} else {
console.warn('❌ 控制权获取失败:', data.message)
// 如果失败,允许重新申请
setIsControlRequested(false)
}
}
}
// ✅ 监听控制错误,自动重新申请控制权
const handleControlError = (data: any) => {
if (data.deviceId === deviceId && data.error === 'NO_PERMISSION') {
console.warn('⚠️ 检测到权限丢失,重新申请控制权:', deviceId)
setIsControlRequested(false)
}
}
webSocket.on('device_control_response', handleControlResponse)
webSocket.on('control_error', handleControlError)
// 清理函数只在组件卸载或deviceId变化时释放控制权
return () => {
webSocket.off('device_control_response', handleControlResponse)
webSocket.off('control_error', handleControlError)
// 只有在已申请过控制权时才释放
if (isControlRequested) {
console.log('🔓 释放设备控制权:', deviceId)
webSocket.emit('client_event', {
type: 'RELEASE_DEVICE_CONTROL',
data: { deviceId }
})
}
}
}, [webSocket, deviceId, isControlRequested, currentWebSocket]) // 🔧 不包含lastControlRequestTime避免重复执行
// ✅ rAF 驱动的渲染函数:取最新帧解码并绘制,一次只解码一帧
const renderLatestFrame = useCallback(() => {
const doRender = () => {
const frameData = latestFrameRef.current
if (!frameData) {
isRenderingRef.current = false
return
}
// 取走帧数据,清空引用
latestFrameRef.current = null
const canvas = canvasRef.current
if (!canvas) {
isRenderingRef.current = false
return
}
if (!frameData?.data || !frameData?.format) {
// 无效帧,检查是否有新帧等待
if (latestFrameRef.current) {
rafIdRef.current = requestAnimationFrame(doRender)
} else {
isRenderingRef.current = false
}
return
}
const img = new Image()
img.onload = () => {
try {
const ctx = canvas.getContext('2d')
if (!ctx) {
if (latestFrameRef.current) rafIdRef.current = requestAnimationFrame(doRender)
else isRenderingRef.current = false
return
}
// ✅ 只在尺寸变化时才重设 canvas 尺寸(避免清空画布导致黑屏)
if (canvas.width !== img.width || canvas.height !== img.height) {
canvas.width = img.width
canvas.height = img.height
}
// 直接绘制,覆盖上一帧
switch (screenDisplay.fitMode) {
case 'fit':
drawFitMode(ctx, img, canvas)
break
case 'fill':
drawFillMode(ctx, img, canvas)
break
case 'stretch':
drawStretchMode(ctx, img, canvas)
break
case 'original':
drawOriginalMode(ctx, img, 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 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) {
rafIdRef.current = requestAnimationFrame(doRender)
} else {
isRenderingRef.current = false
}
}
img.onerror = () => {
console.error('图像加载失败')
if (latestFrameRef.current) {
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 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 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) => {
ctx.drawImage(img, 0, 0, canvas.width, canvas.height)
}
const drawOriginalMode = (ctx: CanvasRenderingContext2D, img: HTMLImageElement, canvas: HTMLCanvasElement) => {
const x = (canvas.width - img.width) / 2
const y = (canvas.height - img.height) / 2
ctx.drawImage(img, x, y)
}
// 坐标转换函数将canvas坐标转换为设备坐标
const convertCanvasToDeviceCoords = useCallback((canvasX: number, canvasY: number, canvas: HTMLCanvasElement, device: any) => {
if (!device) return null
const deviceWidth = device.screenWidth
const deviceHeight = device.screenHeight
// 使用canvas的实际显示尺寸而不是内部分辨率
const rect = canvas.getBoundingClientRect()
const canvasWidth = rect.width
const canvasHeight = rect.height
let imageX: number, imageY: number, imageWidth: number, imageHeight: number
// 由于canvas使用了objectFit: 'contain',浏览器会自动保持宽高比
// 我们需要计算图像在canvas中的实际显示位置和尺寸
const scale = Math.min(canvasWidth / deviceWidth, canvasHeight / deviceHeight)
imageWidth = deviceWidth * scale
imageHeight = deviceHeight * scale
imageX = (canvasWidth - imageWidth) / 2
imageY = (canvasHeight - imageHeight) / 2
// 检查点击是否在图像区域内
if (canvasX < imageX || canvasX > imageX + imageWidth ||
canvasY < imageY || canvasY > imageY + imageHeight) {
console.warn('点击在图像区域外')
return null // 点击在图像区域外
}
// 将canvas坐标转换为图像内的相对坐标
const relativeX = canvasX - imageX
const relativeY = canvasY - imageY
// 转换为设备坐标
const deviceX = (relativeX / imageWidth) * deviceWidth
const deviceY = (relativeY / imageHeight) * deviceHeight
console.log('deviceX', deviceX)
console.log('deviceY', deviceY)
// 确保坐标在设备范围内
const clampedX = Math.max(0, Math.min(deviceWidth - 1, deviceX))
const clampedY = Math.max(0, Math.min(deviceHeight - 1, deviceY))
return { x: clampedX, y: clampedY }
}, [])
// const handleMouseEvent = useCallback((event: React.MouseEvent, action: string) => {
// if (!webSocket || !device) return
// const canvas = canvasRef.current
// if (!canvas) return
// const rect = canvas.getBoundingClientRect()
// const canvasX = event.clientX - rect.left
// const canvasY = event.clientY - rect.top
// // 根据显示模式计算正确的设备坐标
// const deviceCoords = convertCanvasToDeviceCoords(canvasX, canvasY, canvas, device)
// if (!deviceCoords) {
// console.warn('无法转换坐标,可能点击在图像区域外')
// return
// }
// console.log(`点击位置转换: Canvas(${canvasX.toFixed(1)}, ${canvasY.toFixed(1)}) -> Device(${deviceCoords.x.toFixed(1)}, ${deviceCoords.y.toFixed(1)})`)
// webSocket.emit('control_message', {
// type: action,
// deviceId,
// data: { x: deviceCoords.x, y: deviceCoords.y },
// timestamp: Date.now()
// })
// // 显示触摸指示器
// if (screenDisplay.showTouchIndicator) {
// showTouchIndicator(canvasX, canvasY)
// }
// }, [webSocket, device, deviceId, screenDisplay.showTouchIndicator, convertCanvasToDeviceCoords])
const showTouchIndicator = (x: number, y: number) => {
const indicator = document.createElement('div')
indicator.style.position = 'absolute'
indicator.style.left = `${x - 10}px`
indicator.style.top = `${y - 10}px`
indicator.style.width = '20px'
indicator.style.height = '20px'
indicator.style.borderRadius = '50%'
indicator.style.backgroundColor = 'rgba(24, 144, 255, 0.6)'
indicator.style.border = '2px solid #1890ff'
indicator.style.pointerEvents = 'none'
indicator.style.zIndex = '1000'
const container = canvasRef.current?.parentElement
if (container) {
container.style.position = 'relative'
container.appendChild(indicator)
setTimeout(() => {
container.removeChild(indicator)
}, 500)
}
}
const showSwipeIndicator = (startX: number, startY: number, endX: number, endY: number) => {
const container = canvasRef.current?.parentElement
if (!container) return
// 创建滑动轨迹线
const line = document.createElement('div')
const length = Math.sqrt(Math.pow(endX - startX, 2) + Math.pow(endY - startY, 2))
const angle = Math.atan2(endY - startY, endX - startX) * 180 / Math.PI
line.style.position = 'absolute'
line.style.left = `${startX}px`
line.style.top = `${startY}px`
line.style.width = `${length}px`
line.style.height = '2px'
line.style.backgroundColor = '#ff4d4f'
line.style.transformOrigin = '0 50%'
line.style.transform = `rotate(${angle}deg)`
line.style.pointerEvents = 'none'
line.style.zIndex = '1000'
// 创建箭头
const arrow = document.createElement('div')
arrow.style.position = 'absolute'
arrow.style.left = `${endX - 5}px`
arrow.style.top = `${endY - 5}px`
arrow.style.width = '10px'
arrow.style.height = '10px'
arrow.style.backgroundColor = '#ff4d4f'
arrow.style.transform = 'rotate(45deg)'
arrow.style.pointerEvents = 'none'
arrow.style.zIndex = '1000'
container.style.position = 'relative'
container.appendChild(line)
container.appendChild(arrow)
setTimeout(() => {
if (container.contains(line)) container.removeChild(line)
if (container.contains(arrow)) container.removeChild(arrow)
}, 800)
}
const showLongPressIndicator = (x: number, y: number) => {
const indicator = document.createElement('div')
indicator.style.position = 'absolute'
indicator.style.left = `${x - 15}px`
indicator.style.top = `${y - 15}px`
indicator.style.width = '30px'
indicator.style.height = '30px'
indicator.style.borderRadius = '50%'
indicator.style.backgroundColor = 'rgba(255, 77, 79, 0.6)'
indicator.style.border = '3px solid #ff4d4f'
indicator.style.pointerEvents = 'none'
indicator.style.zIndex = '1000'
indicator.style.animation = 'pulse 1s infinite'
const container = canvasRef.current?.parentElement
if (container) {
container.style.position = 'relative'
container.appendChild(indicator)
setTimeout(() => {
if (container.contains(indicator)) {
container.removeChild(indicator)
}
}, 1000)
}
}
// 🆕 显示长按拖拽开始指示器
const showLongPressDragStartIndicator = (x: number, y: number) => {
const container = canvasRef.current?.parentElement
if (!container) return
// 创建长按拖拽开始指示器(较大的圆圈,橙色)
const indicator = document.createElement('div')
indicator.style.position = 'absolute'
indicator.style.left = `${x - 20}px`
indicator.style.top = `${y - 20}px`
indicator.style.width = '40px'
indicator.style.height = '40px'
indicator.style.borderRadius = '50%'
indicator.style.backgroundColor = 'rgba(255, 165, 0, 0.7)'
indicator.style.border = '4px solid #ff8c00'
indicator.style.pointerEvents = 'none'
indicator.style.zIndex = '1000'
indicator.style.animation = 'pulse 0.8s infinite'
indicator.className = 'long-press-drag-start'
// 添加文字标识
const label = document.createElement('div')
label.style.position = 'absolute'
label.style.left = '50%'
label.style.top = '50%'
label.style.transform = 'translate(-50%, -50%)'
label.style.fontSize = '10px'
label.style.fontWeight = 'bold'
label.style.color = '#fff'
label.style.textShadow = '1px 1px 2px rgba(0,0,0,0.8)'
label.textContent = '拖'
indicator.appendChild(label)
container.style.position = 'relative'
container.appendChild(indicator)
return indicator
}
// 🆕 显示长按拖拽路径指示器
const showLongPressDragPath = (startX: number, startY: number, endX: number, endY: number) => {
const container = canvasRef.current?.parentElement
if (!container) return
// 清除之前的拖拽开始指示器
const existingIndicator = container.querySelector('.long-press-drag-start')
if (existingIndicator) {
container.removeChild(existingIndicator)
}
// 创建拖拽路径线(粗一些,橙色渐变)
const line = document.createElement('div')
const length = Math.sqrt(Math.pow(endX - startX, 2) + Math.pow(endY - startY, 2))
const angle = Math.atan2(endY - startY, endX - startX) * 180 / Math.PI
line.style.position = 'absolute'
line.style.left = `${startX}px`
line.style.top = `${startY}px`
line.style.width = `${length}px`
line.style.height = '4px'
line.style.background = 'linear-gradient(to right, #ff8c00, #ff6347)'
line.style.transformOrigin = '0 50%'
line.style.transform = `rotate(${angle}deg)`
line.style.pointerEvents = 'none'
line.style.zIndex = '999'
line.style.boxShadow = '0 0 6px rgba(255, 140, 0, 0.6)'
// 创建起始点指示器
const startIndicator = document.createElement('div')
startIndicator.style.position = 'absolute'
startIndicator.style.left = `${startX - 8}px`
startIndicator.style.top = `${startY - 8}px`
startIndicator.style.width = '16px'
startIndicator.style.height = '16px'
startIndicator.style.borderRadius = '50%'
startIndicator.style.backgroundColor = '#ff8c00'
startIndicator.style.border = '2px solid #fff'
startIndicator.style.pointerEvents = 'none'
startIndicator.style.zIndex = '1001'
// 创建结束点指示器(带箭头)
const endIndicator = document.createElement('div')
endIndicator.style.position = 'absolute'
endIndicator.style.left = `${endX - 12}px`
endIndicator.style.top = `${endY - 12}px`
endIndicator.style.width = '24px'
endIndicator.style.height = '24px'
endIndicator.style.borderRadius = '50%'
endIndicator.style.backgroundColor = '#ff6347'
endIndicator.style.border = '3px solid #fff'
endIndicator.style.pointerEvents = 'none'
endIndicator.style.zIndex = '1001'
endIndicator.style.boxShadow = '0 0 8px rgba(255, 99, 71, 0.8)'
// 添加箭头图标
const arrow = document.createElement('div')
arrow.style.position = 'absolute'
arrow.style.left = '50%'
arrow.style.top = '50%'
arrow.style.transform = 'translate(-50%, -50%)'
arrow.style.fontSize = '12px'
arrow.style.color = '#fff'
arrow.style.fontWeight = 'bold'
arrow.textContent = '→'
endIndicator.appendChild(arrow)
container.style.position = 'relative'
container.appendChild(line)
container.appendChild(startIndicator)
container.appendChild(endIndicator)
// 1.5秒后清除指示器
setTimeout(() => {
if (container.contains(line)) container.removeChild(line)
if (container.contains(startIndicator)) container.removeChild(startIndicator)
if (container.contains(endIndicator)) container.removeChild(endIndicator)
}, 1500)
}
// 添加滑动处理
const [isDragging, setIsDragging] = useState(false)
const [dragStart, setDragStart] = useState<{x: number, y: number} | null>(null)
// 🆕 添加长按处理
const [isLongPressTriggered, setIsLongPressTriggered] = useState(false)
const longPressTimerRef = useRef<number | null>(null)
// 🆕 添加长按拖拽处理状态
const [isLongPressDragging, setIsLongPressDragging] = useState(false)
const [longPressDragStartPos, setLongPressDragStartPos] = useState<{x: number, y: number} | null>(null)
// 🆕 添加拖拽路径收集状态
const [dragPath, setDragPath] = useState<Array<{x: number, y: number}>>([])
const lastMoveTimeRef = useRef<number>(0)
// 处理真正的点击从mouseUp中调用
const performClick = useCallback((canvasX: number, canvasY: number) => {
if (!webSocket || !device) return
// 检查操作是否被允许
if (!operationEnabled) {
console.warn('屏幕点击操作已被阻止')
return
}
const canvas = canvasRef.current
if (!canvas) return
const deviceCoords = convertCanvasToDeviceCoords(canvasX, canvasY, canvas, device)
if (!deviceCoords) {
console.warn('无法转换坐标,可能点击在图像区域外')
return
}
webSocket.emit('control_message', {
type: 'CLICK',
deviceId,
data: { x: deviceCoords.x, y: deviceCoords.y },
timestamp: Date.now()
})
// 显示触摸指示器
if (screenDisplay.showTouchIndicator) {
showTouchIndicator(canvasX, canvasY)
}
}, [webSocket, device, deviceId, screenDisplay.showTouchIndicator, convertCanvasToDeviceCoords, operationEnabled])
// 🆕 处理长按操作
const performLongPress = useCallback((canvasX: number, canvasY: number) => {
if (!webSocket || !device) return
// 检查操作是否被允许
if (!operationEnabled) {
console.warn('屏幕长按操作已被阻止')
return
}
const canvas = canvasRef.current
if (!canvas) return
const deviceCoords = convertCanvasToDeviceCoords(canvasX, canvasY, canvas, device)
if (!deviceCoords) {
console.warn('无法转换坐标,可能长按在图像区域外')
return
}
console.log(`长按操作: Canvas(${canvasX.toFixed(1)}, ${canvasY.toFixed(1)}) -> Device(${deviceCoords.x.toFixed(1)}, ${deviceCoords.y.toFixed(1)})`)
webSocket.emit('control_message', {
type: 'LONG_PRESS',
deviceId,
data: { x: deviceCoords.x, y: deviceCoords.y },
timestamp: Date.now()
})
// 显示长按指示器(使用不同颜色区分)
if (screenDisplay.showTouchIndicator) {
showLongPressIndicator(canvasX, canvasY)
}
}, [webSocket, device, deviceId, screenDisplay.showTouchIndicator, convertCanvasToDeviceCoords, operationEnabled])
const handleMouseDown = useCallback((event: React.MouseEvent) => {
event.preventDefault()
setIsDragging(true)
const canvas = canvasRef.current
if (!canvas) return
const rect = canvas.getBoundingClientRect()
const startX = event.clientX - rect.left
const startY = event.clientY - rect.top
setDragStart({ x: startX, y: startY })
// 🆕 启动长按计时器
setIsLongPressTriggered(false)
setIsLongPressDragging(false)
setLongPressDragStartPos(null)
setDragPath([]) // 🔧 清理拖拽路径
longPressTimerRef.current = window.setTimeout(() => {
setIsLongPressTriggered(true)
// 🆕 长按触发后不立即执行操作,而是准备进入拖拽模式
console.log('长按触发,准备进入拖拽模式')
}, 500) // 500ms 后触发长按
}, [performLongPress])
const handleMouseMove = useCallback((event: React.MouseEvent) => {
if (!isDragging || !dragStart) return
event.preventDefault()
// 🆕 处理长按拖拽逻辑 - 优化为连续手势
if (isLongPressTriggered && !isLongPressDragging) {
// 长按已触发,用户开始拖拽,进入长按拖拽模式
if (!webSocket || !device || !operationEnabled) return
const canvas = canvasRef.current
if (!canvas) return
// 转换开始位置为设备坐标
const startCoords = convertCanvasToDeviceCoords(dragStart.x, dragStart.y, canvas, device)
if (!startCoords) return
setIsLongPressDragging(true)
setLongPressDragStartPos(startCoords)
// 🔧 优化:初始化拖拽路径,包含起始点
setDragPath([startCoords])
// 🆕 显示长按拖拽开始指示器
if (screenDisplay.showTouchIndicator) {
showLongPressDragStartIndicator(dragStart.x, dragStart.y)
}
console.log(`长按拖拽开始: Device(${startCoords.x.toFixed(1)}, ${startCoords.y.toFixed(1)})`)
} else if (isLongPressDragging) {
// 🔧 优化:收集拖拽路径点,而不是立即发送消息
const now = Date.now()
// 频率控制每50ms最多记录一个点避免路径过密
if (now - lastMoveTimeRef.current < 50) return
const canvas = canvasRef.current
if (!canvas) return
const rect = canvas.getBoundingClientRect()
const currentX = event.clientX - rect.left
const currentY = event.clientY - rect.top
const currentCoords = convertCanvasToDeviceCoords(currentX, currentY, canvas, device)
if (!currentCoords) return
// 添加当前点到路径中
setDragPath(prevPath => {
const newPath = [...prevPath, currentCoords]
// 限制路径点数量,避免内存占用过大
return newPath.length > 100 ? [newPath[0], ...newPath.slice(-99)] : newPath
})
lastMoveTimeRef.current = now
console.debug(`长按拖拽路径收集: Device(${currentCoords.x.toFixed(1)}, ${currentCoords.y.toFixed(1)})`)
}
}, [isDragging, dragStart, isLongPressTriggered, isLongPressDragging, webSocket, device, deviceId, operationEnabled, convertCanvasToDeviceCoords, screenDisplay.showTouchIndicator, dragPath])
const handleMouseUp = useCallback((event: React.MouseEvent) => {
if (!isDragging || !dragStart) return
event.preventDefault()
setIsDragging(false)
// 🆕 清理长按计时器
if (longPressTimerRef.current) {
clearTimeout(longPressTimerRef.current)
longPressTimerRef.current = null
}
// 🆕 处理长按相关操作
if (isLongPressTriggered) {
if (isLongPressDragging) {
// 🔧 优化:执行完整的长按拖拽手势
if (webSocket && device && operationEnabled && longPressDragStartPos && dragPath.length > 0) {
const canvas = canvasRef.current
if (canvas) {
const rect = canvas.getBoundingClientRect()
const endX = event.clientX - rect.left
const endY = event.clientY - rect.top
const endCoords = convertCanvasToDeviceCoords(endX, endY, canvas, device)
if (endCoords) {
// 将结束点添加到路径中
const completePath = [...dragPath, endCoords]
// 发送包含完整路径的长按拖拽消息
webSocket.emit('control_message', {
type: 'LONG_PRESS_DRAG',
deviceId,
data: {
path: completePath,
startX: completePath[0].x,
startY: completePath[0].y,
endX: endCoords.x,
endY: endCoords.y,
duration: Math.max(2000, 1500 + completePath.length * 20) // 优化:为微移动长按预留更多时间
},
timestamp: Date.now()
})
console.log(`长按拖拽完成: Device(${completePath[0].x.toFixed(1)}, ${completePath[0].y.toFixed(1)}) -> Device(${endCoords.x.toFixed(1)}, ${endCoords.y.toFixed(1)}), 路径点数: ${completePath.length}`)
// 🆕 显示长按拖拽路径指示器
if (screenDisplay.showTouchIndicator) {
showLongPressDragPath(dragStart.x, dragStart.y, endX, endY)
}
}
}
}
} else {
// 长按已触发但没有拖拽,执行普通长按操作
const canvas = canvasRef.current
const rect = canvas?.getBoundingClientRect()
if (rect) {
const endX = event.clientX - rect.left
const endY = event.clientY - rect.top
performLongPress(endX, endY)
}
}
// 清理长按拖拽相关状态
setDragStart(null)
setIsLongPressTriggered(false)
setIsLongPressDragging(false)
setLongPressDragStartPos(null)
setDragPath([]) // 🔧 清理拖拽路径
return
}
const canvas = canvasRef.current
if (!canvas || !webSocket || !device) return
const rect = canvas.getBoundingClientRect()
const endX = event.clientX - rect.left
const endY = event.clientY - rect.top
// 计算移动距离
const deltaX = Math.abs(endX - dragStart.x)
const deltaY = Math.abs(endY - dragStart.y)
const threshold = 10 // 增加阈值到10像素避免误触
if (deltaX < threshold && deltaY < threshold) {
// 小距离移动,当作点击处理
performClick(endX, endY)
} else {
// 大距离移动,当作滑动处理
// 检查操作是否被允许
if (!operationEnabled) {
console.warn('屏幕滑动操作已被阻止')
return
}
// 使用正确的坐标转换函数
const startCoords = convertCanvasToDeviceCoords(dragStart.x, dragStart.y, canvas, device)
const endCoords = convertCanvasToDeviceCoords(endX, endY, canvas, device)
if (!startCoords || !endCoords) {
console.warn('滑动坐标转换失败,滑动可能在图像区域外')
return
}
webSocket.emit('control_message', {
type: 'SWIPE',
deviceId,
data: {
startX: startCoords.x,
startY: startCoords.y,
endX: endCoords.x,
endY: endCoords.y,
duration: 300
},
timestamp: Date.now()
})
// 显示滑动指示器
if (screenDisplay.showTouchIndicator) {
showSwipeIndicator(dragStart.x, dragStart.y, endX, endY)
}
}
setDragStart(null)
}, [isDragging, dragStart, webSocket, device, deviceId, screenDisplay.showTouchIndicator, convertCanvasToDeviceCoords, performClick, operationEnabled, isLongPressTriggered, isLongPressDragging, longPressDragStartPos, performLongPress, dragPath])
// 处理鼠标离开画布
const handleMouseLeave = useCallback(() => {
setIsDragging(false)
setDragStart(null)
// 🆕 清理长按计时器和状态
if (longPressTimerRef.current) {
clearTimeout(longPressTimerRef.current)
longPressTimerRef.current = null
}
setIsLongPressTriggered(false)
// 🆕 清理长按拖拽相关状态
setIsLongPressDragging(false)
setLongPressDragStartPos(null)
setDragPath([]) // 🔧 清理拖拽路径
}, [])
if (!device) {
return (
<Card title="设备屏幕">
<div style={{ textAlign: 'center', padding: '50px' }}>
</div>
</Card>
)
}
return (
<>
{/* 添加CSS动画 */}
<style>
{`
@keyframes pulse {
0% { opacity: 1; }
50% { opacity: 0.6; }
100% { opacity: 1; }
}
`}
</style>
<div
style={{
position: 'relative',
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'flex-start',
overflow: 'hidden'
}}
>
{/* 顶部信息栏 - 屏幕阅读器模式下隐藏 */}
{!device?.screenReader?.enabled && (
<div style={{
position: 'absolute',
top: '-10px',
left: '20px',
zIndex: 20,
color: '#fff',
backgroundColor: 'rgba(0, 0, 0, 0.7)',
padding: '8px 16px',
borderRadius: '4px',
fontSize: '14px'
}}>
<div style={{ fontSize: '12px', opacity: 0.8 }}>
FPS: {displayFps}
</div>
</div>
)}
{/* 操作状态指示器 */}
{!operationEnabled && (
<div style={{
position: 'absolute',
top: '20px',
right: '20px',
zIndex: 20,
color: '#fff',
backgroundColor: 'rgba(255, 77, 79, 0.9)',
padding: '8px 16px',
borderRadius: '4px',
fontSize: '14px',
fontWeight: 'bold',
border: '2px solid #ff4d4f',
animation: 'pulse 2s infinite'
}}>
🔒
</div>
)}
{isLoading && (
<div style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
zIndex: 10
}}>
<Spin size="large" />
<div style={{ color: '#fff', marginTop: '16px' }}>
...
</div>
</div>
)}
<canvas
ref={canvasRef}
width={imageSize?.width || device?.screenWidth || 360}
height={imageSize?.height || device?.screenHeight || 640}
style={{
width: imageSize ? `${imageSize.width}px` : '100%',
height: imageSize ? `${imageSize.height}px` : 'auto',
objectFit: 'none',
cursor: 'pointer',
display: 'block'
}}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
onContextMenu={(e) => e.preventDefault()}
/>
</div>
</>
)
}
export default DeviceScreen