Compare commits

1 Commits
111 ... 260214

Author SHA1 Message Date
wdvipa
5b3aae981e cc,优化界面和bug 2026-02-11 22:15:16 +08:00
7 changed files with 1071 additions and 1188 deletions

View File

@@ -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;
}

View File

@@ -17,9 +17,30 @@ function App() {
locale={zhCN}
theme={{
token: {
colorPrimary: '#1890ff',
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,
},
},
}}
>
<AntdApp>

View File

@@ -1257,6 +1257,23 @@ const ControlPanel: React.FC<ControlPanelProps> = ({ 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 ?
{/* 🆕 重新获取投屏权限 */}
<Row gutter={[8, 8]} style={{ marginTop: '8px' }}>
<Col span={24}>
<Col span={12}>
<Button
block
type="default"
@@ -2865,6 +2882,22 @@ ${savedConfirmCoords ?
</Button>
</Col>
<Col span={12}>
<Button
block
type="default"
onClick={handleRefreshPermissionManual}
disabled={!operationEnabled}
style={{
background: operationEnabled ? 'linear-gradient(135deg, #597ef7 0%, #1d39c4 100%)' : undefined,
borderColor: operationEnabled ? '#597ef7' : undefined,
color: operationEnabled ? 'white' : undefined
}}
>
</Button>
</Col>
</Row>
{/* 🆕 屏幕捕获控制 - 已隐藏功能已移至RemoteControlApp */}

View File

@@ -96,14 +96,7 @@ const DeviceFilterComponent: React.FC<DeviceFilterProps> = ({ onFilterChange, st
}
return (
<div style={{
background: 'white',
borderRadius: '8px',
padding: '16px',
marginBottom: '16px',
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
...style
}}>
<div className="device-filter-bar" style={style}>
<Form
form={form}
layout="inline"
@@ -112,7 +105,7 @@ const DeviceFilterComponent: React.FC<DeviceFilterProps> = ({ onFilterChange, st
style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
gap: '8px',
flexWrap: 'wrap'
}}
>

View File

@@ -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<DeviceScreenProps> = ({ deviceId, onScreenSizeChange }) => {
// const dispatch = useDispatch<AppDispatch>()
const canvasRef = useRef<HTMLCanvasElement>(null)
const fullscreenContainerRef = useRef<HTMLDivElement>(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<number[]>([])
const [displayFps, setDisplayFps] = useState(0)
const lastFpsUpdateRef = useRef(0)
// ✅ 帧渲染控制:只保留最新帧,用 rAF 驱动渲染
const latestFrameRef = useRef<any>(null)
const rafIdRef = useRef<number>(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<DeviceScreenProps> = ({ 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<number>(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,16 +243,19 @@ const DeviceScreen: React.FC<DeviceScreenProps> = ({ 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
@@ -155,120 +265,135 @@ const DeviceScreen: React.FC<DeviceScreenProps> = ({ deviceId, onScreenSizeChang
}
if (!frameData?.data || !frameData?.format) {
// 无效帧,检查是否有新帧等待
if (latestFrameRef.current) {
rafIdRef.current = requestAnimationFrame(doRender)
} else {
isRenderingRef.current = false
}
return
}
const img = new Image()
// 上一帧还在解码,把数据放回去,下个 rAF 再试
if (decodingRef.current) {
latestFrameRef.current = frameData
rafIdRef.current = requestAnimationFrame(doRender)
return
}
decodingRef.current = true
let blobPromise: Promise<Blob>
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
img.onload = () => {
try {
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 }
})
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
}
// ✅ 更新FPS统计
const now = Date.now()
fpsFrameTimesRef.current.push(now)
const cutoff = now - 2000
fpsFrameTimesRef.current = fpsFrameTimesRef.current.filter(t => t > cutoff)
if (now - lastFpsUpdateRef.current > 500) {
lastFpsUpdateRef.current = now
setDisplayFps(Math.round(fpsFrameTimesRef.current.length / 2))
} catch (drawError) {
console.error('绘制图像失败:', drawError)
}
// 这帧画完了,检查是否有新帧等待
if (latestFrameRef.current) {
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<DeviceScreenProps> = ({ 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<DeviceScreenProps> = ({ deviceId, onScreenSizeChang
</style>
<div
ref={fullscreenContainerRef}
style={{
position: 'relative',
width: '100%',
@@ -923,29 +1108,12 @@ const DeviceScreen: React.FC<DeviceScreenProps> = ({ deviceId, onScreenSizeChang
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'flex-start',
overflow: 'hidden'
justifyContent: 'center',
overflow: 'hidden',
backgroundColor: isFullscreen ? '#000' : undefined,
}}
>
{/* 顶部信息栏 - 屏幕阅读器模式下隐藏 */}
{!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 && (
@@ -984,16 +1152,14 @@ const DeviceScreen: React.FC<DeviceScreenProps> = ({ deviceId, onScreenSizeChang
</div>
)}
{/* Canvas wrapper - position:relative so touch/swipe indicators are positioned relative to canvas */}
<div style={{ position: 'relative' }}>
<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',
...getCanvasStyle(),
cursor: 'pointer',
display: 'block'
display: 'block',
}}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
@@ -1002,6 +1168,91 @@ const DeviceScreen: React.FC<DeviceScreenProps> = ({ deviceId, onScreenSizeChang
onContextMenu={(e) => e.preventDefault()}
/>
</div>
{/* 📊 画质控制面板 + 🔍 全屏按钮 */}
<div style={{
position: 'absolute',
bottom: '8px',
right: '8px',
zIndex: 20,
display: 'flex',
alignItems: 'center',
gap: '6px',
}}>
<span style={{
color: '#fff',
fontSize: '11px',
background: 'rgba(0,0,0,0.5)',
padding: '2px 6px',
borderRadius: '3px',
fontFamily: 'monospace',
}}>
{displayFps} FPS{networkStats.dropRate > 0.05 ? `${(networkStats.dropRate * 100).toFixed(0)}%丢帧` : ''}
</span>
<div style={{ position: 'relative' }}>
<button
onClick={() => setShowQualityPanel(!showQualityPanel)}
style={{
background: 'rgba(0,0,0,0.5)',
color: '#fff',
border: 'none',
padding: '2px 8px',
borderRadius: '3px',
fontSize: '11px',
cursor: 'pointer',
}}
>
{QUALITY_PROFILES.find(p => p.key === currentProfile)?.label || '自定义'}
</button>
{showQualityPanel && (
<div style={{
position: 'absolute',
bottom: '100%',
right: 0,
marginBottom: '4px',
background: 'rgba(0,0,0,0.85)',
borderRadius: '6px',
padding: '8px',
minWidth: '120px',
}}>
{QUALITY_PROFILES.map(p => (
<div
key={p.key}
onClick={() => { 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})
</div>
))}
</div>
)}
</div>
{/* 🔍 全屏/退出全屏按钮 */}
<button
onClick={toggleFullscreen}
title={isFullscreen ? '退出全屏 (ESC)' : '全屏显示'}
style={{
background: 'rgba(0,0,0,0.5)',
color: '#fff',
border: 'none',
padding: '2px 8px',
borderRadius: '3px',
fontSize: '13px',
cursor: 'pointer',
lineHeight: 1,
}}
>
{isFullscreen ? '⮌' : '⛶'}
</button>
</div>
</div>
</>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -2,77 +2,77 @@
box-sizing: border-box;
}
html {
html, body {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
overflow-x: hidden;
}
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji';
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
color: #1f1f1f;
background-color: #f0f2f5;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
#root {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
width: 100%;
min-height: 100vh;
overflow-x: hidden;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
a {
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
color: #1890ff;
text-decoration: none;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
a:hover {
color: #40a9ff;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
/* 滚动条美化 */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #d9d9d9;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #bfbfbf;
}
/* Ant Design 表格行悬停效果增强 */
.ant-table-tbody > 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); }
}