Compare commits
2 Commits
111
...
aaa6acfded
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aaa6acfded | ||
|
|
5b3aae981e |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1 +1 @@
|
|||||||
node_modules
|
/node_modules/
|
||||||
30
node_modules/.vite/deps/_metadata.json
generated
vendored
30
node_modules/.vite/deps/_metadata.json
generated
vendored
@@ -7,83 +7,83 @@
|
|||||||
"react": {
|
"react": {
|
||||||
"src": "../../react/index.js",
|
"src": "../../react/index.js",
|
||||||
"file": "react.js",
|
"file": "react.js",
|
||||||
"fileHash": "27c38da4",
|
"fileHash": "2ffb555a",
|
||||||
"needsInterop": true
|
"needsInterop": true
|
||||||
},
|
},
|
||||||
"react-dom": {
|
"react-dom": {
|
||||||
"src": "../../react-dom/index.js",
|
"src": "../../react-dom/index.js",
|
||||||
"file": "react-dom.js",
|
"file": "react-dom.js",
|
||||||
"fileHash": "6df64ba4",
|
"fileHash": "497e92e9",
|
||||||
"needsInterop": true
|
"needsInterop": true
|
||||||
},
|
},
|
||||||
"@reduxjs/toolkit": {
|
"@reduxjs/toolkit": {
|
||||||
"src": "../../@reduxjs/toolkit/dist/redux-toolkit.modern.mjs",
|
"src": "../../@reduxjs/toolkit/dist/redux-toolkit.modern.mjs",
|
||||||
"file": "@reduxjs_toolkit.js",
|
"file": "@reduxjs_toolkit.js",
|
||||||
"fileHash": "9e3eb961",
|
"fileHash": "326be4c8",
|
||||||
"needsInterop": false
|
"needsInterop": false
|
||||||
},
|
},
|
||||||
"react-redux": {
|
"react-redux": {
|
||||||
"src": "../../react-redux/dist/react-redux.mjs",
|
"src": "../../react-redux/dist/react-redux.mjs",
|
||||||
"file": "react-redux.js",
|
"file": "react-redux.js",
|
||||||
"fileHash": "612e123d",
|
"fileHash": "c9e96044",
|
||||||
"needsInterop": false
|
"needsInterop": false
|
||||||
},
|
},
|
||||||
"antd": {
|
"antd": {
|
||||||
"src": "../../antd/es/index.js",
|
"src": "../../antd/es/index.js",
|
||||||
"file": "antd.js",
|
"file": "antd.js",
|
||||||
"fileHash": "19f2bf41",
|
"fileHash": "fbadf007",
|
||||||
"needsInterop": false
|
"needsInterop": false
|
||||||
},
|
},
|
||||||
"socket.io-client": {
|
"socket.io-client": {
|
||||||
"src": "../../socket.io-client/build/esm/index.js",
|
"src": "../../socket.io-client/build/esm/index.js",
|
||||||
"file": "socket__io-client.js",
|
"file": "socket__io-client.js",
|
||||||
"fileHash": "6a923393",
|
"fileHash": "3a7d980f",
|
||||||
"needsInterop": false
|
"needsInterop": false
|
||||||
},
|
},
|
||||||
"react/jsx-dev-runtime": {
|
"react/jsx-dev-runtime": {
|
||||||
"src": "../../react/jsx-dev-runtime.js",
|
"src": "../../react/jsx-dev-runtime.js",
|
||||||
"file": "react_jsx-dev-runtime.js",
|
"file": "react_jsx-dev-runtime.js",
|
||||||
"fileHash": "561d21fd",
|
"fileHash": "771481b3",
|
||||||
"needsInterop": true
|
"needsInterop": true
|
||||||
},
|
},
|
||||||
"react/jsx-runtime": {
|
"react/jsx-runtime": {
|
||||||
"src": "../../react/jsx-runtime.js",
|
"src": "../../react/jsx-runtime.js",
|
||||||
"file": "react_jsx-runtime.js",
|
"file": "react_jsx-runtime.js",
|
||||||
"fileHash": "d004368c",
|
"fileHash": "fba82193",
|
||||||
"needsInterop": true
|
"needsInterop": true
|
||||||
},
|
},
|
||||||
"@ant-design/icons": {
|
"@ant-design/icons": {
|
||||||
"src": "../../@ant-design/icons/es/index.js",
|
"src": "../../@ant-design/icons/es/index.js",
|
||||||
"file": "@ant-design_icons.js",
|
"file": "@ant-design_icons.js",
|
||||||
"fileHash": "61c40504",
|
"fileHash": "6de612e4",
|
||||||
"needsInterop": false
|
"needsInterop": false
|
||||||
},
|
},
|
||||||
"antd/locale/zh_CN": {
|
"antd/locale/zh_CN": {
|
||||||
"src": "../../antd/locale/zh_CN.js",
|
"src": "../../antd/locale/zh_CN.js",
|
||||||
"file": "antd_locale_zh_CN.js",
|
"file": "antd_locale_zh_CN.js",
|
||||||
"fileHash": "3e119a22",
|
"fileHash": "335584cf",
|
||||||
"needsInterop": true
|
"needsInterop": true
|
||||||
},
|
},
|
||||||
"dayjs": {
|
"dayjs": {
|
||||||
"src": "../../dayjs/dayjs.min.js",
|
"src": "../../dayjs/dayjs.min.js",
|
||||||
"file": "dayjs.js",
|
"file": "dayjs.js",
|
||||||
"fileHash": "8b7ee1c2",
|
"fileHash": "7f4736e7",
|
||||||
"needsInterop": true
|
"needsInterop": true
|
||||||
},
|
},
|
||||||
"react-dom/client": {
|
"react-dom/client": {
|
||||||
"src": "../../react-dom/client.js",
|
"src": "../../react-dom/client.js",
|
||||||
"file": "react-dom_client.js",
|
"file": "react-dom_client.js",
|
||||||
"fileHash": "d4fda170",
|
"fileHash": "f5462f19",
|
||||||
"needsInterop": true
|
"needsInterop": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"chunks": {
|
"chunks": {
|
||||||
"chunk-624ZMHH6": {
|
|
||||||
"file": "chunk-624ZMHH6.js"
|
|
||||||
},
|
|
||||||
"chunk-5Q2RTODE": {
|
"chunk-5Q2RTODE": {
|
||||||
"file": "chunk-5Q2RTODE.js"
|
"file": "chunk-5Q2RTODE.js"
|
||||||
},
|
},
|
||||||
|
"chunk-624ZMHH6": {
|
||||||
|
"file": "chunk-624ZMHH6.js"
|
||||||
|
},
|
||||||
"chunk-MANNBT4V": {
|
"chunk-MANNBT4V": {
|
||||||
"file": "chunk-MANNBT4V.js"
|
"file": "chunk-MANNBT4V.js"
|
||||||
},
|
},
|
||||||
|
|||||||
6
node_modules/.vite/deps/antd.js
generated
vendored
6
node_modules/.vite/deps/antd.js
generated
vendored
@@ -1,7 +1,4 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import {
|
|
||||||
require_dayjs_min
|
|
||||||
} from "./chunk-624ZMHH6.js";
|
|
||||||
import {
|
import {
|
||||||
BarsOutlined_default,
|
BarsOutlined_default,
|
||||||
CalendarOutlined_default,
|
CalendarOutlined_default,
|
||||||
@@ -90,6 +87,9 @@ import {
|
|||||||
warning,
|
warning,
|
||||||
warning_default
|
warning_default
|
||||||
} from "./chunk-5Q2RTODE.js";
|
} from "./chunk-5Q2RTODE.js";
|
||||||
|
import {
|
||||||
|
require_dayjs_min
|
||||||
|
} from "./chunk-624ZMHH6.js";
|
||||||
import {
|
import {
|
||||||
require_react_dom
|
require_react_dom
|
||||||
} from "./chunk-MANNBT4V.js";
|
} from "./chunk-MANNBT4V.js";
|
||||||
|
|||||||
340
src/App.css
340
src/App.css
@@ -1,45 +1,335 @@
|
|||||||
#root {
|
#root {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
text-align: left;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 主布局自适应 */
|
||||||
|
.app-layout {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo {
|
/* Header 响应式 */
|
||||||
height: 6em;
|
.app-header {
|
||||||
padding: 1.5em;
|
padding: 0 24px;
|
||||||
will-change: filter;
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
transition: filter 300ms;
|
display: flex;
|
||||||
}
|
align-items: center;
|
||||||
.logo:hover {
|
justify-content: space-between;
|
||||||
filter: drop-shadow(0 0 2em #646cffaa);
|
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
||||||
}
|
flex-shrink: 0;
|
||||||
.logo.react:hover {
|
height: 56px;
|
||||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
line-height: 56px;
|
||||||
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes logo-spin {
|
@media (max-width: 768px) {
|
||||||
from {
|
.app-header {
|
||||||
transform: rotate(0deg);
|
padding: 0 12px;
|
||||||
}
|
height: 48px;
|
||||||
to {
|
line-height: 48px;
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: no-preference) {
|
/* 侧边栏 */
|
||||||
a:nth-of-type(2) .logo {
|
.app-sider {
|
||||||
animation: logo-spin infinite 20s linear;
|
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 {
|
.device-list-card {
|
||||||
padding: 2em;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
23
src/App.tsx
23
src/App.tsx
@@ -17,9 +17,30 @@ function App() {
|
|||||||
locale={zhCN}
|
locale={zhCN}
|
||||||
theme={{
|
theme={{
|
||||||
token: {
|
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,
|
borderRadius: 6,
|
||||||
},
|
},
|
||||||
|
Menu: {
|
||||||
|
itemBorderRadius: 8,
|
||||||
|
itemMarginInline: 8,
|
||||||
|
},
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<AntdApp>
|
<AntdApp>
|
||||||
|
|||||||
@@ -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)
|
// 🆕 暂停屏幕捕获 - 已隐藏(功能已移至RemoteControlApp)
|
||||||
// const handlePauseScreenCapture = () => {
|
// const handlePauseScreenCapture = () => {
|
||||||
// if (!webSocket) {
|
// if (!webSocket) {
|
||||||
@@ -2849,7 +2866,7 @@ ${savedConfirmCoords ?
|
|||||||
|
|
||||||
{/* 🆕 重新获取投屏权限 */}
|
{/* 🆕 重新获取投屏权限 */}
|
||||||
<Row gutter={[8, 8]} style={{ marginTop: '8px' }}>
|
<Row gutter={[8, 8]} style={{ marginTop: '8px' }}>
|
||||||
<Col span={24}>
|
<Col span={12}>
|
||||||
<Button
|
<Button
|
||||||
block
|
block
|
||||||
type="default"
|
type="default"
|
||||||
@@ -2865,6 +2882,22 @@ ${savedConfirmCoords ?
|
|||||||
重新获取投屏权限
|
重新获取投屏权限
|
||||||
</Button>
|
</Button>
|
||||||
</Col>
|
</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>
|
</Row>
|
||||||
|
|
||||||
{/* 🆕 屏幕捕获控制 - 已隐藏(功能已移至RemoteControlApp) */}
|
{/* 🆕 屏幕捕获控制 - 已隐藏(功能已移至RemoteControlApp) */}
|
||||||
|
|||||||
@@ -96,14 +96,7 @@ const DeviceFilterComponent: React.FC<DeviceFilterProps> = ({ onFilterChange, st
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div className="device-filter-bar" style={style}>
|
||||||
background: 'white',
|
|
||||||
borderRadius: '8px',
|
|
||||||
padding: '16px',
|
|
||||||
marginBottom: '16px',
|
|
||||||
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
|
|
||||||
...style
|
|
||||||
}}>
|
|
||||||
<Form
|
<Form
|
||||||
form={form}
|
form={form}
|
||||||
layout="inline"
|
layout="inline"
|
||||||
@@ -112,7 +105,7 @@ const DeviceFilterComponent: React.FC<DeviceFilterProps> = ({ onFilterChange, st
|
|||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '12px',
|
gap: '8px',
|
||||||
flexWrap: 'wrap'
|
flexWrap: 'wrap'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -3,6 +3,14 @@ import { Card, Spin } from 'antd'
|
|||||||
import { useSelector } from 'react-redux'
|
import { useSelector } from 'react-redux'
|
||||||
import type { RootState } from '../../store/store'
|
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 {
|
interface DeviceScreenProps {
|
||||||
deviceId: string
|
deviceId: string
|
||||||
onScreenSizeChange?: (size: { width: number, height: number }) => void
|
onScreenSizeChange?: (size: { width: number, height: number }) => void
|
||||||
@@ -14,17 +22,26 @@ interface DeviceScreenProps {
|
|||||||
const DeviceScreen: React.FC<DeviceScreenProps> = ({ deviceId, onScreenSizeChange }) => {
|
const DeviceScreen: React.FC<DeviceScreenProps> = ({ deviceId, onScreenSizeChange }) => {
|
||||||
// const dispatch = useDispatch<AppDispatch>()
|
// const dispatch = useDispatch<AppDispatch>()
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||||
|
const fullscreenContainerRef = useRef<HTMLDivElement>(null)
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const [imageSize, setImageSize] = useState<{ width: number, height: number } | null>(null)
|
const [imageSize, setImageSize] = useState<{ width: number, height: number } | null>(null)
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||||
|
|
||||||
// ✅ FPS 计算:使用滑动窗口统计真实帧率
|
// ✅ FPS 计算:使用滑动窗口统计真实帧率
|
||||||
const fpsFrameTimesRef = useRef<number[]>([])
|
const fpsFrameTimesRef = useRef<number[]>([])
|
||||||
const [displayFps, setDisplayFps] = useState(0)
|
const [displayFps, setDisplayFps] = useState(0)
|
||||||
|
const lastFpsUpdateRef = useRef(0)
|
||||||
|
|
||||||
// ✅ 帧渲染控制:只保留最新帧,用 rAF 驱动渲染
|
// ✅ 帧渲染控制:只保留最新帧,用 rAF 驱动渲染
|
||||||
const latestFrameRef = useRef<any>(null)
|
const latestFrameRef = useRef<any>(null)
|
||||||
const rafIdRef = useRef<number>(0)
|
const rafIdRef = useRef<number>(0)
|
||||||
const isRenderingRef = useRef(false)
|
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)
|
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 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,避免与控制权逻辑混合
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!webSocket) return
|
if (!webSocket) return
|
||||||
|
|
||||||
const handleScreenData = (data: any) => {
|
const handleScreenData = (data: any) => {
|
||||||
if (data.deviceId === deviceId) {
|
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
|
latestFrameRef.current = data
|
||||||
setIsLoading(false)
|
|
||||||
|
// 只在首帧时更新loading状态,避免每帧触发重渲染
|
||||||
|
if (isLoading) setIsLoading(false)
|
||||||
|
|
||||||
// ✅ 如果没有正在进行的渲染循环,启动一个
|
// ✅ 如果没有正在进行的渲染循环,启动一个
|
||||||
if (!isRenderingRef.current) {
|
if (!isRenderingRef.current) {
|
||||||
@@ -136,16 +243,19 @@ const DeviceScreen: React.FC<DeviceScreenProps> = ({ deviceId, onScreenSizeChang
|
|||||||
}
|
}
|
||||||
}, [webSocket, deviceId, isControlRequested, currentWebSocket]) // 🔧 不包含lastControlRequestTime避免重复执行
|
}, [webSocket, deviceId, isControlRequested, currentWebSocket]) // 🔧 不包含lastControlRequestTime避免重复执行
|
||||||
|
|
||||||
// ✅ rAF 驱动的渲染函数:取最新帧解码并绘制,一次只解码一帧
|
// ✅ 高性能渲染:createImageBitmap 离屏解码 + 持续 rAF 循环
|
||||||
|
const decodingRef = useRef(false)
|
||||||
|
|
||||||
const renderLatestFrame = useCallback(() => {
|
const renderLatestFrame = useCallback(() => {
|
||||||
const doRender = () => {
|
const doRender = () => {
|
||||||
const frameData = latestFrameRef.current
|
const frameData = latestFrameRef.current
|
||||||
if (!frameData) {
|
if (!frameData) {
|
||||||
|
// 没有新帧,停止循环,等 handleScreenData 重新启动
|
||||||
isRenderingRef.current = false
|
isRenderingRef.current = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 取走帧数据,清空引用
|
// 取走帧数据
|
||||||
latestFrameRef.current = null
|
latestFrameRef.current = null
|
||||||
|
|
||||||
const canvas = canvasRef.current
|
const canvas = canvasRef.current
|
||||||
@@ -155,120 +265,135 @@ const DeviceScreen: React.FC<DeviceScreenProps> = ({ deviceId, onScreenSizeChang
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!frameData?.data || !frameData?.format) {
|
if (!frameData?.data || !frameData?.format) {
|
||||||
// 无效帧,检查是否有新帧等待
|
|
||||||
if (latestFrameRef.current) {
|
|
||||||
rafIdRef.current = requestAnimationFrame(doRender)
|
rafIdRef.current = requestAnimationFrame(doRender)
|
||||||
} else {
|
|
||||||
isRenderingRef.current = false
|
|
||||||
}
|
|
||||||
return
|
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')
|
const ctx = canvas.getContext('2d')
|
||||||
if (!ctx) {
|
if (!ctx) {
|
||||||
if (latestFrameRef.current) rafIdRef.current = requestAnimationFrame(doRender)
|
bitmap.close()
|
||||||
else isRenderingRef.current = false
|
rafIdRef.current = requestAnimationFrame(doRender)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ 只在尺寸变化时才重设 canvas 尺寸(避免清空画布导致黑屏)
|
// 只在canvas内部分辨率需要增大时才更新,避免偶发小帧清空画布导致闪烁
|
||||||
if (canvas.width !== img.width || canvas.height !== img.height) {
|
if (canvas.width < bitmap.width || canvas.height < bitmap.height) {
|
||||||
canvas.width = img.width
|
canvas.width = Math.max(canvas.width, bitmap.width)
|
||||||
canvas.height = img.height
|
canvas.height = Math.max(canvas.height, bitmap.height)
|
||||||
}
|
}
|
||||||
|
// 每帧绘制前清除(canvas可能比bitmap大)
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||||
|
|
||||||
// 直接绘制,覆盖上一帧
|
|
||||||
switch (screenDisplay.fitMode) {
|
switch (screenDisplay.fitMode) {
|
||||||
case 'fit':
|
case 'fit':
|
||||||
drawFitMode(ctx, img, canvas)
|
drawFitMode(ctx, bitmap, canvas)
|
||||||
break
|
break
|
||||||
case 'fill':
|
case 'fill':
|
||||||
drawFillMode(ctx, img, canvas)
|
drawFillMode(ctx, bitmap, canvas)
|
||||||
break
|
break
|
||||||
case 'stretch':
|
case 'stretch':
|
||||||
drawStretchMode(ctx, img, canvas)
|
drawStretchMode(ctx, bitmap, canvas)
|
||||||
break
|
break
|
||||||
case 'original':
|
case 'original':
|
||||||
drawOriginalMode(ctx, img, canvas)
|
drawOriginalMode(ctx, bitmap, canvas)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新图片尺寸
|
const bw = bitmap.width
|
||||||
setImageSize(prev => {
|
const bh = bitmap.height
|
||||||
if (prev && prev.width === img.width && prev.height === img.height) return prev
|
const prevSize = imageSizeRef.current
|
||||||
if (onScreenSizeChange) onScreenSizeChange({ width: img.width, height: img.height })
|
if (!prevSize || prevSize.width !== bw || prevSize.height !== bh) {
|
||||||
return { width: img.width, height: img.height }
|
// 尺寸稳定性检查:只有连续多帧相同尺寸才更新,防止偶发异常帧闪烁
|
||||||
})
|
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()
|
const now = Date.now()
|
||||||
fpsFrameTimesRef.current.push(now)
|
fpsFrameTimesRef.current.push(now)
|
||||||
const cutoff = now - 2000
|
const cutoff = now - 2000
|
||||||
fpsFrameTimesRef.current = fpsFrameTimesRef.current.filter(t => t > cutoff)
|
fpsFrameTimesRef.current = fpsFrameTimesRef.current.filter(t => t > cutoff)
|
||||||
|
if (now - lastFpsUpdateRef.current > 500) {
|
||||||
|
lastFpsUpdateRef.current = now
|
||||||
setDisplayFps(Math.round(fpsFrameTimesRef.current.length / 2))
|
setDisplayFps(Math.round(fpsFrameTimesRef.current.length / 2))
|
||||||
|
|
||||||
} catch (drawError) {
|
|
||||||
console.error('绘制图像失败:', drawError)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 这帧画完了,检查是否有新帧等待
|
bitmap.close()
|
||||||
if (latestFrameRef.current) {
|
|
||||||
|
// 解码完成后始终调度下一帧,保持循环活跃
|
||||||
rafIdRef.current = requestAnimationFrame(doRender)
|
rafIdRef.current = requestAnimationFrame(doRender)
|
||||||
} else {
|
})
|
||||||
isRenderingRef.current = false
|
.catch(err => {
|
||||||
}
|
decodingRef.current = false
|
||||||
}
|
console.error('图像解码失败:', err)
|
||||||
|
|
||||||
img.onerror = () => {
|
|
||||||
console.error('图像加载失败')
|
|
||||||
if (latestFrameRef.current) {
|
|
||||||
rafIdRef.current = requestAnimationFrame(doRender)
|
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()
|
doRender()
|
||||||
}, [screenDisplay.fitMode])
|
}, [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 scale = Math.min(canvas.width / img.width, canvas.height / img.height)
|
||||||
const x = (canvas.width - img.width * scale) / 2
|
const x = (canvas.width - img.width * scale) / 2
|
||||||
const y = (canvas.height - img.height * scale) / 2
|
const y = (canvas.height - img.height * scale) / 2
|
||||||
ctx.drawImage(img, x, y, img.width * scale, img.height * scale)
|
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 scale = Math.max(canvas.width / img.width, canvas.height / img.height)
|
||||||
const x = (canvas.width - img.width * scale) / 2
|
const x = (canvas.width - img.width * scale) / 2
|
||||||
const y = (canvas.height - img.height * scale) / 2
|
const y = (canvas.height - img.height * scale) / 2
|
||||||
ctx.drawImage(img, x, y, img.width * scale, img.height * scale)
|
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)
|
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 x = (canvas.width - img.width) / 2
|
||||||
const y = (canvas.height - img.height) / 2
|
const y = (canvas.height - img.height) / 2
|
||||||
ctx.drawImage(img, x, y)
|
ctx.drawImage(img, x, y)
|
||||||
@@ -890,6 +1015,65 @@ const DeviceScreen: React.FC<DeviceScreenProps> = ({ deviceId, onScreenSizeChang
|
|||||||
setDragPath([]) // 🔧 清理拖拽路径
|
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) {
|
if (!device) {
|
||||||
@@ -916,6 +1100,7 @@ const DeviceScreen: React.FC<DeviceScreenProps> = ({ deviceId, onScreenSizeChang
|
|||||||
</style>
|
</style>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
ref={fullscreenContainerRef}
|
||||||
style={{
|
style={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
@@ -923,29 +1108,12 @@ const DeviceScreen: React.FC<DeviceScreenProps> = ({ deviceId, onScreenSizeChang
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'flex-start',
|
justifyContent: 'center',
|
||||||
overflow: 'hidden'
|
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 && (
|
{!operationEnabled && (
|
||||||
@@ -984,16 +1152,14 @@ const DeviceScreen: React.FC<DeviceScreenProps> = ({ deviceId, onScreenSizeChang
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Canvas wrapper - position:relative so touch/swipe indicators are positioned relative to canvas */}
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
<canvas
|
<canvas
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
width={imageSize?.width || device?.screenWidth || 360}
|
|
||||||
height={imageSize?.height || device?.screenHeight || 640}
|
|
||||||
style={{
|
style={{
|
||||||
width: imageSize ? `${imageSize.width}px` : '100%',
|
...getCanvasStyle(),
|
||||||
height: imageSize ? `${imageSize.height}px` : 'auto',
|
|
||||||
objectFit: 'none',
|
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
display: 'block'
|
display: 'block',
|
||||||
}}
|
}}
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
onMouseUp={handleMouseUp}
|
onMouseUp={handleMouseUp}
|
||||||
@@ -1002,6 +1168,91 @@ const DeviceScreen: React.FC<DeviceScreenProps> = ({ deviceId, onScreenSizeChang
|
|||||||
onContextMenu={(e) => e.preventDefault()}
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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
102
src/index.css
102
src/index.css
@@ -2,77 +2,77 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
html {
|
html, body {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
: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;
|
line-height: 1.5;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
|
color: #1f1f1f;
|
||||||
color-scheme: light dark;
|
background-color: #f0f2f5;
|
||||||
color: rgba(255, 255, 255, 0.87);
|
|
||||||
background-color: #242424;
|
|
||||||
|
|
||||||
font-synthesis: none;
|
font-synthesis: none;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
#root {
|
||||||
font-weight: 500;
|
width: 100%;
|
||||||
color: #646cff;
|
height: 100%;
|
||||||
text-decoration: inherit;
|
|
||||||
}
|
|
||||||
a:hover {
|
|
||||||
color: #535bf2;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
width: 100%;
|
|
||||||
min-height: 100vh;
|
|
||||||
overflow-x: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
a {
|
||||||
font-size: 3.2em;
|
|
||||||
line-height: 1.1;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
padding: 0.6em 1.2em;
|
|
||||||
font-size: 1em;
|
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-family: inherit;
|
color: #1890ff;
|
||||||
background-color: #1a1a1a;
|
text-decoration: none;
|
||||||
cursor: pointer;
|
|
||||||
transition: border-color 0.25s;
|
|
||||||
}
|
}
|
||||||
button:hover {
|
a:hover {
|
||||||
border-color: #646cff;
|
color: #40a9ff;
|
||||||
}
|
|
||||||
button:focus,
|
|
||||||
button:focus-visible {
|
|
||||||
outline: 4px auto -webkit-focus-ring-color;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: light) {
|
/* 滚动条美化 */
|
||||||
:root {
|
::-webkit-scrollbar {
|
||||||
color: #213547;
|
width: 6px;
|
||||||
background-color: #ffffff;
|
height: 6px;
|
||||||
}
|
}
|
||||||
a:hover {
|
::-webkit-scrollbar-track {
|
||||||
color: #747bff;
|
background: transparent;
|
||||||
}
|
}
|
||||||
button {
|
::-webkit-scrollbar-thumb {
|
||||||
background-color: #f9f9f9;
|
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); }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user