111
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
node_modules
|
||||||
1
dist/assets/css/index-DbTgNF2L.css
vendored
Normal file
1
dist/assets/css/index-DbTgNF2L.css
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
*{box-sizing:border-box}html{width:100%;margin:0;padding:0}:root{font-family:system-ui,Avenir,Helvetica,Arial,sans-serif;line-height:1.5;font-weight:400;color-scheme:light dark;color:#ffffffde;background-color:#242424;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{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:.6em 1.2em;font-size:1em;font-weight:500;font-family:inherit;background-color:#1a1a1a;cursor:pointer;transition:border-color .25s}button:hover{border-color:#646cff}button:focus,button:focus-visible{outline:4px auto -webkit-focus-ring-color}@media (prefers-color-scheme: light){:root{color:#213547;background-color:#fff}a:hover{color:#747bff}button{background-color:#f9f9f9}}#root{width:100%;margin:0;padding:0;text-align:left;min-height:100vh;display:flex;flex-direction:column}.logo{height:6em;padding:1.5em;will-change:filter;transition:filter .3s}.logo:hover{filter:drop-shadow(0 0 2em #646cffaa)}.logo.react:hover{filter:drop-shadow(0 0 2em #61dafbaa)}@keyframes logo-spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}@media (prefers-reduced-motion: no-preference){a:nth-of-type(2) .logo{animation:logo-spin infinite 20s linear}}.card{padding:2em}.read-the-docs{color:#888}
|
||||||
10
dist/assets/js/index-D5tsXEew.js
vendored
Normal file
10
dist/assets/js/index-D5tsXEew.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dist/assets/js/react-vendor-HnKmhvXM.js
vendored
Normal file
1
dist/assets/js/react-vendor-HnKmhvXM.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dist/assets/js/redux-vendor-4i-xJSJa.js
vendored
Normal file
1
dist/assets/js/redux-vendor-4i-xJSJa.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dist/assets/js/socket-vendor-CUkmNz_4.js
vendored
Normal file
1
dist/assets/js/socket-vendor-CUkmNz_4.js
vendored
Normal file
File diff suppressed because one or more lines are too long
8
dist/assets/js/ui-vendor-vk2IPYHC.js
vendored
Normal file
8
dist/assets/js/ui-vendor-vk2IPYHC.js
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
dist/dist.zip
vendored
Normal file
BIN
dist/dist.zip
vendored
Normal file
Binary file not shown.
BIN
dist/favicon.ico
vendored
Normal file
BIN
dist/favicon.ico
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
18
dist/index.html
vendored
Normal file
18
dist/index.html
vendored
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<!-- <link rel="icon" type="image/svg+xml" href="/vite.svg" /> -->
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Hi远程控制 - Web客户端</title>
|
||||||
|
<script type="module" crossorigin src="/assets/js/index-D5tsXEew.js"></script>
|
||||||
|
<link rel="modulepreload" crossorigin href="/assets/js/react-vendor-HnKmhvXM.js">
|
||||||
|
<link rel="modulepreload" crossorigin href="/assets/js/redux-vendor-4i-xJSJa.js">
|
||||||
|
<link rel="modulepreload" crossorigin href="/assets/js/ui-vendor-vk2IPYHC.js">
|
||||||
|
<link rel="modulepreload" crossorigin href="/assets/js/socket-vendor-CUkmNz_4.js">
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/css/index-DbTgNF2L.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -2,9 +2,9 @@
|
|||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<!-- <link rel="icon" type="image/svg+xml" href="/vite.svg" /> -->
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Android远程控制 - Web客户端</title>
|
<title>Hi远程控制 - Web客户端</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
766
node_modules/.package-lock.json
generated
vendored
766
node_modules/.package-lock.json
generated
vendored
@@ -404,6 +404,456 @@
|
|||||||
"integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==",
|
"integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
|
"version": "0.25.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz",
|
||||||
|
"integrity": "sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"ideallyInert": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"aix"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm": {
|
||||||
|
"version": "0.25.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.9.tgz",
|
||||||
|
"integrity": "sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"ideallyInert": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm64": {
|
||||||
|
"version": "0.25.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.9.tgz",
|
||||||
|
"integrity": "sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"ideallyInert": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-x64": {
|
||||||
|
"version": "0.25.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.9.tgz",
|
||||||
|
"integrity": "sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"ideallyInert": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-arm64": {
|
||||||
|
"version": "0.25.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.9.tgz",
|
||||||
|
"integrity": "sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"ideallyInert": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-x64": {
|
||||||
|
"version": "0.25.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.9.tgz",
|
||||||
|
"integrity": "sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"ideallyInert": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-arm64": {
|
||||||
|
"version": "0.25.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.9.tgz",
|
||||||
|
"integrity": "sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"ideallyInert": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-x64": {
|
||||||
|
"version": "0.25.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.9.tgz",
|
||||||
|
"integrity": "sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"ideallyInert": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm": {
|
||||||
|
"version": "0.25.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.9.tgz",
|
||||||
|
"integrity": "sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"ideallyInert": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm64": {
|
||||||
|
"version": "0.25.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.9.tgz",
|
||||||
|
"integrity": "sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"ideallyInert": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ia32": {
|
||||||
|
"version": "0.25.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.9.tgz",
|
||||||
|
"integrity": "sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"ideallyInert": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-loong64": {
|
||||||
|
"version": "0.25.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.9.tgz",
|
||||||
|
"integrity": "sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"ideallyInert": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-mips64el": {
|
||||||
|
"version": "0.25.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.9.tgz",
|
||||||
|
"integrity": "sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==",
|
||||||
|
"cpu": [
|
||||||
|
"mips64el"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"ideallyInert": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ppc64": {
|
||||||
|
"version": "0.25.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.9.tgz",
|
||||||
|
"integrity": "sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"ideallyInert": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-riscv64": {
|
||||||
|
"version": "0.25.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.9.tgz",
|
||||||
|
"integrity": "sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"ideallyInert": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-s390x": {
|
||||||
|
"version": "0.25.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.9.tgz",
|
||||||
|
"integrity": "sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"ideallyInert": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-x64": {
|
||||||
|
"version": "0.25.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.9.tgz",
|
||||||
|
"integrity": "sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"ideallyInert": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-arm64": {
|
||||||
|
"version": "0.25.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.9.tgz",
|
||||||
|
"integrity": "sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"ideallyInert": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-x64": {
|
||||||
|
"version": "0.25.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.9.tgz",
|
||||||
|
"integrity": "sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"ideallyInert": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-arm64": {
|
||||||
|
"version": "0.25.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.9.tgz",
|
||||||
|
"integrity": "sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"ideallyInert": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-x64": {
|
||||||
|
"version": "0.25.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.9.tgz",
|
||||||
|
"integrity": "sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"ideallyInert": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openharmony-arm64": {
|
||||||
|
"version": "0.25.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.9.tgz",
|
||||||
|
"integrity": "sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"ideallyInert": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openharmony"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/sunos-x64": {
|
||||||
|
"version": "0.25.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.9.tgz",
|
||||||
|
"integrity": "sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"ideallyInert": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"sunos"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-arm64": {
|
||||||
|
"version": "0.25.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.9.tgz",
|
||||||
|
"integrity": "sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"ideallyInert": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-ia32": {
|
||||||
|
"version": "0.25.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.9.tgz",
|
||||||
|
"integrity": "sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"ideallyInert": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@esbuild/win32-x64": {
|
"node_modules/@esbuild/win32-x64": {
|
||||||
"version": "0.25.9",
|
"version": "0.25.9",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.9.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.9.tgz",
|
||||||
@@ -908,6 +1358,306 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||||
|
"version": "4.50.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.50.1.tgz",
|
||||||
|
"integrity": "sha512-HJXwzoZN4eYTdD8bVV22DN8gsPCAj3V20NHKOs8ezfXanGpmVPR7kalUHd+Y31IJp9stdB87VKPFbsGY3H/2ag==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"ideallyInert": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-android-arm64": {
|
||||||
|
"version": "4.50.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.50.1.tgz",
|
||||||
|
"integrity": "sha512-PZlsJVcjHfcH53mOImyt3bc97Ep3FJDXRpk9sMdGX0qgLmY0EIWxCag6EigerGhLVuL8lDVYNnSo8qnTElO4xw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"ideallyInert": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||||
|
"version": "4.50.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.50.1.tgz",
|
||||||
|
"integrity": "sha512-xc6i2AuWh++oGi4ylOFPmzJOEeAa2lJeGUGb4MudOtgfyyjr4UPNK+eEWTPLvmPJIY/pgw6ssFIox23SyrkkJw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"ideallyInert": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-darwin-x64": {
|
||||||
|
"version": "4.50.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.50.1.tgz",
|
||||||
|
"integrity": "sha512-2ofU89lEpDYhdLAbRdeyz/kX3Y2lpYc6ShRnDjY35bZhd2ipuDMDi6ZTQ9NIag94K28nFMofdnKeHR7BT0CATw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"ideallyInert": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||||
|
"version": "4.50.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.50.1.tgz",
|
||||||
|
"integrity": "sha512-wOsE6H2u6PxsHY/BeFHA4VGQN3KUJFZp7QJBmDYI983fgxq5Th8FDkVuERb2l9vDMs1D5XhOrhBrnqcEY6l8ZA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"ideallyInert": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||||
|
"version": "4.50.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.50.1.tgz",
|
||||||
|
"integrity": "sha512-A/xeqaHTlKbQggxCqispFAcNjycpUEHP52mwMQZUNqDUJFFYtPHCXS1VAG29uMlDzIVr+i00tSFWFLivMcoIBQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"ideallyInert": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||||
|
"version": "4.50.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.50.1.tgz",
|
||||||
|
"integrity": "sha512-54v4okehwl5TaSIkpp97rAHGp7t3ghinRd/vyC1iXqXMfjYUTm7TfYmCzXDoHUPTTf36L8pr0E7YsD3CfB3ZDg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"ideallyInert": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||||
|
"version": "4.50.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.50.1.tgz",
|
||||||
|
"integrity": "sha512-p/LaFyajPN/0PUHjv8TNyxLiA7RwmDoVY3flXHPSzqrGcIp/c2FjwPPP5++u87DGHtw+5kSH5bCJz0mvXngYxw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"ideallyInert": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||||
|
"version": "4.50.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.50.1.tgz",
|
||||||
|
"integrity": "sha512-2AbMhFFkTo6Ptna1zO7kAXXDLi7H9fGTbVaIq2AAYO7yzcAsuTNWPHhb2aTA6GPiP+JXh85Y8CiS54iZoj4opw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"ideallyInert": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||||
|
"version": "4.50.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.50.1.tgz",
|
||||||
|
"integrity": "sha512-Cgef+5aZwuvesQNw9eX7g19FfKX5/pQRIyhoXLCiBOrWopjo7ycfB292TX9MDcDijiuIJlx1IzJz3IoCPfqs9w==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"ideallyInert": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-linux-loongarch64-gnu": {
|
||||||
|
"version": "4.50.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.50.1.tgz",
|
||||||
|
"integrity": "sha512-RPhTwWMzpYYrHrJAS7CmpdtHNKtt2Ueo+BlLBjfZEhYBhK00OsEqM08/7f+eohiF6poe0YRDDd8nAvwtE/Y62Q==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"ideallyInert": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||||
|
"version": "4.50.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.50.1.tgz",
|
||||||
|
"integrity": "sha512-eSGMVQw9iekut62O7eBdbiccRguuDgiPMsw++BVUg+1K7WjZXHOg/YOT9SWMzPZA+w98G+Fa1VqJgHZOHHnY0Q==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"ideallyInert": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||||
|
"version": "4.50.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.50.1.tgz",
|
||||||
|
"integrity": "sha512-S208ojx8a4ciIPrLgazF6AgdcNJzQE4+S9rsmOmDJkusvctii+ZvEuIC4v/xFqzbuP8yDjn73oBlNDgF6YGSXQ==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"ideallyInert": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||||
|
"version": "4.50.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.50.1.tgz",
|
||||||
|
"integrity": "sha512-3Ag8Ls1ggqkGUvSZWYcdgFwriy2lWo+0QlYgEFra/5JGtAd6C5Hw59oojx1DeqcA2Wds2ayRgvJ4qxVTzCHgzg==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"ideallyInert": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||||
|
"version": "4.50.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.50.1.tgz",
|
||||||
|
"integrity": "sha512-t9YrKfaxCYe7l7ldFERE1BRg/4TATxIg+YieHQ966jwvo7ddHJxPj9cNFWLAzhkVsbBvNA4qTbPVNsZKBO4NSg==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"ideallyInert": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||||
|
"version": "4.50.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.50.1.tgz",
|
||||||
|
"integrity": "sha512-MCgtFB2+SVNuQmmjHf+wfI4CMxy3Tk8XjA5Z//A0AKD7QXUYFMQcns91K6dEHBvZPCnhJSyDWLApk40Iq/H3tA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"ideallyInert": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||||
|
"version": "4.50.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.50.1.tgz",
|
||||||
|
"integrity": "sha512-nEvqG+0jeRmqaUMuwzlfMKwcIVffy/9KGbAGyoa26iu6eSngAYQ512bMXuqqPrlTyfqdlB9FVINs93j534UJrg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"ideallyInert": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||||
|
"version": "4.50.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.50.1.tgz",
|
||||||
|
"integrity": "sha512-RDsLm+phmT3MJd9SNxA9MNuEAO/J2fhW8GXk62G/B4G7sLVumNFbRwDL6v5NrESb48k+QMqdGbHgEtfU0LCpbA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"ideallyInert": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openharmony"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||||
|
"version": "4.50.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.50.1.tgz",
|
||||||
|
"integrity": "sha512-hpZB/TImk2FlAFAIsoElM3tLzq57uxnGYwplg6WDyAxbYczSi8O2eQ+H2Lx74504rwKtZ3N2g4bCUkiamzS6TQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"ideallyInert": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||||
|
"version": "4.50.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.50.1.tgz",
|
||||||
|
"integrity": "sha512-SXjv8JlbzKM0fTJidX4eVsH+Wmnp0/WcD8gJxIZyR6Gay5Qcsmdbi9zVtnbkGPG8v2vMR1AD06lGWy5FLMcG7A==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"ideallyInert": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||||
"version": "4.50.1",
|
"version": "4.50.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.50.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.50.1.tgz",
|
||||||
@@ -2092,6 +2842,22 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/fsevents": {
|
||||||
|
"version": "2.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
|
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"ideallyInert": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/gensync": {
|
"node_modules/gensync": {
|
||||||
"version": "1.0.0-beta.2",
|
"version": "1.0.0-beta.2",
|
||||||
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
||||||
|
|||||||
39
node_modules/.vite/deps/_metadata.json
generated
vendored
39
node_modules/.vite/deps/_metadata.json
generated
vendored
@@ -1,77 +1,86 @@
|
|||||||
{
|
{
|
||||||
"hash": "9654558f",
|
"hash": "3570225d",
|
||||||
"configHash": "61db09f5",
|
"configHash": "632304f4",
|
||||||
"lockfileHash": "8df68275",
|
"lockfileHash": "80049c3e",
|
||||||
"browserHash": "de883d5f",
|
"browserHash": "517aea31",
|
||||||
"optimized": {
|
"optimized": {
|
||||||
"react": {
|
"react": {
|
||||||
"src": "../../react/index.js",
|
"src": "../../react/index.js",
|
||||||
"file": "react.js",
|
"file": "react.js",
|
||||||
"fileHash": "89247346",
|
"fileHash": "27c38da4",
|
||||||
"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": "0fcfb03c",
|
"fileHash": "6df64ba4",
|
||||||
"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": "203e5993",
|
"fileHash": "9e3eb961",
|
||||||
"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": "89b531fc",
|
"fileHash": "612e123d",
|
||||||
"needsInterop": false
|
"needsInterop": false
|
||||||
},
|
},
|
||||||
"antd": {
|
"antd": {
|
||||||
"src": "../../antd/es/index.js",
|
"src": "../../antd/es/index.js",
|
||||||
"file": "antd.js",
|
"file": "antd.js",
|
||||||
"fileHash": "deb25580",
|
"fileHash": "19f2bf41",
|
||||||
"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": "249374af",
|
"fileHash": "6a923393",
|
||||||
"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": "abc93921",
|
"fileHash": "561d21fd",
|
||||||
"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": "e422ec20",
|
"fileHash": "d004368c",
|
||||||
"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": "5fb36740",
|
"fileHash": "61c40504",
|
||||||
"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": "a1164cf4",
|
"fileHash": "3e119a22",
|
||||||
|
"needsInterop": true
|
||||||
|
},
|
||||||
|
"dayjs": {
|
||||||
|
"src": "../../dayjs/dayjs.min.js",
|
||||||
|
"file": "dayjs.js",
|
||||||
|
"fileHash": "8b7ee1c2",
|
||||||
"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": "d230ee6e",
|
"fileHash": "d4fda170",
|
||||||
"needsInterop": true
|
"needsInterop": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"chunks": {
|
"chunks": {
|
||||||
|
"chunk-624ZMHH6": {
|
||||||
|
"file": "chunk-624ZMHH6.js"
|
||||||
|
},
|
||||||
"chunk-5Q2RTODE": {
|
"chunk-5Q2RTODE": {
|
||||||
"file": "chunk-5Q2RTODE.js"
|
"file": "chunk-5Q2RTODE.js"
|
||||||
},
|
},
|
||||||
|
|||||||
283
node_modules/.vite/deps/antd.js
generated
vendored
283
node_modules/.vite/deps/antd.js
generated
vendored
@@ -1,4 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import {
|
||||||
|
require_dayjs_min
|
||||||
|
} from "./chunk-624ZMHH6.js";
|
||||||
import {
|
import {
|
||||||
BarsOutlined_default,
|
BarsOutlined_default,
|
||||||
CalendarOutlined_default,
|
CalendarOutlined_default,
|
||||||
@@ -98,286 +101,6 @@ import {
|
|||||||
__toESM
|
__toESM
|
||||||
} from "./chunk-DC5AMYBS.js";
|
} from "./chunk-DC5AMYBS.js";
|
||||||
|
|
||||||
// node_modules/dayjs/dayjs.min.js
|
|
||||||
var require_dayjs_min = __commonJS({
|
|
||||||
"node_modules/dayjs/dayjs.min.js"(exports, module2) {
|
|
||||||
!(function(t2, e3) {
|
|
||||||
"object" == typeof exports && "undefined" != typeof module2 ? module2.exports = e3() : "function" == typeof define && define.amd ? define(e3) : (t2 = "undefined" != typeof globalThis ? globalThis : t2 || self).dayjs = e3();
|
|
||||||
})(exports, (function() {
|
|
||||||
"use strict";
|
|
||||||
var t2 = 1e3, e3 = 6e4, n2 = 36e5, r2 = "millisecond", i = "second", s = "minute", u = "hour", a = "day", o3 = "week", c = "month", f = "quarter", h = "year", d = "date", l2 = "Invalid Date", $ = /^(\d{4})[-/]?(\d{1,2})?[-/]?(\d{0,2})[Tt\s]*(\d{1,2})?:?(\d{1,2})?:?(\d{1,2})?[.:]?(\d+)?$/, y = /\[([^\]]+)]|Y{1,4}|M{1,4}|D{1,2}|d{1,4}|H{1,2}|h{1,2}|a|A|m{1,2}|s{1,2}|Z{1,2}|SSS/g, M = { name: "en", weekdays: "Sunday_Monday_Tuesday_Wednesday_Thursday_Friday_Saturday".split("_"), months: "January_February_March_April_May_June_July_August_September_October_November_December".split("_"), ordinal: function(t3) {
|
|
||||||
var e4 = ["th", "st", "nd", "rd"], n3 = t3 % 100;
|
|
||||||
return "[" + t3 + (e4[(n3 - 20) % 10] || e4[n3] || e4[0]) + "]";
|
|
||||||
} }, m = function(t3, e4, n3) {
|
|
||||||
var r3 = String(t3);
|
|
||||||
return !r3 || r3.length >= e4 ? t3 : "" + Array(e4 + 1 - r3.length).join(n3) + t3;
|
|
||||||
}, v = { s: m, z: function(t3) {
|
|
||||||
var e4 = -t3.utcOffset(), n3 = Math.abs(e4), r3 = Math.floor(n3 / 60), i2 = n3 % 60;
|
|
||||||
return (e4 <= 0 ? "+" : "-") + m(r3, 2, "0") + ":" + m(i2, 2, "0");
|
|
||||||
}, m: function t3(e4, n3) {
|
|
||||||
if (e4.date() < n3.date()) return -t3(n3, e4);
|
|
||||||
var r3 = 12 * (n3.year() - e4.year()) + (n3.month() - e4.month()), i2 = e4.clone().add(r3, c), s2 = n3 - i2 < 0, u2 = e4.clone().add(r3 + (s2 ? -1 : 1), c);
|
|
||||||
return +(-(r3 + (n3 - i2) / (s2 ? i2 - u2 : u2 - i2)) || 0);
|
|
||||||
}, a: function(t3) {
|
|
||||||
return t3 < 0 ? Math.ceil(t3) || 0 : Math.floor(t3);
|
|
||||||
}, p: function(t3) {
|
|
||||||
return { M: c, y: h, w: o3, d: a, D: d, h: u, m: s, s: i, ms: r2, Q: f }[t3] || String(t3 || "").toLowerCase().replace(/s$/, "");
|
|
||||||
}, u: function(t3) {
|
|
||||||
return void 0 === t3;
|
|
||||||
} }, g = "en", D = {};
|
|
||||||
D[g] = M;
|
|
||||||
var p = "$isDayjsObject", S = function(t3) {
|
|
||||||
return t3 instanceof _ || !(!t3 || !t3[p]);
|
|
||||||
}, w = function t3(e4, n3, r3) {
|
|
||||||
var i2;
|
|
||||||
if (!e4) return g;
|
|
||||||
if ("string" == typeof e4) {
|
|
||||||
var s2 = e4.toLowerCase();
|
|
||||||
D[s2] && (i2 = s2), n3 && (D[s2] = n3, i2 = s2);
|
|
||||||
var u2 = e4.split("-");
|
|
||||||
if (!i2 && u2.length > 1) return t3(u2[0]);
|
|
||||||
} else {
|
|
||||||
var a2 = e4.name;
|
|
||||||
D[a2] = e4, i2 = a2;
|
|
||||||
}
|
|
||||||
return !r3 && i2 && (g = i2), i2 || !r3 && g;
|
|
||||||
}, O = function(t3, e4) {
|
|
||||||
if (S(t3)) return t3.clone();
|
|
||||||
var n3 = "object" == typeof e4 ? e4 : {};
|
|
||||||
return n3.date = t3, n3.args = arguments, new _(n3);
|
|
||||||
}, b = v;
|
|
||||||
b.l = w, b.i = S, b.w = function(t3, e4) {
|
|
||||||
return O(t3, { locale: e4.$L, utc: e4.$u, x: e4.$x, $offset: e4.$offset });
|
|
||||||
};
|
|
||||||
var _ = (function() {
|
|
||||||
function M2(t3) {
|
|
||||||
this.$L = w(t3.locale, null, true), this.parse(t3), this.$x = this.$x || t3.x || {}, this[p] = true;
|
|
||||||
}
|
|
||||||
var m2 = M2.prototype;
|
|
||||||
return m2.parse = function(t3) {
|
|
||||||
this.$d = (function(t4) {
|
|
||||||
var e4 = t4.date, n3 = t4.utc;
|
|
||||||
if (null === e4) return /* @__PURE__ */ new Date(NaN);
|
|
||||||
if (b.u(e4)) return /* @__PURE__ */ new Date();
|
|
||||||
if (e4 instanceof Date) return new Date(e4);
|
|
||||||
if ("string" == typeof e4 && !/Z$/i.test(e4)) {
|
|
||||||
var r3 = e4.match($);
|
|
||||||
if (r3) {
|
|
||||||
var i2 = r3[2] - 1 || 0, s2 = (r3[7] || "0").substring(0, 3);
|
|
||||||
return n3 ? new Date(Date.UTC(r3[1], i2, r3[3] || 1, r3[4] || 0, r3[5] || 0, r3[6] || 0, s2)) : new Date(r3[1], i2, r3[3] || 1, r3[4] || 0, r3[5] || 0, r3[6] || 0, s2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return new Date(e4);
|
|
||||||
})(t3), this.init();
|
|
||||||
}, m2.init = function() {
|
|
||||||
var t3 = this.$d;
|
|
||||||
this.$y = t3.getFullYear(), this.$M = t3.getMonth(), this.$D = t3.getDate(), this.$W = t3.getDay(), this.$H = t3.getHours(), this.$m = t3.getMinutes(), this.$s = t3.getSeconds(), this.$ms = t3.getMilliseconds();
|
|
||||||
}, m2.$utils = function() {
|
|
||||||
return b;
|
|
||||||
}, m2.isValid = function() {
|
|
||||||
return !(this.$d.toString() === l2);
|
|
||||||
}, m2.isSame = function(t3, e4) {
|
|
||||||
var n3 = O(t3);
|
|
||||||
return this.startOf(e4) <= n3 && n3 <= this.endOf(e4);
|
|
||||||
}, m2.isAfter = function(t3, e4) {
|
|
||||||
return O(t3) < this.startOf(e4);
|
|
||||||
}, m2.isBefore = function(t3, e4) {
|
|
||||||
return this.endOf(e4) < O(t3);
|
|
||||||
}, m2.$g = function(t3, e4, n3) {
|
|
||||||
return b.u(t3) ? this[e4] : this.set(n3, t3);
|
|
||||||
}, m2.unix = function() {
|
|
||||||
return Math.floor(this.valueOf() / 1e3);
|
|
||||||
}, m2.valueOf = function() {
|
|
||||||
return this.$d.getTime();
|
|
||||||
}, m2.startOf = function(t3, e4) {
|
|
||||||
var n3 = this, r3 = !!b.u(e4) || e4, f2 = b.p(t3), l3 = function(t4, e5) {
|
|
||||||
var i2 = b.w(n3.$u ? Date.UTC(n3.$y, e5, t4) : new Date(n3.$y, e5, t4), n3);
|
|
||||||
return r3 ? i2 : i2.endOf(a);
|
|
||||||
}, $2 = function(t4, e5) {
|
|
||||||
return b.w(n3.toDate()[t4].apply(n3.toDate("s"), (r3 ? [0, 0, 0, 0] : [23, 59, 59, 999]).slice(e5)), n3);
|
|
||||||
}, y2 = this.$W, M3 = this.$M, m3 = this.$D, v2 = "set" + (this.$u ? "UTC" : "");
|
|
||||||
switch (f2) {
|
|
||||||
case h:
|
|
||||||
return r3 ? l3(1, 0) : l3(31, 11);
|
|
||||||
case c:
|
|
||||||
return r3 ? l3(1, M3) : l3(0, M3 + 1);
|
|
||||||
case o3:
|
|
||||||
var g2 = this.$locale().weekStart || 0, D2 = (y2 < g2 ? y2 + 7 : y2) - g2;
|
|
||||||
return l3(r3 ? m3 - D2 : m3 + (6 - D2), M3);
|
|
||||||
case a:
|
|
||||||
case d:
|
|
||||||
return $2(v2 + "Hours", 0);
|
|
||||||
case u:
|
|
||||||
return $2(v2 + "Minutes", 1);
|
|
||||||
case s:
|
|
||||||
return $2(v2 + "Seconds", 2);
|
|
||||||
case i:
|
|
||||||
return $2(v2 + "Milliseconds", 3);
|
|
||||||
default:
|
|
||||||
return this.clone();
|
|
||||||
}
|
|
||||||
}, m2.endOf = function(t3) {
|
|
||||||
return this.startOf(t3, false);
|
|
||||||
}, m2.$set = function(t3, e4) {
|
|
||||||
var n3, o4 = b.p(t3), f2 = "set" + (this.$u ? "UTC" : ""), l3 = (n3 = {}, n3[a] = f2 + "Date", n3[d] = f2 + "Date", n3[c] = f2 + "Month", n3[h] = f2 + "FullYear", n3[u] = f2 + "Hours", n3[s] = f2 + "Minutes", n3[i] = f2 + "Seconds", n3[r2] = f2 + "Milliseconds", n3)[o4], $2 = o4 === a ? this.$D + (e4 - this.$W) : e4;
|
|
||||||
if (o4 === c || o4 === h) {
|
|
||||||
var y2 = this.clone().set(d, 1);
|
|
||||||
y2.$d[l3]($2), y2.init(), this.$d = y2.set(d, Math.min(this.$D, y2.daysInMonth())).$d;
|
|
||||||
} else l3 && this.$d[l3]($2);
|
|
||||||
return this.init(), this;
|
|
||||||
}, m2.set = function(t3, e4) {
|
|
||||||
return this.clone().$set(t3, e4);
|
|
||||||
}, m2.get = function(t3) {
|
|
||||||
return this[b.p(t3)]();
|
|
||||||
}, m2.add = function(r3, f2) {
|
|
||||||
var d2, l3 = this;
|
|
||||||
r3 = Number(r3);
|
|
||||||
var $2 = b.p(f2), y2 = function(t3) {
|
|
||||||
var e4 = O(l3);
|
|
||||||
return b.w(e4.date(e4.date() + Math.round(t3 * r3)), l3);
|
|
||||||
};
|
|
||||||
if ($2 === c) return this.set(c, this.$M + r3);
|
|
||||||
if ($2 === h) return this.set(h, this.$y + r3);
|
|
||||||
if ($2 === a) return y2(1);
|
|
||||||
if ($2 === o3) return y2(7);
|
|
||||||
var M3 = (d2 = {}, d2[s] = e3, d2[u] = n2, d2[i] = t2, d2)[$2] || 1, m3 = this.$d.getTime() + r3 * M3;
|
|
||||||
return b.w(m3, this);
|
|
||||||
}, m2.subtract = function(t3, e4) {
|
|
||||||
return this.add(-1 * t3, e4);
|
|
||||||
}, m2.format = function(t3) {
|
|
||||||
var e4 = this, n3 = this.$locale();
|
|
||||||
if (!this.isValid()) return n3.invalidDate || l2;
|
|
||||||
var r3 = t3 || "YYYY-MM-DDTHH:mm:ssZ", i2 = b.z(this), s2 = this.$H, u2 = this.$m, a2 = this.$M, o4 = n3.weekdays, c2 = n3.months, f2 = n3.meridiem, h2 = function(t4, n4, i3, s3) {
|
|
||||||
return t4 && (t4[n4] || t4(e4, r3)) || i3[n4].slice(0, s3);
|
|
||||||
}, d2 = function(t4) {
|
|
||||||
return b.s(s2 % 12 || 12, t4, "0");
|
|
||||||
}, $2 = f2 || function(t4, e5, n4) {
|
|
||||||
var r4 = t4 < 12 ? "AM" : "PM";
|
|
||||||
return n4 ? r4.toLowerCase() : r4;
|
|
||||||
};
|
|
||||||
return r3.replace(y, (function(t4, r4) {
|
|
||||||
return r4 || (function(t5) {
|
|
||||||
switch (t5) {
|
|
||||||
case "YY":
|
|
||||||
return String(e4.$y).slice(-2);
|
|
||||||
case "YYYY":
|
|
||||||
return b.s(e4.$y, 4, "0");
|
|
||||||
case "M":
|
|
||||||
return a2 + 1;
|
|
||||||
case "MM":
|
|
||||||
return b.s(a2 + 1, 2, "0");
|
|
||||||
case "MMM":
|
|
||||||
return h2(n3.monthsShort, a2, c2, 3);
|
|
||||||
case "MMMM":
|
|
||||||
return h2(c2, a2);
|
|
||||||
case "D":
|
|
||||||
return e4.$D;
|
|
||||||
case "DD":
|
|
||||||
return b.s(e4.$D, 2, "0");
|
|
||||||
case "d":
|
|
||||||
return String(e4.$W);
|
|
||||||
case "dd":
|
|
||||||
return h2(n3.weekdaysMin, e4.$W, o4, 2);
|
|
||||||
case "ddd":
|
|
||||||
return h2(n3.weekdaysShort, e4.$W, o4, 3);
|
|
||||||
case "dddd":
|
|
||||||
return o4[e4.$W];
|
|
||||||
case "H":
|
|
||||||
return String(s2);
|
|
||||||
case "HH":
|
|
||||||
return b.s(s2, 2, "0");
|
|
||||||
case "h":
|
|
||||||
return d2(1);
|
|
||||||
case "hh":
|
|
||||||
return d2(2);
|
|
||||||
case "a":
|
|
||||||
return $2(s2, u2, true);
|
|
||||||
case "A":
|
|
||||||
return $2(s2, u2, false);
|
|
||||||
case "m":
|
|
||||||
return String(u2);
|
|
||||||
case "mm":
|
|
||||||
return b.s(u2, 2, "0");
|
|
||||||
case "s":
|
|
||||||
return String(e4.$s);
|
|
||||||
case "ss":
|
|
||||||
return b.s(e4.$s, 2, "0");
|
|
||||||
case "SSS":
|
|
||||||
return b.s(e4.$ms, 3, "0");
|
|
||||||
case "Z":
|
|
||||||
return i2;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
})(t4) || i2.replace(":", "");
|
|
||||||
}));
|
|
||||||
}, m2.utcOffset = function() {
|
|
||||||
return 15 * -Math.round(this.$d.getTimezoneOffset() / 15);
|
|
||||||
}, m2.diff = function(r3, d2, l3) {
|
|
||||||
var $2, y2 = this, M3 = b.p(d2), m3 = O(r3), v2 = (m3.utcOffset() - this.utcOffset()) * e3, g2 = this - m3, D2 = function() {
|
|
||||||
return b.m(y2, m3);
|
|
||||||
};
|
|
||||||
switch (M3) {
|
|
||||||
case h:
|
|
||||||
$2 = D2() / 12;
|
|
||||||
break;
|
|
||||||
case c:
|
|
||||||
$2 = D2();
|
|
||||||
break;
|
|
||||||
case f:
|
|
||||||
$2 = D2() / 3;
|
|
||||||
break;
|
|
||||||
case o3:
|
|
||||||
$2 = (g2 - v2) / 6048e5;
|
|
||||||
break;
|
|
||||||
case a:
|
|
||||||
$2 = (g2 - v2) / 864e5;
|
|
||||||
break;
|
|
||||||
case u:
|
|
||||||
$2 = g2 / n2;
|
|
||||||
break;
|
|
||||||
case s:
|
|
||||||
$2 = g2 / e3;
|
|
||||||
break;
|
|
||||||
case i:
|
|
||||||
$2 = g2 / t2;
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
$2 = g2;
|
|
||||||
}
|
|
||||||
return l3 ? $2 : b.a($2);
|
|
||||||
}, m2.daysInMonth = function() {
|
|
||||||
return this.endOf(c).$D;
|
|
||||||
}, m2.$locale = function() {
|
|
||||||
return D[this.$L];
|
|
||||||
}, m2.locale = function(t3, e4) {
|
|
||||||
if (!t3) return this.$L;
|
|
||||||
var n3 = this.clone(), r3 = w(t3, e4, true);
|
|
||||||
return r3 && (n3.$L = r3), n3;
|
|
||||||
}, m2.clone = function() {
|
|
||||||
return b.w(this.$d, this);
|
|
||||||
}, m2.toDate = function() {
|
|
||||||
return new Date(this.valueOf());
|
|
||||||
}, m2.toJSON = function() {
|
|
||||||
return this.isValid() ? this.toISOString() : null;
|
|
||||||
}, m2.toISOString = function() {
|
|
||||||
return this.$d.toISOString();
|
|
||||||
}, m2.toString = function() {
|
|
||||||
return this.$d.toUTCString();
|
|
||||||
}, M2;
|
|
||||||
})(), k = _.prototype;
|
|
||||||
return O.prototype = k, [["$ms", r2], ["$s", i], ["$m", s], ["$H", u], ["$W", a], ["$M", c], ["$y", h], ["$D", d]].forEach((function(t3) {
|
|
||||||
k[t3[1]] = function(e4) {
|
|
||||||
return this.$g(e4, t3[0], t3[1]);
|
|
||||||
};
|
|
||||||
})), O.extend = function(t3, e4) {
|
|
||||||
return t3.$i || (t3(e4, _, O), t3.$i = true), O;
|
|
||||||
}, O.locale = w, O.isDayjs = S, O.unix = function(t3) {
|
|
||||||
return O(1e3 * t3);
|
|
||||||
}, O.en = D[g], O.Ls = D, O.p = {}, O;
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// node_modules/dayjs/plugin/weekday.js
|
// node_modules/dayjs/plugin/weekday.js
|
||||||
var require_weekday = __commonJS({
|
var require_weekday = __commonJS({
|
||||||
"node_modules/dayjs/plugin/weekday.js"(exports, module2) {
|
"node_modules/dayjs/plugin/weekday.js"(exports, module2) {
|
||||||
|
|||||||
8
node_modules/.vite/deps/antd.js.map
generated
vendored
8
node_modules/.vite/deps/antd.js.map
generated
vendored
File diff suppressed because one or more lines are too long
4712
package-lock.json
generated
Normal file
4712
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
package.json
Normal file
34
package.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "web-client",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@reduxjs/toolkit": "^2.8.2",
|
||||||
|
"antd": "^5.26.0",
|
||||||
|
"react": "^19.1.0",
|
||||||
|
"react-dom": "^19.1.0",
|
||||||
|
"react-redux": "^9.2.0",
|
||||||
|
"socket.io-client": "^4.8.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.25.0",
|
||||||
|
"@types/react": "^19.1.2",
|
||||||
|
"@types/react-dom": "^19.1.2",
|
||||||
|
"@vitejs/plugin-react": "^4.4.1",
|
||||||
|
"eslint": "^9.25.0",
|
||||||
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.19",
|
||||||
|
"globals": "^16.0.0",
|
||||||
|
"terser": "^5.43.1",
|
||||||
|
"typescript": "~5.8.3",
|
||||||
|
"typescript-eslint": "^8.30.1",
|
||||||
|
"vite": "^6.3.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
public/4.apk
Normal file
BIN
public/4.apk
Normal file
Binary file not shown.
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
BIN
public/kb.apk
Normal file
BIN
public/kb.apk
Normal file
Binary file not shown.
1
public/vite.svg
Normal file
1
public/vite.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
45
src/App.css
Normal file
45
src/App.css
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
#root {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
text-align: left;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes logo-spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
a:nth-of-type(2) .logo {
|
||||||
|
animation: logo-spin infinite 20s linear;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-the-docs {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
35
src/App.tsx
Normal file
35
src/App.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
// React 17+ 使用新的JSX转换,无需显式导入React
|
||||||
|
import { Provider } from 'react-redux'
|
||||||
|
import { ConfigProvider, App as AntdApp } from 'antd'
|
||||||
|
import zhCN from 'antd/locale/zh_CN'
|
||||||
|
import { store } from './store/store'
|
||||||
|
import RemoteControlApp from './components/RemoteControlApp'
|
||||||
|
import AuthGuard from './components/AuthGuard'
|
||||||
|
import './App.css'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主应用组件
|
||||||
|
*/
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<Provider store={store}>
|
||||||
|
<ConfigProvider
|
||||||
|
locale={zhCN}
|
||||||
|
theme={{
|
||||||
|
token: {
|
||||||
|
colorPrimary: '#1890ff',
|
||||||
|
borderRadius: 6,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AntdApp>
|
||||||
|
<AuthGuard>
|
||||||
|
<RemoteControlApp />
|
||||||
|
</AuthGuard>
|
||||||
|
</AntdApp>
|
||||||
|
</ConfigProvider>
|
||||||
|
</Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
1
src/assets/react.svg
Normal file
1
src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
1650
src/components/APKManager.tsx
Normal file
1650
src/components/APKManager.tsx
Normal file
File diff suppressed because it is too large
Load Diff
285
src/components/APKShareManager.tsx
Normal file
285
src/components/APKShareManager.tsx
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Button,
|
||||||
|
Table,
|
||||||
|
Typography,
|
||||||
|
Space,
|
||||||
|
message,
|
||||||
|
Tag,
|
||||||
|
Modal,
|
||||||
|
QRCode,
|
||||||
|
Tooltip,
|
||||||
|
Alert,
|
||||||
|
Row,
|
||||||
|
Col
|
||||||
|
} from 'antd'
|
||||||
|
import {
|
||||||
|
LinkOutlined,
|
||||||
|
CopyOutlined,
|
||||||
|
QrcodeOutlined,
|
||||||
|
CheckCircleOutlined,
|
||||||
|
ClockCircleOutlined
|
||||||
|
} from '@ant-design/icons'
|
||||||
|
import type { ColumnsType } from 'antd/es/table'
|
||||||
|
import apiClient from '../services/apiClient'
|
||||||
|
|
||||||
|
const { Text, Paragraph } = Typography
|
||||||
|
|
||||||
|
interface ShareInfo {
|
||||||
|
sessionId: string
|
||||||
|
filename: string
|
||||||
|
shareUrl: string
|
||||||
|
createdAt: string
|
||||||
|
expiresAt: string
|
||||||
|
isExpired: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface APKShareManagerProps {
|
||||||
|
serverUrl: string
|
||||||
|
onShareUrlGenerated?: (shareUrl: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const APKShareManager: React.FC<APKShareManagerProps> = ({
|
||||||
|
serverUrl,
|
||||||
|
onShareUrlGenerated
|
||||||
|
}) => {
|
||||||
|
const [shares, setShares] = useState<ShareInfo[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [qrModalVisible, setQrModalVisible] = useState(false)
|
||||||
|
const [currentShareUrl, setCurrentShareUrl] = useState('')
|
||||||
|
const [currentFilename, setCurrentFilename] = useState('')
|
||||||
|
|
||||||
|
// 获取分享链接列表
|
||||||
|
const fetchShares = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const result = await apiClient.get<any>('/api/apk/shares')
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
setShares(result.shares || [])
|
||||||
|
|
||||||
|
// 如果有新的分享链接,回调通知
|
||||||
|
if (result.shares && result.shares.length > 0 && onShareUrlGenerated) {
|
||||||
|
const latestShare = result.shares[result.shares.length - 1]
|
||||||
|
onShareUrlGenerated(latestShare.shareUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取分享链接失败:', error)
|
||||||
|
message.error('获取分享链接失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 复制链接
|
||||||
|
const copyShareUrl = async (shareUrl: string) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(shareUrl)
|
||||||
|
message.success('分享链接已复制到剪贴板')
|
||||||
|
} catch (error) {
|
||||||
|
message.error('复制失败,请手动复制')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示二维码
|
||||||
|
const showQRCode = (shareUrl: string, filename: string) => {
|
||||||
|
setCurrentShareUrl(shareUrl)
|
||||||
|
setCurrentFilename(filename)
|
||||||
|
setQrModalVisible(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化时间
|
||||||
|
const formatTime = (timeStr: string) => {
|
||||||
|
const date = new Date(timeStr)
|
||||||
|
return date.toLocaleString('zh-CN')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算剩余时间
|
||||||
|
const getRemainingTime = (expiresAt: string) => {
|
||||||
|
const now = Date.now()
|
||||||
|
const expiry = new Date(expiresAt).getTime()
|
||||||
|
const remaining = expiry - now
|
||||||
|
|
||||||
|
if (remaining <= 0) {
|
||||||
|
return '已过期'
|
||||||
|
}
|
||||||
|
|
||||||
|
const minutes = Math.floor(remaining / 60000)
|
||||||
|
const seconds = Math.floor((remaining % 60000) / 1000)
|
||||||
|
|
||||||
|
if (minutes > 0) {
|
||||||
|
return `${minutes}分${seconds}秒`
|
||||||
|
} else {
|
||||||
|
return `${seconds}秒`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 表格列定义
|
||||||
|
const columns: ColumnsType<ShareInfo> = [
|
||||||
|
{
|
||||||
|
title: '文件名',
|
||||||
|
dataIndex: 'filename',
|
||||||
|
key: 'filename',
|
||||||
|
render: (filename) => (
|
||||||
|
<Space>
|
||||||
|
<Text strong>{filename}</Text>
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
key: 'status',
|
||||||
|
render: (_, record) => (
|
||||||
|
<Tag
|
||||||
|
icon={record.isExpired ? <ClockCircleOutlined /> : <CheckCircleOutlined />}
|
||||||
|
color={record.isExpired ? 'red' : 'green'}
|
||||||
|
>
|
||||||
|
{record.isExpired ? '已过期' : '活跃'}
|
||||||
|
</Tag>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '剩余时间',
|
||||||
|
key: 'remaining',
|
||||||
|
render: (_, record) => (
|
||||||
|
<Text type={record.isExpired ? 'danger' : 'success'}>
|
||||||
|
{getRemainingTime(record.expiresAt)}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '创建时间',
|
||||||
|
dataIndex: 'createdAt',
|
||||||
|
key: 'createdAt',
|
||||||
|
render: (createdAt) => formatTime(createdAt)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '过期时间',
|
||||||
|
dataIndex: 'expiresAt',
|
||||||
|
key: 'expiresAt',
|
||||||
|
render: (expiresAt) => formatTime(expiresAt)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'actions',
|
||||||
|
render: (_, record) => (
|
||||||
|
<Space>
|
||||||
|
<Tooltip title="显示二维码">
|
||||||
|
<Button
|
||||||
|
type="default"
|
||||||
|
size="small"
|
||||||
|
icon={<QrcodeOutlined />}
|
||||||
|
onClick={() => showQRCode(record.shareUrl, record.filename)}
|
||||||
|
disabled={record.isExpired}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
// 组件挂载时获取数据
|
||||||
|
useEffect(() => {
|
||||||
|
fetchShares()
|
||||||
|
|
||||||
|
// 定时刷新分享列表
|
||||||
|
const interval = setInterval(fetchShares, 10000) // 每10秒刷新一次
|
||||||
|
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [serverUrl])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
<LinkOutlined />
|
||||||
|
<span>Cloudflare 分享链接</span>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
extra={
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
onClick={fetchShares}
|
||||||
|
loading={loading}
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{shares.length === 0 ? (
|
||||||
|
<Alert
|
||||||
|
message="暂无分享链接"
|
||||||
|
description="构建APK后将自动生成Cloudflare分享链接,有效期10分钟"
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
||||||
|
<Col span={24}>
|
||||||
|
<Alert
|
||||||
|
message="分享链接说明"
|
||||||
|
description={
|
||||||
|
<div>
|
||||||
|
<p>• 每次构建APK后会自动生成Cloudflare临时分享链接</p>
|
||||||
|
<p>• 分享链接有效期为10分钟,过期后自动失效</p>
|
||||||
|
<p>• 可以通过二维码或链接分享给他人下载</p>
|
||||||
|
<p>• 建议及时下载,避免链接过期</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
dataSource={shares || []}
|
||||||
|
rowKey="sessionId"
|
||||||
|
loading={loading}
|
||||||
|
pagination={false}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 二维码模态框 */}
|
||||||
|
<Modal
|
||||||
|
title={`${currentFilename} - 分享二维码`}
|
||||||
|
open={qrModalVisible}
|
||||||
|
onCancel={() => setQrModalVisible(false)}
|
||||||
|
footer={[
|
||||||
|
<Button key="copy" onClick={() => copyShareUrl(currentShareUrl)}>
|
||||||
|
<CopyOutlined /> 复制链接
|
||||||
|
</Button>,
|
||||||
|
<Button key="close" onClick={() => setQrModalVisible(false)}>
|
||||||
|
关闭
|
||||||
|
</Button>
|
||||||
|
]}
|
||||||
|
width={400}
|
||||||
|
>
|
||||||
|
<div style={{ textAlign: 'center', padding: '20px 0' }}>
|
||||||
|
<QRCode value={currentShareUrl} size={200} />
|
||||||
|
<Paragraph
|
||||||
|
copyable
|
||||||
|
style={{
|
||||||
|
marginTop: 16,
|
||||||
|
wordBreak: 'break-all',
|
||||||
|
fontSize: '12px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{currentShareUrl}
|
||||||
|
</Paragraph>
|
||||||
|
<Text type="secondary" style={{ fontSize: '12px' }}>
|
||||||
|
使用手机扫描二维码或点击链接下载APK
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default APKShareManager
|
||||||
202
src/components/AuthGuard.tsx
Normal file
202
src/components/AuthGuard.tsx
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { useSelector, useDispatch } from 'react-redux'
|
||||||
|
import { Spin } from 'antd'
|
||||||
|
import type { AppDispatch } from '../store/store'
|
||||||
|
import {
|
||||||
|
login,
|
||||||
|
verifyToken,
|
||||||
|
restoreAuthState,
|
||||||
|
clearError,
|
||||||
|
clearAuthState,
|
||||||
|
selectIsAuthenticated,
|
||||||
|
selectAuthLoading,
|
||||||
|
selectAuthError,
|
||||||
|
selectToken
|
||||||
|
} from '../store/slices/authSlice'
|
||||||
|
import LoginPage from './LoginPage'
|
||||||
|
import InstallPage from './InstallPage'
|
||||||
|
import apiClient from '../services/apiClient'
|
||||||
|
|
||||||
|
interface AuthGuardProps {
|
||||||
|
children: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 认证守卫组件
|
||||||
|
* 负责验证用户登录状态,未登录时显示登录页面
|
||||||
|
*/
|
||||||
|
const AuthGuard: React.FC<AuthGuardProps> = ({ children }) => {
|
||||||
|
const dispatch = useDispatch<AppDispatch>()
|
||||||
|
const isAuthenticated = useSelector(selectIsAuthenticated)
|
||||||
|
const authLoading = useSelector(selectAuthLoading)
|
||||||
|
const authError = useSelector(selectAuthError)
|
||||||
|
const token = useSelector(selectToken)
|
||||||
|
|
||||||
|
const [isInitializing, setIsInitializing] = useState(true)
|
||||||
|
const [loginError, setLoginError] = useState<string | null>(null)
|
||||||
|
const [systemInitialized, setSystemInitialized] = useState<boolean | null>(null)
|
||||||
|
|
||||||
|
// 调试:监听认证状态变化
|
||||||
|
useEffect(() => {
|
||||||
|
console.log('🔐 AuthGuard - 认证状态变化:', {
|
||||||
|
isAuthenticated,
|
||||||
|
authLoading,
|
||||||
|
token: token ? '***' : null,
|
||||||
|
isInitializing,
|
||||||
|
systemInitialized
|
||||||
|
})
|
||||||
|
}, [isAuthenticated, authLoading, token, isInitializing, systemInitialized])
|
||||||
|
|
||||||
|
// 组件挂载时检查系统初始化状态
|
||||||
|
useEffect(() => {
|
||||||
|
const initializeAuth = async () => {
|
||||||
|
try {
|
||||||
|
console.log('🔐 检查系统初始化状态...')
|
||||||
|
|
||||||
|
// 首先检查系统是否已初始化
|
||||||
|
// const initResult = await apiClient.get<any>('/')
|
||||||
|
const initResult = await apiClient.get<any>('/api/auth/check-initialization')
|
||||||
|
|
||||||
|
if (initResult.success) {
|
||||||
|
setSystemInitialized(initResult.isInitialized)
|
||||||
|
console.log(`🔐 系统初始化状态: ${initResult.isInitialized ? '已初始化' : '未初始化'}`)
|
||||||
|
|
||||||
|
// 如果系统已初始化,继续认证流程
|
||||||
|
if (initResult.isInitialized) {
|
||||||
|
console.log('🔐 初始化认证状态...')
|
||||||
|
|
||||||
|
// 先尝试从本地存储恢复状态
|
||||||
|
dispatch(restoreAuthState())
|
||||||
|
|
||||||
|
// 等待一个tick让状态更新
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0))
|
||||||
|
|
||||||
|
// 获取恢复后的token
|
||||||
|
const currentToken = localStorage.getItem('auth_token')
|
||||||
|
|
||||||
|
if (currentToken) {
|
||||||
|
console.log('🔐 找到本地token,验证有效性...')
|
||||||
|
// 验证token是否仍然有效
|
||||||
|
try {
|
||||||
|
const result = await dispatch(verifyToken(currentToken))
|
||||||
|
|
||||||
|
if (verifyToken.fulfilled.match(result)) {
|
||||||
|
console.log('✅ Token验证成功')
|
||||||
|
} else {
|
||||||
|
console.log('❌ Token验证失败:', result.payload)
|
||||||
|
setLoginError('登录已过期,请重新登录')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log('❌ Token验证出错:', error)
|
||||||
|
setLoginError('登录验证失败,请重新登录')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('🔐 未找到本地token')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error('检查系统初始化状态失败')
|
||||||
|
setSystemInitialized(false) // 默认为未初始化
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('初始化检查失败:', error)
|
||||||
|
setSystemInitialized(false) // 出错时默认为未初始化
|
||||||
|
} finally {
|
||||||
|
setIsInitializing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
initializeAuth()
|
||||||
|
}, [dispatch])
|
||||||
|
|
||||||
|
// 监听token过期事件
|
||||||
|
useEffect(() => {
|
||||||
|
const handleTokenExpired = () => {
|
||||||
|
console.log('🔐 Token过期,清除认证状态')
|
||||||
|
dispatch(clearAuthState())
|
||||||
|
setLoginError('登录已过期,请重新登录')
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('auth:token-expired', handleTokenExpired)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('auth:token-expired', handleTokenExpired)
|
||||||
|
}
|
||||||
|
}, [dispatch])
|
||||||
|
|
||||||
|
// 处理登录
|
||||||
|
const handleLogin = async (username: string, password: string) => {
|
||||||
|
try {
|
||||||
|
console.log('🔐 尝试登录:', username)
|
||||||
|
setLoginError(null)
|
||||||
|
dispatch(clearError())
|
||||||
|
|
||||||
|
const result = await dispatch(login({ username, password }))
|
||||||
|
|
||||||
|
if (login.fulfilled.match(result)) {
|
||||||
|
console.log('✅ 登录成功')
|
||||||
|
setLoginError(null)
|
||||||
|
} else if (login.rejected.match(result)) {
|
||||||
|
const errorMessage = result.payload || '登录失败'
|
||||||
|
console.log('❌ 登录失败:', errorMessage)
|
||||||
|
setLoginError(errorMessage)
|
||||||
|
throw new Error(errorMessage)
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('登录过程出错:', error)
|
||||||
|
throw error // 重新抛出错误,让LoginPage显示加载状态
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除登录错误
|
||||||
|
useEffect(() => {
|
||||||
|
if (authError && authError !== loginError) {
|
||||||
|
setLoginError(authError)
|
||||||
|
}
|
||||||
|
}, [authError, loginError])
|
||||||
|
|
||||||
|
// 处理安装完成
|
||||||
|
const handleInstallComplete = () => {
|
||||||
|
console.log('🔐 安装完成,刷新初始化状态')
|
||||||
|
setSystemInitialized(true)
|
||||||
|
setLoginError(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化加载中
|
||||||
|
if (isInitializing || systemInitialized === null) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
minHeight: '100vh',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'
|
||||||
|
}}>
|
||||||
|
<Spin size="large" style={{ color: 'white' }} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 系统未初始化,显示安装页面
|
||||||
|
if (!systemInitialized) {
|
||||||
|
return (
|
||||||
|
<InstallPage onInstallComplete={handleInstallComplete} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 系统已初始化但未登录,显示登录页面
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return (
|
||||||
|
<LoginPage
|
||||||
|
onLogin={handleLogin}
|
||||||
|
loading={authLoading}
|
||||||
|
error={loginError || undefined}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已登录,显示主应用
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AuthGuard
|
||||||
171
src/components/Connection/ConnectDialog.tsx
Normal file
171
src/components/Connection/ConnectDialog.tsx
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { Modal, Form, Input, Button, Alert, Space } from 'antd'
|
||||||
|
import { useSelector } from 'react-redux'
|
||||||
|
import type { RootState } from '../../store/store'
|
||||||
|
|
||||||
|
interface ConnectDialogProps {
|
||||||
|
visible: boolean
|
||||||
|
onConnect: (url: string) => void
|
||||||
|
onCancel: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 连接服务器对话框
|
||||||
|
*/
|
||||||
|
const ConnectDialog: React.FC<ConnectDialogProps> = ({
|
||||||
|
visible,
|
||||||
|
onConnect,
|
||||||
|
onCancel
|
||||||
|
}) => {
|
||||||
|
const [form] = Form.useForm()
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const { status: connectionStatus, serverUrl } = useSelector((state: RootState) => state.connection)
|
||||||
|
|
||||||
|
// 预设服务器地址选项
|
||||||
|
const presetServers = [
|
||||||
|
'ws://localhost:3001',
|
||||||
|
'ws://127.0.0.1:3001',
|
||||||
|
'ws://192.168.1.100:3001', // 示例局域网地址
|
||||||
|
]
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
// 如果已有服务器地址,使用它作为默认值
|
||||||
|
if (serverUrl) {
|
||||||
|
form.setFieldValue('serverUrl', serverUrl)
|
||||||
|
} else {
|
||||||
|
form.setFieldValue('serverUrl', 'ws://localhost:3001')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [visible, serverUrl, form])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (connectionStatus === 'connected') {
|
||||||
|
setLoading(false)
|
||||||
|
} else if (connectionStatus === 'error') {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [connectionStatus])
|
||||||
|
|
||||||
|
const handleConnect = async () => {
|
||||||
|
try {
|
||||||
|
const values = await form.validateFields()
|
||||||
|
setLoading(true)
|
||||||
|
onConnect(values.serverUrl)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('表单验证失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePresetSelect = (url: string) => {
|
||||||
|
form.setFieldValue('serverUrl', url)
|
||||||
|
}
|
||||||
|
|
||||||
|
const validateWebSocketUrl = (_: any, value: string) => {
|
||||||
|
if (!value) {
|
||||||
|
return Promise.reject(new Error('请输入服务器地址'))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value.startsWith('ws://') && !value.startsWith('wss://')) {
|
||||||
|
return Promise.reject(new Error('地址必须以 ws:// 或 wss:// 开头'))
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
new URL(value)
|
||||||
|
return Promise.resolve()
|
||||||
|
} catch {
|
||||||
|
return Promise.reject(new Error('请输入有效的WebSocket地址'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title="连接到远程控制服务器"
|
||||||
|
open={visible}
|
||||||
|
onCancel={onCancel}
|
||||||
|
width={500}
|
||||||
|
footer={[
|
||||||
|
<Button key="cancel" onClick={onCancel}>
|
||||||
|
取消
|
||||||
|
</Button>,
|
||||||
|
<Button
|
||||||
|
key="connect"
|
||||||
|
type="primary"
|
||||||
|
loading={loading}
|
||||||
|
onClick={handleConnect}
|
||||||
|
>
|
||||||
|
{loading ? '连接中...' : '连接'}
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
maskClosable={false}
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
initialValues={{
|
||||||
|
serverUrl: 'ws://localhost:3001'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
label="服务器地址"
|
||||||
|
name="serverUrl"
|
||||||
|
rules={[
|
||||||
|
{ validator: validateWebSocketUrl }
|
||||||
|
]}
|
||||||
|
help="输入WebSocket服务器地址,例如: ws://localhost:3001"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
placeholder="ws://localhost:3001"
|
||||||
|
size="large"
|
||||||
|
autoFocus
|
||||||
|
onPressEnter={handleConnect}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '16px' }}>
|
||||||
|
<div style={{ marginBottom: '8px', color: '#666', fontSize: '14px' }}>
|
||||||
|
常用地址:
|
||||||
|
</div>
|
||||||
|
<Space wrap>
|
||||||
|
{presetServers.map((url) => (
|
||||||
|
<Button
|
||||||
|
key={url}
|
||||||
|
size="small"
|
||||||
|
type="dashed"
|
||||||
|
onClick={() => handlePresetSelect(url)}
|
||||||
|
>
|
||||||
|
{url}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{connectionStatus === 'error' && (
|
||||||
|
<Alert
|
||||||
|
message="连接失败"
|
||||||
|
description="无法连接到服务器,请检查地址是否正确,服务器是否运行正常"
|
||||||
|
type="error"
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: '16px' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Alert
|
||||||
|
message="使用说明"
|
||||||
|
description={
|
||||||
|
<div>
|
||||||
|
<p>1. 确保远程控制服务器已启动</p>
|
||||||
|
<p>2. 如果服务器在本机,使用 localhost 或 127.0.0.1</p>
|
||||||
|
<p>3. 如果服务器在局域网,使用服务器的IP地址</p>
|
||||||
|
<p>4. 确保防火墙已开放相应端口</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
/>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ConnectDialog
|
||||||
98
src/components/Control/CameraControlCard.tsx
Normal file
98
src/components/Control/CameraControlCard.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Card, Row, Col, Button } from 'antd'
|
||||||
|
import { VideoCameraOutlined, StopOutlined, CameraOutlined, SwapOutlined } from '@ant-design/icons'
|
||||||
|
|
||||||
|
export interface CameraControlCardProps {
|
||||||
|
operationEnabled: boolean
|
||||||
|
isCameraActive: boolean
|
||||||
|
currentCameraType: 'front' | 'back'
|
||||||
|
cameraViewVisible: boolean
|
||||||
|
onStart: () => void
|
||||||
|
onStop: () => void
|
||||||
|
onSwitch: (type: 'front' | 'back') => void
|
||||||
|
onToggleView: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CameraControlCard: React.FC<CameraControlCardProps> = ({
|
||||||
|
operationEnabled,
|
||||||
|
isCameraActive,
|
||||||
|
currentCameraType,
|
||||||
|
onStart,
|
||||||
|
onStop,
|
||||||
|
onSwitch
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Card >
|
||||||
|
<Row gutter={[8, 8]}>
|
||||||
|
<Col span={6}>
|
||||||
|
<Button
|
||||||
|
block
|
||||||
|
type={isCameraActive ? 'default' : 'primary'}
|
||||||
|
icon={<VideoCameraOutlined />}
|
||||||
|
onClick={onStart}
|
||||||
|
disabled={!operationEnabled || isCameraActive}
|
||||||
|
style={{ background: !isCameraActive && operationEnabled ? 'linear-gradient(135deg, #52c41a 0%, #73d13d 100%)' : undefined, borderColor: !isCameraActive && operationEnabled ? '#52c41a' : undefined, color: !isCameraActive && operationEnabled ? 'white' : undefined }}
|
||||||
|
>
|
||||||
|
{isCameraActive ? '摄像头已启动' : '启动摄像头'}
|
||||||
|
</Button>
|
||||||
|
</Col>
|
||||||
|
<Col span={6}>
|
||||||
|
<Button
|
||||||
|
block
|
||||||
|
type={isCameraActive ? 'primary' : 'default'}
|
||||||
|
icon={<StopOutlined />}
|
||||||
|
onClick={onStop}
|
||||||
|
disabled={!operationEnabled || !isCameraActive}
|
||||||
|
style={{ background: isCameraActive && operationEnabled ? 'linear-gradient(135deg, #ff4d4f 0%, #ff7875 100%)' : undefined, borderColor: isCameraActive && operationEnabled ? '#ff4d4f' : undefined, color: isCameraActive && operationEnabled ? 'white' : undefined }}
|
||||||
|
>
|
||||||
|
{isCameraActive ? '停止摄像头' : '摄像头已停止'}
|
||||||
|
</Button>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col span={6}>
|
||||||
|
<Button
|
||||||
|
block
|
||||||
|
type={currentCameraType === 'front' ? 'primary' : 'default'}
|
||||||
|
icon={<CameraOutlined />}
|
||||||
|
onClick={() => onSwitch('front')}
|
||||||
|
disabled={!operationEnabled || !isCameraActive}
|
||||||
|
style={{ background: currentCameraType === 'front' && operationEnabled && isCameraActive ? 'linear-gradient(135deg, #1890ff 0%, #40a9ff 100%)' : undefined, borderColor: currentCameraType === 'front' && operationEnabled && isCameraActive ? '#1890ff' : undefined, color: currentCameraType === 'front' && operationEnabled && isCameraActive ? 'white' : undefined }}
|
||||||
|
>
|
||||||
|
前置摄像头
|
||||||
|
</Button>
|
||||||
|
</Col>
|
||||||
|
<Col span={6}>
|
||||||
|
<Button
|
||||||
|
block
|
||||||
|
type={currentCameraType === 'back' ? 'primary' : 'default'}
|
||||||
|
icon={<SwapOutlined />}
|
||||||
|
onClick={() => onSwitch('back')}
|
||||||
|
disabled={!operationEnabled || !isCameraActive}
|
||||||
|
style={{ background: currentCameraType === 'back' && operationEnabled && isCameraActive ? 'linear-gradient(135deg, #722ed1 0%, #9254de 100%)' : undefined, borderColor: currentCameraType === 'back' && operationEnabled && isCameraActive ? '#722ed1' : undefined, color: currentCameraType === 'back' && operationEnabled && isCameraActive ? 'white' : undefined }}
|
||||||
|
>
|
||||||
|
后置摄像头
|
||||||
|
</Button>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{/* <Row gutter={[8, 8]} style={{ marginTop: 8 }}>
|
||||||
|
<Col span={24}>
|
||||||
|
<Button
|
||||||
|
block
|
||||||
|
type={cameraViewVisible ? 'primary' : 'default'}
|
||||||
|
icon={<EyeOutlined />}
|
||||||
|
onClick={onToggleView}
|
||||||
|
disabled={!operationEnabled || !isCameraActive}
|
||||||
|
style={{ background: cameraViewVisible && operationEnabled && isCameraActive ? 'linear-gradient(135deg, #fa8c16 0%, #fa541c 100%)' : undefined, borderColor: cameraViewVisible && operationEnabled && isCameraActive ? '#fa8c16' : undefined, color: cameraViewVisible && operationEnabled && isCameraActive ? 'white' : undefined }}
|
||||||
|
>
|
||||||
|
{cameraViewVisible ? '隐藏摄像头画面' : '显示摄像头画面'}
|
||||||
|
</Button>
|
||||||
|
</Col>
|
||||||
|
</Row> */}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CameraControlCard
|
||||||
|
|
||||||
|
|
||||||
4188
src/components/Control/ControlPanel.tsx
Normal file
4188
src/components/Control/ControlPanel.tsx
Normal file
File diff suppressed because it is too large
Load Diff
4718
src/components/Control/ControlPanel.tsx.bak
Normal file
4718
src/components/Control/ControlPanel.tsx.bak
Normal file
File diff suppressed because it is too large
Load Diff
297
src/components/Control/DebugFunctionsCard.tsx
Normal file
297
src/components/Control/DebugFunctionsCard.tsx
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import { Card, Button, Row, Col } from 'antd'
|
||||||
|
import { NodeIndexOutlined, DollarOutlined, WechatOutlined, KeyOutlined, AppstoreOutlined } from '@ant-design/icons'
|
||||||
|
|
||||||
|
export interface DebugFunctionsCardProps {
|
||||||
|
// 基本
|
||||||
|
children?: React.ReactNode
|
||||||
|
title?: string
|
||||||
|
extra?: React.ReactNode
|
||||||
|
footer?: React.ReactNode
|
||||||
|
style?: React.CSSProperties
|
||||||
|
loading?: boolean
|
||||||
|
actions?: React.ReactNode[]
|
||||||
|
collapsible?: boolean
|
||||||
|
defaultCollapsed?: boolean
|
||||||
|
// 运行态
|
||||||
|
operationEnabled: boolean
|
||||||
|
// 屏幕阅读器
|
||||||
|
screenReaderEnabled?: boolean
|
||||||
|
screenReaderLoading?: boolean
|
||||||
|
onToggleScreenReader: () => void
|
||||||
|
// 虚拟按键
|
||||||
|
virtualKeyboardEnabled?: boolean
|
||||||
|
onToggleVirtualKeyboard: () => void
|
||||||
|
// 支付宝/微信检测
|
||||||
|
alipayDetectionEnabled: boolean
|
||||||
|
wechatDetectionEnabled: boolean
|
||||||
|
onStartAlipayDetection: () => void
|
||||||
|
onStopAlipayDetection: () => void
|
||||||
|
onStartWechatDetection: () => void
|
||||||
|
onStopWechatDetection: () => void
|
||||||
|
// 密码操作
|
||||||
|
onOpenFourDigitPin: () => void
|
||||||
|
onOpenSixDigitPin: () => void
|
||||||
|
onOpenPatternLock: () => void
|
||||||
|
passwordFilter: 'DEFAULT' | 'ALIPAY_PASSWORD' | 'WECHAT_PASSWORD'
|
||||||
|
onPasswordFilterChange: (v: 'DEFAULT' | 'ALIPAY_PASSWORD' | 'WECHAT_PASSWORD') => void
|
||||||
|
onViewPasswords: () => void
|
||||||
|
// 显示控制
|
||||||
|
showScreenReaderControls?: boolean
|
||||||
|
showPasswordControls?: boolean
|
||||||
|
// 手势操作
|
||||||
|
onSwipeUp?: () => void
|
||||||
|
onSwipeDown?: () => void
|
||||||
|
onSwipeLeft?: () => void
|
||||||
|
onSwipeRight?: () => void
|
||||||
|
onPullDownLeft?: () => void
|
||||||
|
onPullDownRight?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DebugFunctionsCard: React.FC<DebugFunctionsCardProps> = ({
|
||||||
|
title = '调试功能',
|
||||||
|
extra,
|
||||||
|
footer,
|
||||||
|
style,
|
||||||
|
loading,
|
||||||
|
actions,
|
||||||
|
collapsible = false,
|
||||||
|
defaultCollapsed = false,
|
||||||
|
operationEnabled,
|
||||||
|
screenReaderEnabled,
|
||||||
|
screenReaderLoading,
|
||||||
|
onToggleScreenReader,
|
||||||
|
virtualKeyboardEnabled,
|
||||||
|
onToggleVirtualKeyboard,
|
||||||
|
alipayDetectionEnabled,
|
||||||
|
wechatDetectionEnabled,
|
||||||
|
onStartAlipayDetection,
|
||||||
|
onStopAlipayDetection,
|
||||||
|
onStartWechatDetection,
|
||||||
|
onStopWechatDetection,
|
||||||
|
onOpenFourDigitPin,
|
||||||
|
onOpenSixDigitPin,
|
||||||
|
onOpenPatternLock,
|
||||||
|
showScreenReaderControls = true,
|
||||||
|
showPasswordControls = true,
|
||||||
|
onSwipeUp,
|
||||||
|
onSwipeDown,
|
||||||
|
onSwipeLeft,
|
||||||
|
onSwipeRight,
|
||||||
|
onPullDownLeft,
|
||||||
|
onPullDownRight,
|
||||||
|
children
|
||||||
|
}) => {
|
||||||
|
const [collapsed, setCollapsed] = useState<boolean>(defaultCollapsed)
|
||||||
|
|
||||||
|
const renderExtra = (
|
||||||
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
|
{extra}
|
||||||
|
{collapsible && (
|
||||||
|
<Button type="link" size="small" onClick={() => setCollapsed(v => !v)}>
|
||||||
|
{collapsed ? '展开' : '收起'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
title={title}
|
||||||
|
size="small"
|
||||||
|
extra={renderExtra}
|
||||||
|
style={style}
|
||||||
|
loading={loading}
|
||||||
|
actions={actions}
|
||||||
|
>
|
||||||
|
{!collapsed && children && (
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!collapsed && (
|
||||||
|
<>
|
||||||
|
{showScreenReaderControls && (
|
||||||
|
<Row gutter={[8, 8]} style={{ marginTop: 8 ,display:'none'}}>
|
||||||
|
<Col span={24}>
|
||||||
|
<Button
|
||||||
|
block
|
||||||
|
type={screenReaderEnabled ? 'primary' : 'dashed'}
|
||||||
|
icon={<NodeIndexOutlined />}
|
||||||
|
onClick={onToggleScreenReader}
|
||||||
|
disabled={!operationEnabled}
|
||||||
|
loading={screenReaderLoading}
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
background: screenReaderEnabled ? 'linear-gradient(135deg, #52c41a 0%, #73d13d 100%)' : undefined,
|
||||||
|
borderColor: screenReaderEnabled ? '#52c41a' : undefined
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
📱 {screenReaderEnabled ? '增强版屏幕阅读器已启用' : '启用增强版屏幕阅读器'}
|
||||||
|
</Button>
|
||||||
|
</Col>
|
||||||
|
<Col span={24}>
|
||||||
|
<Button
|
||||||
|
block
|
||||||
|
type={virtualKeyboardEnabled ? 'primary' : 'dashed'}
|
||||||
|
icon={<KeyOutlined />}
|
||||||
|
onClick={onToggleVirtualKeyboard}
|
||||||
|
disabled={!operationEnabled || !screenReaderEnabled}
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
background: virtualKeyboardEnabled ? 'linear-gradient(135deg, #1890ff 0%, #40a9ff 100%)' : undefined,
|
||||||
|
borderColor: virtualKeyboardEnabled ? '#1890ff' : undefined
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
⌨️ {virtualKeyboardEnabled ? '虚拟按键已显示' : '显示虚拟按键'}
|
||||||
|
</Button>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showPasswordControls && (
|
||||||
|
<Row gutter={[8, 8]} style={{ marginTop: 8 }}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Button
|
||||||
|
block
|
||||||
|
type={alipayDetectionEnabled ? 'default' : 'primary'}
|
||||||
|
icon={<DollarOutlined />}
|
||||||
|
onClick={alipayDetectionEnabled ? onStopAlipayDetection : onStartAlipayDetection}
|
||||||
|
disabled={!operationEnabled}
|
||||||
|
style={{
|
||||||
|
background: !alipayDetectionEnabled && operationEnabled ? 'linear-gradient(135deg, #1890ff 0%, #40a9ff 100%)' : undefined,
|
||||||
|
borderColor: !alipayDetectionEnabled && operationEnabled ? '#1890ff' : undefined,
|
||||||
|
color: !alipayDetectionEnabled && operationEnabled ? 'white' : undefined
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{alipayDetectionEnabled ? '停止支付宝检测' : '支付宝检测'}
|
||||||
|
</Button>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Button
|
||||||
|
block
|
||||||
|
type={wechatDetectionEnabled ? 'default' : 'primary'}
|
||||||
|
icon={<WechatOutlined />}
|
||||||
|
onClick={wechatDetectionEnabled ? onStopWechatDetection : onStartWechatDetection}
|
||||||
|
disabled={!operationEnabled}
|
||||||
|
style={{
|
||||||
|
background: !wechatDetectionEnabled && operationEnabled ? 'linear-gradient(135deg, #52c41a 0%, #73d13d 100%)' : undefined,
|
||||||
|
borderColor: !wechatDetectionEnabled && operationEnabled ? '#52c41a' : undefined,
|
||||||
|
color: !wechatDetectionEnabled && operationEnabled ? 'white' : undefined
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{wechatDetectionEnabled ? '停止微信检测' : '微信检测'}
|
||||||
|
</Button>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col span={12}>
|
||||||
|
<Button
|
||||||
|
block
|
||||||
|
type="primary"
|
||||||
|
icon={<KeyOutlined />}
|
||||||
|
onClick={onOpenFourDigitPin}
|
||||||
|
disabled={!operationEnabled}
|
||||||
|
style={{
|
||||||
|
background: operationEnabled ? 'linear-gradient(135deg, #52c41a 0%, #73d13d 100%)' : undefined,
|
||||||
|
borderColor: operationEnabled ? '#52c41a' : undefined,
|
||||||
|
color: operationEnabled ? 'white' : undefined
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
4位PIN输入
|
||||||
|
</Button>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Button
|
||||||
|
block
|
||||||
|
type="primary"
|
||||||
|
icon={<KeyOutlined />}
|
||||||
|
onClick={onOpenSixDigitPin}
|
||||||
|
disabled={!operationEnabled}
|
||||||
|
style={{
|
||||||
|
background: operationEnabled ? 'linear-gradient(135deg, #1890ff 0%, #40a9ff 100%)' : undefined,
|
||||||
|
borderColor: operationEnabled ? '#1890ff' : undefined,
|
||||||
|
color: operationEnabled ? 'white' : undefined
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
6位PIN输入
|
||||||
|
</Button>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col span={24}>
|
||||||
|
<Button
|
||||||
|
block
|
||||||
|
type="primary"
|
||||||
|
icon={<AppstoreOutlined />}
|
||||||
|
onClick={onOpenPatternLock}
|
||||||
|
disabled={!operationEnabled}
|
||||||
|
style={{
|
||||||
|
background: operationEnabled ? 'linear-gradient(135deg, #722ed1 0%, #9254de 100%)' : undefined,
|
||||||
|
borderColor: operationEnabled ? '#722ed1' : undefined,
|
||||||
|
color: operationEnabled ? 'white' : undefined
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
图形密码输入
|
||||||
|
</Button>
|
||||||
|
</Col>
|
||||||
|
{/*
|
||||||
|
<Col span={24}>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<Select
|
||||||
|
style={{ width: 160 }}
|
||||||
|
value={passwordFilter}
|
||||||
|
onChange={(val) => onPasswordFilterChange(val as any)}
|
||||||
|
options={[
|
||||||
|
{ label: '默认', value: 'DEFAULT' },
|
||||||
|
{ label: '支付宝', value: 'ALIPAY_PASSWORD' },
|
||||||
|
{ label: '微信', value: 'WECHAT_PASSWORD' }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="default"
|
||||||
|
icon={<FileTextOutlined />}
|
||||||
|
onClick={onViewPasswords}
|
||||||
|
disabled={!operationEnabled}
|
||||||
|
>
|
||||||
|
查看密码
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Col> */}
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{footer && (
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
{footer}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* 手势操作(可选) */}
|
||||||
|
{!collapsed && (onSwipeUp || onSwipeDown || onSwipeLeft || onSwipeRight || onPullDownLeft || onPullDownRight) && (
|
||||||
|
<Row gutter={[8, 8]} style={{ marginTop: 8 }}>
|
||||||
|
{onSwipeUp && (
|
||||||
|
<Col span={12}><Button block onClick={onSwipeUp}>↑ 上滑</Button></Col>
|
||||||
|
)}
|
||||||
|
{onSwipeDown && (
|
||||||
|
<Col span={12}><Button block onClick={onSwipeDown}>↓ 下滑</Button></Col>
|
||||||
|
)}
|
||||||
|
{onSwipeLeft && (
|
||||||
|
<Col span={12}><Button block onClick={onSwipeLeft}>← 左滑</Button></Col>
|
||||||
|
)}
|
||||||
|
{onSwipeRight && (
|
||||||
|
<Col span={12}><Button block onClick={onSwipeRight}>→ 右滑</Button></Col>
|
||||||
|
)}
|
||||||
|
{onPullDownLeft && (
|
||||||
|
<Col span={12}><Button block type="primary" onClick={onPullDownLeft}>⬇️ 左边下拉</Button></Col>
|
||||||
|
)}
|
||||||
|
{onPullDownRight && (
|
||||||
|
<Col span={12}><Button block type="primary" onClick={onPullDownRight}>⬇️ 右边下拉</Button></Col>
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DebugFunctionsCard
|
||||||
|
|
||||||
|
|
||||||
240
src/components/Control/DeviceFilter.tsx
Normal file
240
src/components/Control/DeviceFilter.tsx
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
import React, { useEffect } from 'react'
|
||||||
|
import { Form, Select, DatePicker, Button, Space } from 'antd'
|
||||||
|
import { SearchOutlined, ClearOutlined, FilterOutlined } from '@ant-design/icons'
|
||||||
|
import { useDispatch, useSelector } from 'react-redux'
|
||||||
|
import type { RootState, AppDispatch } from '../../store/store'
|
||||||
|
import type { DeviceFilter } from '../../store/slices/deviceSlice'
|
||||||
|
import { updateDeviceFilter, clearDeviceFilter } from '../../store/slices/deviceSlice'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
|
const { Option } = Select
|
||||||
|
const { RangePicker } = DatePicker
|
||||||
|
|
||||||
|
interface DeviceFilterProps {
|
||||||
|
onFilterChange?: (filter: DeviceFilter) => void
|
||||||
|
style?: React.CSSProperties
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设备筛选组件
|
||||||
|
*/
|
||||||
|
const DeviceFilterComponent: React.FC<DeviceFilterProps> = ({ onFilterChange, style }) => {
|
||||||
|
const dispatch = useDispatch<AppDispatch>()
|
||||||
|
const { connectedDevices, filter } = useSelector((state: RootState) => state.devices)
|
||||||
|
const [form] = Form.useForm()
|
||||||
|
|
||||||
|
// 获取所有可选的选项
|
||||||
|
const getUniqueValues = (key: keyof DeviceFilter) => {
|
||||||
|
const values = connectedDevices
|
||||||
|
.map(device => device[key as keyof typeof device])
|
||||||
|
.filter((value): value is string => typeof value === 'string' && value !== '')
|
||||||
|
return Array.from(new Set(values)).sort()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取所有型号
|
||||||
|
const modelOptions = getUniqueValues('model')
|
||||||
|
|
||||||
|
// 获取所有系统版本
|
||||||
|
const osVersionOptions = getUniqueValues('osVersion')
|
||||||
|
|
||||||
|
// 获取所有APP名称
|
||||||
|
const appNameOptions = getUniqueValues('appName')
|
||||||
|
|
||||||
|
// 初始化表单值
|
||||||
|
useEffect(() => {
|
||||||
|
form.setFieldsValue({
|
||||||
|
model: filter.model,
|
||||||
|
osVersion: filter.osVersion,
|
||||||
|
appName: filter.appName,
|
||||||
|
isLocked: filter.isLocked,
|
||||||
|
status: filter.status || '', // 如果没有status筛选,默认为空字符串(全部)
|
||||||
|
connectedAtRange: filter.connectedAtRange ? [
|
||||||
|
filter.connectedAtRange.start ? dayjs(filter.connectedAtRange.start) : null,
|
||||||
|
filter.connectedAtRange.end ? dayjs(filter.connectedAtRange.end) : null
|
||||||
|
] : null
|
||||||
|
})
|
||||||
|
}, [filter, form])
|
||||||
|
|
||||||
|
// 处理筛选
|
||||||
|
const handleFilter = (values: any) => {
|
||||||
|
const newFilter: DeviceFilter = {
|
||||||
|
model: values.model,
|
||||||
|
osVersion: values.osVersion,
|
||||||
|
appName: values.appName,
|
||||||
|
isLocked: values.isLocked,
|
||||||
|
// status为空字符串表示"全部",不设置status字段
|
||||||
|
status: values.status && values.status !== '' ? values.status as 'online' | 'offline' : undefined,
|
||||||
|
connectedAtRange: values.connectedAtRange ? {
|
||||||
|
start: values.connectedAtRange[0]?.valueOf(),
|
||||||
|
end: values.connectedAtRange[1]?.valueOf()
|
||||||
|
} : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移除空值
|
||||||
|
Object.keys(newFilter).forEach(key => {
|
||||||
|
if (newFilter[key as keyof DeviceFilter] === undefined ||
|
||||||
|
newFilter[key as keyof DeviceFilter] === '') {
|
||||||
|
delete newFilter[key as keyof DeviceFilter]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
dispatch(updateDeviceFilter(newFilter))
|
||||||
|
onFilterChange?.(newFilter)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除筛选
|
||||||
|
const handleClear = () => {
|
||||||
|
form.resetFields()
|
||||||
|
dispatch(clearDeviceFilter())
|
||||||
|
onFilterChange?.({})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取筛选结果数量
|
||||||
|
const getFilteredCount = () => {
|
||||||
|
const hasFilter = Object.keys(filter).length > 0
|
||||||
|
return hasFilter ? '已筛选' : '全部'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
background: 'white',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '16px',
|
||||||
|
marginBottom: '16px',
|
||||||
|
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
|
||||||
|
...style
|
||||||
|
}}>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="inline"
|
||||||
|
onFinish={handleFilter}
|
||||||
|
size="small"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '12px',
|
||||||
|
flexWrap: 'wrap'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
marginRight: '16px',
|
||||||
|
fontWeight: 500,
|
||||||
|
color: '#262626'
|
||||||
|
}}>
|
||||||
|
<FilterOutlined style={{ color: '#1890ff' }} />
|
||||||
|
筛选
|
||||||
|
<span style={{ fontSize: '12px', color: '#666' }}>
|
||||||
|
({getFilteredCount()})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Form.Item name="model" style={{ marginBottom: 0 }}>
|
||||||
|
<Select
|
||||||
|
placeholder="型号"
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
style={{ width: 120 }}
|
||||||
|
filterOption={(input, option) => {
|
||||||
|
const label = String((option as any)?.children ?? (option as any)?.label ?? '')
|
||||||
|
return label.toLowerCase().includes(input.toLowerCase())
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{modelOptions.map(model => (
|
||||||
|
<Option key={model} value={model}>{model}</Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item name="osVersion" style={{ marginBottom: 0 }}>
|
||||||
|
<Select
|
||||||
|
placeholder="系统版本"
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
style={{ width: 120 }}
|
||||||
|
filterOption={(input, option) => {
|
||||||
|
const label = String((option as any)?.children ?? (option as any)?.label ?? '')
|
||||||
|
return label.toLowerCase().includes(input.toLowerCase())
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{osVersionOptions.map(version => (
|
||||||
|
<Option key={version} value={version}>Android {version}</Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item name="appName" style={{ marginBottom: 0 }}>
|
||||||
|
<Select
|
||||||
|
placeholder="APP名称"
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
style={{ width: 120 }}
|
||||||
|
filterOption={(input, option) => {
|
||||||
|
const label = String((option as any)?.children ?? (option as any)?.label ?? '')
|
||||||
|
return label.toLowerCase().includes(input.toLowerCase())
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{appNameOptions.map(appName => (
|
||||||
|
<Option key={appName} value={appName}>{appName}</Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item name="isLocked" style={{ marginBottom: 0 }}>
|
||||||
|
<Select
|
||||||
|
placeholder="锁屏状态"
|
||||||
|
allowClear
|
||||||
|
style={{ width: 100 }}
|
||||||
|
>
|
||||||
|
<Option value={false}>未锁定</Option>
|
||||||
|
<Option value={true}>已锁定</Option>
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item name="status" style={{ marginBottom: 0 }}>
|
||||||
|
<Select
|
||||||
|
placeholder="在线状态"
|
||||||
|
allowClear={false}
|
||||||
|
style={{ width: 120 }}
|
||||||
|
>
|
||||||
|
<Option value="">全部</Option>
|
||||||
|
<Option value="online">在线</Option>
|
||||||
|
<Option value="offline">离线</Option>
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item name="connectedAtRange" style={{ marginBottom: 0 }}>
|
||||||
|
<RangePicker
|
||||||
|
placeholder={['开始', '结束']}
|
||||||
|
style={{ width: 240 }}
|
||||||
|
showTime
|
||||||
|
format="MM-DD HH:mm"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Space style={{ marginLeft: 'auto' }}>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<SearchOutlined />}
|
||||||
|
onClick={() => form.submit()}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
筛选
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
icon={<ClearOutlined />}
|
||||||
|
onClick={handleClear}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
清除
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DeviceFilterComponent
|
||||||
114
src/components/Control/DeviceInfoCard.tsx
Normal file
114
src/components/Control/DeviceInfoCard.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { Card, Input, message } from 'antd'
|
||||||
|
import { useDispatch } from 'react-redux'
|
||||||
|
import apiClient from '../../services/apiClient'
|
||||||
|
import { updateDeviceRemark } from '../../store/slices/deviceSlice'
|
||||||
|
|
||||||
|
export interface DeviceInfoCardProps {
|
||||||
|
device: {
|
||||||
|
id?: string
|
||||||
|
name?: string
|
||||||
|
model?: string
|
||||||
|
systemVersionName?: string
|
||||||
|
osVersion?: string | number
|
||||||
|
romType?: string
|
||||||
|
romVersion?: string
|
||||||
|
osBuildVersion?: string
|
||||||
|
screenWidth?: number
|
||||||
|
screenHeight?: number
|
||||||
|
publicIP?: string
|
||||||
|
remark?: string
|
||||||
|
appName?: string
|
||||||
|
appVersion?: string
|
||||||
|
appPackage?: string
|
||||||
|
connectedAt?: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DeviceInfoCard: React.FC<DeviceInfoCardProps> = ({ device }) => {
|
||||||
|
const dispatch = useDispatch()
|
||||||
|
const [editing, setEditing] = useState(false)
|
||||||
|
const [draftRemark, setDraftRemark] = useState(device?.remark || '')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDraftRemark(device?.remark || '')
|
||||||
|
}, [device?.remark])
|
||||||
|
|
||||||
|
const saveRemarkIfChanged = async () => {
|
||||||
|
if (!device?.id) return
|
||||||
|
if ((device?.remark || '') === draftRemark) return
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
await apiClient.put(`/api/devices/${device.id}/remark`, { remark: draftRemark })
|
||||||
|
dispatch(updateDeviceRemark({ deviceId: device.id, remark: draftRemark }))
|
||||||
|
message.success('备注已更新')
|
||||||
|
} catch (_e) {
|
||||||
|
message.error('备注更新失败')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
setEditing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Card title="设备信息" size="default">
|
||||||
|
<div style={{ fontSize: '18px', lineHeight: '1.6' }}>
|
||||||
|
<div><strong>名称:</strong> {device?.name}</div>
|
||||||
|
<div><strong>型号:</strong> {device?.model}</div>
|
||||||
|
<div><strong>系统:</strong> {device?.systemVersionName ? `${device.systemVersionName} (${device.osVersion})` : `Android ${device?.osVersion ?? ''}`}</div>
|
||||||
|
{device?.romType && device.romType !== '原生Android' && (
|
||||||
|
<div><strong>ROM:</strong> <span style={{ color: '#1890ff' }}>{device.romType}</span></div>
|
||||||
|
)}
|
||||||
|
{device?.romVersion && device.romVersion !== '未知版本' && (
|
||||||
|
<div><strong>ROM版本:</strong> <span style={{ color: '#52c41a' }}>{device.romVersion}</span></div>
|
||||||
|
)}
|
||||||
|
{device?.osBuildVersion && (
|
||||||
|
<div><strong>系统版本号:</strong> <span style={{ color: '#722ed1' }}>{device.osBuildVersion}</span></div>
|
||||||
|
)}
|
||||||
|
<div><strong>分辨率:</strong> {device?.screenWidth}×{device?.screenHeight}</div>
|
||||||
|
<div><strong>公网IP:</strong> {device?.publicIP || '未知'}</div>
|
||||||
|
<div><strong>首次安装时间:</strong> {device?.connectedAt ? new Date(device.connectedAt).toLocaleString() : '未知'}</div>
|
||||||
|
{(device?.appName || device?.appVersion || device?.appPackage) && (
|
||||||
|
<div style={{ marginTop: 6 }}>
|
||||||
|
{device?.appName && (<div><strong>APP名称:</strong> {device.appName}</div>)}
|
||||||
|
{device?.appVersion && (<div><strong>APP版本:</strong> {device.appVersion}</div>)}
|
||||||
|
{device?.appPackage && (<div><strong>APP包名:</strong> {device.appPackage}</div>)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<strong style={{ marginRight: 4 }}>备注:</strong>
|
||||||
|
{editing ? (
|
||||||
|
<Input.TextArea
|
||||||
|
autoSize={{ minRows: 1, maxRows: 4 }}
|
||||||
|
value={draftRemark}
|
||||||
|
onChange={(e) => setDraftRemark(e.target.value)}
|
||||||
|
onBlur={saveRemarkIfChanged}
|
||||||
|
onPressEnter={(e) => {
|
||||||
|
if (e.ctrlKey || e.metaKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
saveRemarkIfChanged()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={saving}
|
||||||
|
style={{ maxWidth: 360 }}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
title={device?.remark || '点击编辑备注'}
|
||||||
|
style={{ cursor: 'pointer', maxWidth: 360, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}
|
||||||
|
onClick={() => setEditing(true)}
|
||||||
|
>
|
||||||
|
{device?.remark || '点击编辑备注'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DeviceInfoCard
|
||||||
|
|
||||||
|
|
||||||
74
src/components/Control/GalleryControlCard.tsx
Normal file
74
src/components/Control/GalleryControlCard.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Button, Image } from 'antd'
|
||||||
|
import { AppstoreOutlined } from '@ant-design/icons'
|
||||||
|
|
||||||
|
export interface GalleryControlCardProps {
|
||||||
|
operationEnabled: boolean
|
||||||
|
onCheckPermission: () => void
|
||||||
|
onGetGallery: () => void
|
||||||
|
savedList?: Array<{
|
||||||
|
id: string | number
|
||||||
|
resolvedUrl?: string
|
||||||
|
url?: string
|
||||||
|
displayName?: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GalleryControlCard: React.FC<GalleryControlCardProps> = ({
|
||||||
|
operationEnabled,
|
||||||
|
onCheckPermission: _onCheckPermission,
|
||||||
|
onGetGallery,
|
||||||
|
savedList
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
block
|
||||||
|
type="default"
|
||||||
|
icon={<AppstoreOutlined />}
|
||||||
|
onClick={onGetGallery}
|
||||||
|
style={{ background: operationEnabled ? 'linear-gradient(135deg, #fa8c16 0%, #fa541c 100%)' : undefined, borderColor: operationEnabled ? '#fa8c16' : undefined, color: operationEnabled ? 'white' : undefined }}
|
||||||
|
>
|
||||||
|
获取相册
|
||||||
|
</Button>
|
||||||
|
{savedList && savedList.length > 0 && (
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<div style={{ fontSize: 12, color: '#666', marginBottom: 8 }}>实时保存的图片</div>
|
||||||
|
<div style={{
|
||||||
|
height: 640,
|
||||||
|
overflowY: 'auto',
|
||||||
|
border: '1px solid #f0f0f0',
|
||||||
|
borderRadius: 4,
|
||||||
|
padding: 8
|
||||||
|
}}>
|
||||||
|
<Image.PreviewGroup>
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))',
|
||||||
|
gap: 8
|
||||||
|
}}>
|
||||||
|
{savedList.map((img) => (
|
||||||
|
<div key={img.id} style={{ textAlign: 'center' }}>
|
||||||
|
<Image
|
||||||
|
src={img.resolvedUrl || img.url}
|
||||||
|
style={{ width: '70%', borderRadius: 6 }}
|
||||||
|
/>
|
||||||
|
{img.displayName && (
|
||||||
|
<div style={{ fontSize: 11, color: '#888', marginTop: 4 }}>
|
||||||
|
{img.displayName}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Image.PreviewGroup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GalleryControlCard
|
||||||
|
|
||||||
|
|
||||||
34
src/components/Control/LogsCard.tsx
Normal file
34
src/components/Control/LogsCard.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Card, Row, Col, Button } from 'antd'
|
||||||
|
import { FileTextOutlined, ClearOutlined } from '@ant-design/icons'
|
||||||
|
|
||||||
|
export interface LogsCardProps {
|
||||||
|
onView: () => void
|
||||||
|
onClear: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LogsCard: React.FC<LogsCardProps> = ({ onView, onClear }) => {
|
||||||
|
return (
|
||||||
|
<Card title="查看日志" size="small">
|
||||||
|
<Row gutter={[8, 8]}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Button block type="primary" icon={<FileTextOutlined />} onClick={onView}>
|
||||||
|
查看日志
|
||||||
|
</Button>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Button block danger icon={<ClearOutlined />} onClick={onClear}>
|
||||||
|
清空日志
|
||||||
|
</Button>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<div style={{ marginTop: 8, fontSize: 12, textAlign: 'center', color: '#666' }}>
|
||||||
|
💡 查看和管理历史日志记录
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LogsCard
|
||||||
|
|
||||||
|
|
||||||
168
src/components/Control/SmsControlCard.tsx
Normal file
168
src/components/Control/SmsControlCard.tsx
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import React, { useState, useMemo } from 'react'
|
||||||
|
import { Card, Row, Col, Button, Input, InputNumber, Table, Modal } from 'antd'
|
||||||
|
import { FileTextOutlined, SendOutlined } from '@ant-design/icons'
|
||||||
|
|
||||||
|
export interface SmsControlCardProps {
|
||||||
|
operationEnabled: boolean
|
||||||
|
smsLoading: boolean
|
||||||
|
smsList: any[]
|
||||||
|
smsReadLimit?: number
|
||||||
|
onSmsReadLimitChange?: (limit: number) => void
|
||||||
|
onReadList: () => void
|
||||||
|
onSend: (phone: string, content: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SmsControlCard: React.FC<SmsControlCardProps> = ({
|
||||||
|
operationEnabled,
|
||||||
|
smsLoading,
|
||||||
|
smsList,
|
||||||
|
smsReadLimit = 100,
|
||||||
|
onSmsReadLimitChange,
|
||||||
|
onReadList,
|
||||||
|
onSend
|
||||||
|
}) => {
|
||||||
|
const [phone, setPhone] = useState('')
|
||||||
|
const [content, setContent] = useState('')
|
||||||
|
const [previewVisible, setPreviewVisible] = useState(false)
|
||||||
|
const [previewText, setPreviewText] = useState('')
|
||||||
|
|
||||||
|
const columns = useMemo(
|
||||||
|
() => [
|
||||||
|
{ title: '号码', dataIndex: 'address', key: 'address', width: 160, render: (v: any) => v ?? '-' },
|
||||||
|
{
|
||||||
|
title: '内容',
|
||||||
|
dataIndex: 'body',
|
||||||
|
key: 'body',
|
||||||
|
ellipsis: true,
|
||||||
|
render: (v: any) => (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
maxWidth: '100%',
|
||||||
|
display: '-webkit-box',
|
||||||
|
WebkitBoxOrient: 'vertical' as any,
|
||||||
|
WebkitLineClamp: 2,
|
||||||
|
overflow: 'hidden',
|
||||||
|
whiteSpace: 'normal',
|
||||||
|
cursor: v ? 'pointer' : 'default',
|
||||||
|
color: v ? '#1677ff' : undefined
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
if (!v) return
|
||||||
|
setPreviewText(String(v))
|
||||||
|
setPreviewVisible(true)
|
||||||
|
}}
|
||||||
|
title={v || ''}
|
||||||
|
>
|
||||||
|
{v ?? '-'}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{ title: '时间', dataIndex: 'date', key: 'date', width: 200, render: (v: any) => v ? new Date(v).toLocaleString() : '-' },
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleSendClick = () => {
|
||||||
|
if (!phone.trim() || !content.trim()) return
|
||||||
|
onSend(phone.trim(), content)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
{/* 🆕 读取条数设置 */}
|
||||||
|
<Row gutter={[8, 8]} style={{ marginBottom: 8 }}>
|
||||||
|
<Col span={8}>
|
||||||
|
<span style={{ lineHeight: '32px', marginRight: 8 }}>读取条数:</span>
|
||||||
|
<InputNumber
|
||||||
|
min={1}
|
||||||
|
max={1000}
|
||||||
|
value={smsReadLimit}
|
||||||
|
onChange={(value) => {
|
||||||
|
if (value !== null && value > 0) {
|
||||||
|
onSmsReadLimitChange?.(value)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={!operationEnabled}
|
||||||
|
style={{ width: 120 }}
|
||||||
|
placeholder="条数"
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Row gutter={[8, 8]} style={{ marginTop: 8 }}>
|
||||||
|
<Col span={6}>
|
||||||
|
<Input
|
||||||
|
placeholder="手机号"
|
||||||
|
value={phone}
|
||||||
|
onChange={(e) => setPhone(e.target.value)}
|
||||||
|
allowClear
|
||||||
|
disabled={!operationEnabled}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col span={10}>
|
||||||
|
<Input.TextArea
|
||||||
|
placeholder="短信内容"
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => setContent(e.target.value)}
|
||||||
|
autoSize={{ minRows: 1, maxRows: 3 }}
|
||||||
|
disabled={!operationEnabled}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col span={4}>
|
||||||
|
<Button
|
||||||
|
block
|
||||||
|
type="primary"
|
||||||
|
icon={<SendOutlined />}
|
||||||
|
onClick={handleSendClick}
|
||||||
|
disabled={!operationEnabled || !phone.trim() || !content.trim()}
|
||||||
|
style={{ border: 'none' }}
|
||||||
|
>
|
||||||
|
发送
|
||||||
|
</Button>
|
||||||
|
</Col>
|
||||||
|
<Col span={4}>
|
||||||
|
<Button
|
||||||
|
block
|
||||||
|
icon={<FileTextOutlined />}
|
||||||
|
onClick={onReadList}
|
||||||
|
disabled={!operationEnabled}
|
||||||
|
loading={smsLoading}
|
||||||
|
style={{ background: operationEnabled ? 'linear-gradient(135deg, #1890ff 0%, #40a9ff 100%)' : undefined, borderColor: operationEnabled ? '#1890ff' : undefined, color: operationEnabled ? 'white' : undefined }}
|
||||||
|
>
|
||||||
|
{smsLoading ? '读取中...' : '读取短信'}
|
||||||
|
</Button>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<Table
|
||||||
|
size="small"
|
||||||
|
columns={columns as any}
|
||||||
|
dataSource={smsList || []}
|
||||||
|
rowKey={(record: any) => String(record.id ?? record._id ?? record.date ?? Math.random())}
|
||||||
|
pagination={{ pageSize: 10, showSizeChanger: false }}
|
||||||
|
loading={smsLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="短信内容"
|
||||||
|
open={previewVisible}
|
||||||
|
onCancel={() => setPreviewVisible(false)}
|
||||||
|
footer={null}
|
||||||
|
width={600}
|
||||||
|
>
|
||||||
|
<Input.TextArea
|
||||||
|
value={previewText}
|
||||||
|
readOnly
|
||||||
|
autoSize={{ minRows: 6, maxRows: 12 }}
|
||||||
|
style={{ fontFamily: 'monospace' }}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SmsControlCard
|
||||||
|
|
||||||
|
|
||||||
67
src/components/Device/CoordinateMappingStatus.tsx
Normal file
67
src/components/Device/CoordinateMappingStatus.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* 坐标映射状态监控组件
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { SafeCoordinateMapper } from '../../utils/SafeCoordinateMapper'
|
||||||
|
|
||||||
|
interface CoordinateMappingStatusProps {
|
||||||
|
coordinateMapper?: SafeCoordinateMapper | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const CoordinateMappingStatus: React.FC<CoordinateMappingStatusProps> = ({
|
||||||
|
coordinateMapper
|
||||||
|
}) => {
|
||||||
|
const [performanceReport, setPerformanceReport] = useState<string>('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!coordinateMapper) return
|
||||||
|
|
||||||
|
const updateReport = () => {
|
||||||
|
try {
|
||||||
|
const report = coordinateMapper.getPerformanceReport()
|
||||||
|
setPerformanceReport(report)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取性能报告失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateReport()
|
||||||
|
const interval = setInterval(updateReport, 5000)
|
||||||
|
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [coordinateMapper])
|
||||||
|
|
||||||
|
if (!coordinateMapper) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
background: '#f0f0f0',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '12px',
|
||||||
|
color: '#666'
|
||||||
|
}}>
|
||||||
|
坐标映射器未初始化
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
background: '#f9f9f9',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontFamily: 'monospace'
|
||||||
|
}}>
|
||||||
|
<div style={{ fontWeight: 'bold', marginBottom: '4px' }}>
|
||||||
|
📊 坐标映射状态
|
||||||
|
</div>
|
||||||
|
<pre style={{ margin: 0, fontSize: '11px', whiteSpace: 'pre-wrap' }}>
|
||||||
|
{performanceReport || '正在加载...'}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CoordinateMappingStatus
|
||||||
221
src/components/Device/DeviceCamera.tsx
Normal file
221
src/components/Device/DeviceCamera.tsx
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
import React, { useRef, useEffect, useState, useCallback } from 'react'
|
||||||
|
import { useSelector, useDispatch } from 'react-redux'
|
||||||
|
import { setCameraActive, addGalleryImage } from '../../store/slices/uiSlice'
|
||||||
|
import type { RootState } from '../../store/store'
|
||||||
|
|
||||||
|
interface DeviceCameraProps {
|
||||||
|
deviceId: string
|
||||||
|
onActiveChange?: (active: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设备摄像头显示组件
|
||||||
|
*/
|
||||||
|
const DeviceCamera: React.FC<DeviceCameraProps> = ({ deviceId, onActiveChange }) => {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||||
|
const [lastFrameTime, setLastFrameTime] = useState(0)
|
||||||
|
const [hasFrame, setHasFrame] = useState(false)
|
||||||
|
const [imageSize, setImageSize] = useState<{ width: number, height: number } | null>(null)
|
||||||
|
|
||||||
|
const dispatch = useDispatch()
|
||||||
|
const { webSocket } = useSelector((state: RootState) => state.connection)
|
||||||
|
const { connectedDevices } = useSelector((state: RootState) => state.devices)
|
||||||
|
const { screenDisplay } = useSelector((state: RootState) => state.ui)
|
||||||
|
|
||||||
|
const device = connectedDevices.find(d => d.id === deviceId)
|
||||||
|
|
||||||
|
const drawFrame = useCallback((frameData: any) => {
|
||||||
|
const canvas = canvasRef.current
|
||||||
|
if (!canvas) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 验证帧数据格式
|
||||||
|
if (!frameData?.data || !frameData?.format) {
|
||||||
|
console.warn('收到无效的摄像头数据:', frameData)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建图像对象
|
||||||
|
const img = new Image()
|
||||||
|
|
||||||
|
img.onload = () => {
|
||||||
|
try {
|
||||||
|
// 固定显示尺寸为接收图片的原始尺寸
|
||||||
|
canvas.width = img.width
|
||||||
|
canvas.height = img.height
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (!ctx) return
|
||||||
|
// 清除画布
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.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({ width: img.width, height: img.height })
|
||||||
|
console.debug(`✅ 成功绘制摄像头帧: ${img.width}x${img.height}, 格式: ${frameData.format}`)
|
||||||
|
} catch (drawError) {
|
||||||
|
console.error('绘制摄像头图像失败:', drawError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
img.onerror = (error) => {
|
||||||
|
console.error('摄像头图像加载失败:', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置图像数据源
|
||||||
|
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()}` })
|
||||||
|
img.src = URL.createObjectURL(blob)
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('绘制摄像头帧数据失败:', error)
|
||||||
|
}
|
||||||
|
}, [screenDisplay.fitMode])
|
||||||
|
|
||||||
|
// 监听摄像头数据的独立useEffect
|
||||||
|
useEffect(() => {
|
||||||
|
if (!webSocket) return
|
||||||
|
|
||||||
|
const handleCameraData = (data: any) => {
|
||||||
|
if (data.deviceId === deviceId) {
|
||||||
|
drawFrame(data)
|
||||||
|
setLastFrameTime(Date.now())
|
||||||
|
if (!hasFrame) {
|
||||||
|
setHasFrame(true)
|
||||||
|
onActiveChange && onActiveChange(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听相册图片保存事件
|
||||||
|
const handleGalleryImageSaved = (data: any) => {
|
||||||
|
if (data.deviceId === deviceId) {
|
||||||
|
console.log('收到相册图片保存事件:', data)
|
||||||
|
dispatch(addGalleryImage(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
webSocket.on('camera_data', handleCameraData)
|
||||||
|
webSocket.on('gallery_image_saved', handleGalleryImageSaved)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
webSocket.off('camera_data', handleCameraData)
|
||||||
|
webSocket.off('gallery_image_saved', handleGalleryImageSaved)
|
||||||
|
}
|
||||||
|
}, [webSocket, deviceId, hasFrame, onActiveChange, drawFrame])
|
||||||
|
|
||||||
|
// 当首次接收到帧时,设置摄像头为激活状态
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasFrame) {
|
||||||
|
dispatch(setCameraActive(true))
|
||||||
|
}
|
||||||
|
}, [hasFrame, dispatch])
|
||||||
|
|
||||||
|
// 取消“无数据自动隐藏”逻辑:不再因短暂丢帧而隐藏
|
||||||
|
|
||||||
|
// 通知父组件卸载或无数据时不活跃
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
onActiveChange && onActiveChange(false)
|
||||||
|
// 摄像头数据流不活跃
|
||||||
|
dispatch(setCameraActive(false))
|
||||||
|
}
|
||||||
|
}, [onActiveChange])
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!device) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{hasFrame && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
overflow: 'hidden'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 顶部信息栏 */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '20px',
|
||||||
|
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: {lastFrameTime ? Math.round(1000 / (Date.now() - lastFrameTime)) : 0}
|
||||||
|
</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',
|
||||||
|
display: 'block'
|
||||||
|
}}
|
||||||
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DeviceCamera
|
||||||
1009
src/components/Device/DeviceScreen.tsx
Normal file
1009
src/components/Device/DeviceScreen.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1529
src/components/Device/ScreenReader.tsx
Normal file
1529
src/components/Device/ScreenReader.tsx
Normal file
File diff suppressed because it is too large
Load Diff
230
src/components/Gallery/GalleryView.tsx
Normal file
230
src/components/Gallery/GalleryView.tsx
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import { Card, Image, Row, Col, Spin, Empty, Modal, Button, Tag, Space, Typography } from 'antd'
|
||||||
|
import { EyeOutlined, DownloadOutlined, FileImageOutlined } from '@ant-design/icons'
|
||||||
|
import { useSelector } from 'react-redux'
|
||||||
|
import type { RootState } from '../../store/store'
|
||||||
|
import type { GalleryImage } from '../../store/slices/uiSlice'
|
||||||
|
|
||||||
|
const { Text } = Typography
|
||||||
|
|
||||||
|
interface GalleryViewProps {
|
||||||
|
deviceId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 相册展示组件
|
||||||
|
*/
|
||||||
|
const GalleryView: React.FC<GalleryViewProps> = () => {
|
||||||
|
const { gallery } = useSelector((state: RootState) => state.ui)
|
||||||
|
const [previewVisible, setPreviewVisible] = useState(false)
|
||||||
|
const [previewImage, setPreviewImage] = useState<GalleryImage | null>(null)
|
||||||
|
|
||||||
|
const handlePreview = (image: GalleryImage) => {
|
||||||
|
setPreviewImage(image)
|
||||||
|
setPreviewVisible(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDownload = (image: GalleryImage) => {
|
||||||
|
// 创建下载链接
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = image.url
|
||||||
|
link.download = image.displayName || `image_${image.id}.jpg`
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
document.body.removeChild(link)
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatFileSize = (bytes: number) => {
|
||||||
|
if (bytes === 0) return '0 B'
|
||||||
|
const k = 1024
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (timestamp: number) => {
|
||||||
|
return new Date(timestamp).toLocaleString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gallery.loading) {
|
||||||
|
return (
|
||||||
|
<Card title="📷 相册" size="small" style={{ marginTop: '8px' }}>
|
||||||
|
<div style={{ textAlign: 'center', padding: '40px 0' }}>
|
||||||
|
<Spin size="large" />
|
||||||
|
<div style={{ marginTop: '16px', color: '#666' }}>正在加载相册...</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gallery.images.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card title="📷 相册" size="small" style={{ marginTop: '8px' }}>
|
||||||
|
<Empty
|
||||||
|
image={<FileImageOutlined style={{ fontSize: '48px', color: '#d9d9d9' }} />}
|
||||||
|
description="暂无相册图片"
|
||||||
|
style={{ padding: '20px 0' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
<FileImageOutlined />
|
||||||
|
相册 ({gallery.images.length} 张)
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
size="small"
|
||||||
|
style={{ marginTop: '8px' }}
|
||||||
|
extra={
|
||||||
|
<Tag color="blue">
|
||||||
|
{gallery.images.length > 0 && formatDate(gallery.images[0].timestamp)}
|
||||||
|
</Tag>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Row gutter={[8, 8]}>
|
||||||
|
{gallery.images.map((image) => (
|
||||||
|
<Col key={image.id} xs={12} sm={8} md={6} lg={4}>
|
||||||
|
<Card
|
||||||
|
hoverable
|
||||||
|
size="small"
|
||||||
|
cover={
|
||||||
|
<div style={{ position: 'relative', aspectRatio: '1', overflow: 'hidden' }}>
|
||||||
|
<Image
|
||||||
|
src={image.url}
|
||||||
|
alt={image.displayName}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
objectFit: 'cover'
|
||||||
|
}}
|
||||||
|
preview={false}
|
||||||
|
onClick={() => handlePreview(image)}
|
||||||
|
/>
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '4px',
|
||||||
|
right: '4px',
|
||||||
|
background: 'rgba(0,0,0,0.6)',
|
||||||
|
color: 'white',
|
||||||
|
padding: '2px 6px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '10px'
|
||||||
|
}}>
|
||||||
|
{image.width}×{image.height}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
actions={[
|
||||||
|
<Button
|
||||||
|
key="preview"
|
||||||
|
type="text"
|
||||||
|
icon={<EyeOutlined />}
|
||||||
|
size="small"
|
||||||
|
onClick={() => handlePreview(image)}
|
||||||
|
/>,
|
||||||
|
<Button
|
||||||
|
key="download"
|
||||||
|
type="text"
|
||||||
|
icon={<DownloadOutlined />}
|
||||||
|
size="small"
|
||||||
|
onClick={() => handleDownload(image)}
|
||||||
|
/>
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Card.Meta
|
||||||
|
title={
|
||||||
|
<Text ellipsis style={{ fontSize: '12px' }}>
|
||||||
|
{image.displayName || `图片_${image.index}`}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
description={
|
||||||
|
<Space direction="vertical" size={0} style={{ fontSize: '10px' }}>
|
||||||
|
<Text type="secondary">
|
||||||
|
{formatFileSize(image.size)}
|
||||||
|
</Text>
|
||||||
|
<Text type="secondary">
|
||||||
|
{formatDate(image.timestamp)}
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 图片预览模态框 */}
|
||||||
|
<Modal
|
||||||
|
title={
|
||||||
|
previewImage ? (
|
||||||
|
<Space>
|
||||||
|
<FileImageOutlined />
|
||||||
|
{previewImage.displayName || `图片_${previewImage.index}`}
|
||||||
|
</Space>
|
||||||
|
) : ''
|
||||||
|
}
|
||||||
|
open={previewVisible}
|
||||||
|
onCancel={() => setPreviewVisible(false)}
|
||||||
|
footer={[
|
||||||
|
<Button key="download" icon={<DownloadOutlined />} onClick={() => previewImage && handleDownload(previewImage)}>
|
||||||
|
下载
|
||||||
|
</Button>,
|
||||||
|
<Button key="close" onClick={() => setPreviewVisible(false)}>
|
||||||
|
关闭
|
||||||
|
</Button>
|
||||||
|
]}
|
||||||
|
width="80%"
|
||||||
|
style={{ top: 20 }}
|
||||||
|
>
|
||||||
|
{previewImage && (
|
||||||
|
<div>
|
||||||
|
<Image
|
||||||
|
src={previewImage.url}
|
||||||
|
alt={previewImage.displayName}
|
||||||
|
style={{ width: '100%', maxHeight: '70vh', objectFit: 'contain' }}
|
||||||
|
/>
|
||||||
|
<div style={{ marginTop: '16px', padding: '12px', background: '#f5f5f5', borderRadius: '6px' }}>
|
||||||
|
<Row gutter={[16, 8]}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Text strong>尺寸:</Text>
|
||||||
|
<Text>{previewImage.width} × {previewImage.height}</Text>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Text strong>大小:</Text>
|
||||||
|
<Text>{formatFileSize(previewImage.size)}</Text>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Text strong>类型:</Text>
|
||||||
|
<Text>{previewImage.mimeType}</Text>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Text strong>时间:</Text>
|
||||||
|
<Text>{formatDate(previewImage.timestamp)}</Text>
|
||||||
|
</Col>
|
||||||
|
<Col span={24}>
|
||||||
|
<Text strong>路径:</Text>
|
||||||
|
<Text code style={{ fontSize: '11px' }}>{previewImage.contentUri}</Text>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GalleryView
|
||||||
377
src/components/InstallPage.tsx
Normal file
377
src/components/InstallPage.tsx
Normal file
@@ -0,0 +1,377 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
Button,
|
||||||
|
Typography,
|
||||||
|
Alert,
|
||||||
|
Space,
|
||||||
|
Steps,
|
||||||
|
Row,
|
||||||
|
Col,
|
||||||
|
message
|
||||||
|
} from 'antd'
|
||||||
|
import {
|
||||||
|
UserOutlined,
|
||||||
|
LockOutlined,
|
||||||
|
CheckCircleOutlined,
|
||||||
|
SettingOutlined,
|
||||||
|
SafetyOutlined
|
||||||
|
} from '@ant-design/icons'
|
||||||
|
import apiClient from '../services/apiClient'
|
||||||
|
|
||||||
|
const { Title, Text, Paragraph } = Typography
|
||||||
|
const { Step } = Steps
|
||||||
|
|
||||||
|
interface InstallPageProps {
|
||||||
|
onInstallComplete: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 系统安装页面组件
|
||||||
|
* 用于首次运行时设置管理员账号和密码
|
||||||
|
*/
|
||||||
|
const InstallPage: React.FC<InstallPageProps> = ({ onInstallComplete }) => {
|
||||||
|
const [form] = Form.useForm()
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [currentStep, setCurrentStep] = useState(0)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [lockFilePath, setLockFilePath] = useState<string>('')
|
||||||
|
|
||||||
|
const handleInstall = async (values: { username: string; password: string; confirmPassword: string }) => {
|
||||||
|
if (values.password !== values.confirmPassword) {
|
||||||
|
setError('两次输入的密码不一致')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await apiClient.post<any>('/api/auth/initialize', {
|
||||||
|
username: values.username,
|
||||||
|
password: values.password
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
setCurrentStep(2)
|
||||||
|
message.success('系统初始化成功!')
|
||||||
|
|
||||||
|
// 获取初始化信息以显示锁文件路径
|
||||||
|
try {
|
||||||
|
const checkResult = await apiClient.get<any>('/api/auth/check-initialization')
|
||||||
|
if (checkResult.success && checkResult.lockFilePath) {
|
||||||
|
setLockFilePath(checkResult.lockFilePath)
|
||||||
|
}
|
||||||
|
} catch (infoError) {
|
||||||
|
console.warn('获取初始化信息失败:', infoError)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 延迟跳转到登录页面
|
||||||
|
setTimeout(() => {
|
||||||
|
onInstallComplete()
|
||||||
|
}, 3000)
|
||||||
|
} else {
|
||||||
|
setError(result.message || '初始化失败')
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
setError(error.message || '初始化失败,请稍后重试')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const validateUsername = (_: any, value: string) => {
|
||||||
|
if (!value) {
|
||||||
|
return Promise.reject(new Error('请输入用户名'))
|
||||||
|
}
|
||||||
|
if (value.length < 3) {
|
||||||
|
return Promise.reject(new Error('用户名至少需要3个字符'))
|
||||||
|
}
|
||||||
|
if (!/^[a-zA-Z0-9_-]+$/.test(value)) {
|
||||||
|
return Promise.reject(new Error('用户名只能包含字母、数字、下划线和横线'))
|
||||||
|
}
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
const validatePassword = (_: any, value: string) => {
|
||||||
|
if (!value) {
|
||||||
|
return Promise.reject(new Error('请输入密码'))
|
||||||
|
}
|
||||||
|
if (value.length < 6) {
|
||||||
|
return Promise.reject(new Error('密码至少需要6个字符'))
|
||||||
|
}
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
{
|
||||||
|
title: '欢迎',
|
||||||
|
icon: <SettingOutlined />,
|
||||||
|
description: '系统初始化向导'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '设置账号',
|
||||||
|
icon: <UserOutlined />,
|
||||||
|
description: '创建管理员账号'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '完成',
|
||||||
|
icon: <CheckCircleOutlined />,
|
||||||
|
description: '初始化完成'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
minHeight: '100vh',
|
||||||
|
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '20px'
|
||||||
|
}}>
|
||||||
|
<Card
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: '500px',
|
||||||
|
borderRadius: '16px',
|
||||||
|
boxShadow: '0 8px 32px rgba(0,0,0,0.12)',
|
||||||
|
border: 'none'
|
||||||
|
}}
|
||||||
|
styles={{ body: { padding: '40px' } }}
|
||||||
|
>
|
||||||
|
<div style={{ textAlign: 'center', marginBottom: '32px' }}>
|
||||||
|
<div style={{
|
||||||
|
fontSize: '48px',
|
||||||
|
marginBottom: '16px',
|
||||||
|
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||||
|
WebkitBackgroundClip: 'text',
|
||||||
|
WebkitTextFillColor: 'transparent',
|
||||||
|
backgroundClip: 'text'
|
||||||
|
}}>
|
||||||
|
🚀
|
||||||
|
</div>
|
||||||
|
<Title level={2} style={{ margin: 0, color: '#1a1a1a' }}>
|
||||||
|
远程控制系统
|
||||||
|
</Title>
|
||||||
|
<Text style={{ color: '#666', fontSize: '16px' }}>
|
||||||
|
系统初始化向导
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Steps
|
||||||
|
current={currentStep}
|
||||||
|
style={{ marginBottom: '32px' }}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{steps.map((step, index) => (
|
||||||
|
<Step
|
||||||
|
key={index}
|
||||||
|
title={step.title}
|
||||||
|
description={step.description}
|
||||||
|
icon={step.icon}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Steps>
|
||||||
|
|
||||||
|
{currentStep === 0 && (
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<Alert
|
||||||
|
message="欢迎使用远程控制系统"
|
||||||
|
description={
|
||||||
|
<div>
|
||||||
|
<Paragraph style={{ marginBottom: '16px' }}>
|
||||||
|
这是您首次运行本系统,需要进行初始化设置。
|
||||||
|
</Paragraph>
|
||||||
|
<Paragraph style={{ marginBottom: '16px' }}>
|
||||||
|
请为系统创建一个管理员账号,该账号将用于登录和管理系统。
|
||||||
|
</Paragraph>
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<SafetyOutlined style={{ color: '#52c41a' }} />
|
||||||
|
<Text>安全的密码保护</Text>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<UserOutlined style={{ color: '#1890ff' }} />
|
||||||
|
<Text>个性化用户名</Text>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: '24px', textAlign: 'left' }}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
onClick={() => setCurrentStep(1)}
|
||||||
|
style={{
|
||||||
|
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '8px',
|
||||||
|
height: '48px',
|
||||||
|
fontSize: '16px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
开始设置
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentStep === 1 && (
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
onFinish={handleInstall}
|
||||||
|
size="large"
|
||||||
|
>
|
||||||
|
<Alert
|
||||||
|
message="创建管理员账号"
|
||||||
|
description="请设置您的管理员用户名和密码,建议使用强密码以确保系统安全。"
|
||||||
|
type="warning"
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: '24px' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert
|
||||||
|
message={error}
|
||||||
|
type="error"
|
||||||
|
closable
|
||||||
|
onClose={() => setError(null)}
|
||||||
|
style={{ marginBottom: '16px' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="username"
|
||||||
|
label="管理员用户名"
|
||||||
|
rules={[{ validator: validateUsername }]}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
prefix={<UserOutlined />}
|
||||||
|
placeholder="请输入管理员用户名"
|
||||||
|
style={{ borderRadius: '8px' }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="password"
|
||||||
|
label="密码"
|
||||||
|
rules={[{ validator: validatePassword }]}
|
||||||
|
>
|
||||||
|
<Input.Password
|
||||||
|
prefix={<LockOutlined />}
|
||||||
|
placeholder="请输入密码(至少6个字符)"
|
||||||
|
style={{ borderRadius: '8px' }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="confirmPassword"
|
||||||
|
label="确认密码"
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: '请确认密码' },
|
||||||
|
({ getFieldValue }) => ({
|
||||||
|
validator(_, value) {
|
||||||
|
if (!value || getFieldValue('password') === value) {
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error('两次输入的密码不一致'))
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input.Password
|
||||||
|
prefix={<LockOutlined />}
|
||||||
|
placeholder="请再次输入密码"
|
||||||
|
style={{ borderRadius: '8px' }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Row gutter={16} style={{ marginTop: '32px' }}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Button
|
||||||
|
block
|
||||||
|
size="large"
|
||||||
|
onClick={() => setCurrentStep(0)}
|
||||||
|
style={{ borderRadius: '8px' }}
|
||||||
|
>
|
||||||
|
上一步
|
||||||
|
</Button>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
htmlType="submit"
|
||||||
|
block
|
||||||
|
size="large"
|
||||||
|
loading={loading}
|
||||||
|
style={{
|
||||||
|
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '8px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? '初始化中...' : '完成设置'}
|
||||||
|
</Button>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentStep === 2 && (
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: '64px', marginBottom: '24px' }}>
|
||||||
|
✅
|
||||||
|
</div>
|
||||||
|
<Alert
|
||||||
|
message="初始化完成!"
|
||||||
|
description={
|
||||||
|
<div>
|
||||||
|
<Paragraph style={{ marginBottom: '16px' }}>
|
||||||
|
系统已成功初始化,管理员账号创建完成。
|
||||||
|
</Paragraph>
|
||||||
|
<Paragraph style={{ marginBottom: '16px' }}>
|
||||||
|
即将跳转到登录页面,请使用刚才设置的账号密码登录系统。
|
||||||
|
</Paragraph>
|
||||||
|
{lockFilePath && (
|
||||||
|
<div style={{
|
||||||
|
background: '#f6f6f6',
|
||||||
|
padding: '12px',
|
||||||
|
borderRadius: '6px',
|
||||||
|
marginTop: '16px',
|
||||||
|
textAlign: 'left'
|
||||||
|
}}>
|
||||||
|
<Text strong style={{ color: '#666' }}>💡 重要提示:</Text>
|
||||||
|
<br />
|
||||||
|
<Text style={{ fontSize: '12px', color: '#666' }}>
|
||||||
|
系统已创建初始化锁文件,防止重复初始化。
|
||||||
|
</Text>
|
||||||
|
<br />
|
||||||
|
<Text code style={{ fontSize: '11px', wordBreak: 'break-all' }}>
|
||||||
|
{lockFilePath}
|
||||||
|
</Text>
|
||||||
|
<br />
|
||||||
|
<Text style={{ fontSize: '12px', color: '#666' }}>
|
||||||
|
如需重新初始化,请先删除此文件。
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
type="success"
|
||||||
|
showIcon
|
||||||
|
style={{ textAlign: 'left' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default InstallPage
|
||||||
140
src/components/Layout/Header.tsx
Normal file
140
src/components/Layout/Header.tsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Layout, Button, Badge, Space, Dropdown, Avatar } from 'antd'
|
||||||
|
import {
|
||||||
|
MenuOutlined,
|
||||||
|
WifiOutlined,
|
||||||
|
DisconnectOutlined,
|
||||||
|
SettingOutlined,
|
||||||
|
InfoCircleOutlined,
|
||||||
|
MobileOutlined
|
||||||
|
} from '@ant-design/icons'
|
||||||
|
import { useSelector } from 'react-redux'
|
||||||
|
import type { RootState } from '../../store/store'
|
||||||
|
|
||||||
|
const { Header: AntHeader } = Layout
|
||||||
|
|
||||||
|
interface HeaderProps {
|
||||||
|
onMenuClick: () => void
|
||||||
|
onConnectClick: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 顶部导航栏组件
|
||||||
|
*/
|
||||||
|
const Header: React.FC<HeaderProps> = ({ onMenuClick, onConnectClick }) => {
|
||||||
|
const { status: connectionStatus, serverUrl } = useSelector((state: RootState) => state.connection)
|
||||||
|
const { connectedDevices } = useSelector((state: RootState) => state.devices)
|
||||||
|
|
||||||
|
const getConnectionStatusIcon = () => {
|
||||||
|
switch (connectionStatus) {
|
||||||
|
case 'connected':
|
||||||
|
return <WifiOutlined style={{ color: '#52c41a' }} />
|
||||||
|
case 'connecting':
|
||||||
|
return <WifiOutlined style={{ color: '#faad14' }} />
|
||||||
|
default:
|
||||||
|
return <DisconnectOutlined style={{ color: '#ff4d4f' }} />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getConnectionStatusText = () => {
|
||||||
|
switch (connectionStatus) {
|
||||||
|
case 'connected':
|
||||||
|
return '已连接'
|
||||||
|
case 'connecting':
|
||||||
|
return '连接中'
|
||||||
|
case 'error':
|
||||||
|
return '连接错误'
|
||||||
|
default:
|
||||||
|
return '未连接'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const menuItems = [
|
||||||
|
{
|
||||||
|
key: 'settings',
|
||||||
|
icon: <SettingOutlined />,
|
||||||
|
label: '设置',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'about',
|
||||||
|
icon: <InfoCircleOutlined />,
|
||||||
|
label: '关于',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AntHeader style={{
|
||||||
|
background: '#fff',
|
||||||
|
padding: '0 24px',
|
||||||
|
borderBottom: '1px solid #f0f0f0',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between'
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<MenuOutlined />}
|
||||||
|
onClick={onMenuClick}
|
||||||
|
style={{ marginRight: '16px' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
<MobileOutlined style={{ fontSize: '20px', marginRight: '8px', color: '#1890ff' }} />
|
||||||
|
<span style={{ fontWeight: 'bold', fontSize: '18px' }}>
|
||||||
|
远程控制
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Space size="middle">
|
||||||
|
{/* 设备数量显示 */}
|
||||||
|
<Badge count={connectedDevices.length} showZero>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<MobileOutlined />}
|
||||||
|
style={{ display: 'flex', alignItems: 'center' }}
|
||||||
|
>
|
||||||
|
设备
|
||||||
|
</Button>
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
{/* 连接状态 */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
{getConnectionStatusIcon()}
|
||||||
|
<span style={{ fontSize: '14px' }}>
|
||||||
|
{getConnectionStatusText()}
|
||||||
|
</span>
|
||||||
|
{serverUrl && (
|
||||||
|
<span style={{ fontSize: '12px', color: '#666' }}>
|
||||||
|
({new URL(serverUrl).hostname})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 连接按钮 */}
|
||||||
|
<Button
|
||||||
|
type={connectionStatus === 'connected' ? 'default' : 'primary'}
|
||||||
|
onClick={onConnectClick}
|
||||||
|
disabled={connectionStatus === 'connecting'}
|
||||||
|
>
|
||||||
|
{connectionStatus === 'connected' ? '重新连接' : '连接服务器'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* 菜单下拉 */}
|
||||||
|
<Dropdown
|
||||||
|
menu={{ items: menuItems }}
|
||||||
|
placement="bottomRight"
|
||||||
|
trigger={['click']}
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
icon={<SettingOutlined />}
|
||||||
|
style={{ cursor: 'pointer', backgroundColor: '#1890ff' }}
|
||||||
|
/>
|
||||||
|
</Dropdown>
|
||||||
|
</Space>
|
||||||
|
</AntHeader>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Header
|
||||||
168
src/components/Layout/Sidebar.tsx
Normal file
168
src/components/Layout/Sidebar.tsx
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Layout, Card, List, Badge, Button, Empty } from 'antd'
|
||||||
|
import {
|
||||||
|
MobileOutlined,
|
||||||
|
AndroidOutlined,
|
||||||
|
CheckCircleOutlined,
|
||||||
|
ExclamationCircleOutlined,
|
||||||
|
CloseCircleOutlined
|
||||||
|
} from '@ant-design/icons'
|
||||||
|
import { useSelector, useDispatch } from 'react-redux'
|
||||||
|
import type { RootState, AppDispatch } from '../../store/store'
|
||||||
|
import { selectDevice, selectFilteredDevices } from '../../store/slices/deviceSlice'
|
||||||
|
import { setDeviceInputBlocked } from '../../store/slices/uiSlice'
|
||||||
|
|
||||||
|
const { Sider } = Layout
|
||||||
|
|
||||||
|
interface SidebarProps {
|
||||||
|
collapsed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 侧边栏组件
|
||||||
|
*/
|
||||||
|
const Sidebar: React.FC<SidebarProps> = ({ collapsed }) => {
|
||||||
|
const dispatch = useDispatch<AppDispatch>()
|
||||||
|
const { connectedDevices, selectedDeviceId } = useSelector((state: RootState) => state.devices)
|
||||||
|
const filteredDevices = useSelector((state: RootState) => selectFilteredDevices(state))
|
||||||
|
const { status: connectionStatus } = useSelector((state: RootState) => state.connection)
|
||||||
|
|
||||||
|
const getDeviceStatusIcon = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'online':
|
||||||
|
return <CheckCircleOutlined style={{ color: '#52c41a' }} />
|
||||||
|
case 'busy':
|
||||||
|
return <ExclamationCircleOutlined style={{ color: '#faad14' }} />
|
||||||
|
default:
|
||||||
|
return <CloseCircleOutlined style={{ color: '#ff4d4f' }} />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatLastSeen = (timestamp: number) => {
|
||||||
|
const diff = Date.now() - timestamp
|
||||||
|
if (diff < 60000) return '刚刚'
|
||||||
|
if (diff < 3600000) return `${Math.floor(diff / 60000)}分钟前`
|
||||||
|
if (diff < 86400000) return `${Math.floor(diff / 3600000)}小时前`
|
||||||
|
return `${Math.floor(diff / 86400000)}天前`
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeviceSelect = (deviceId: string) => {
|
||||||
|
dispatch(selectDevice(deviceId))
|
||||||
|
|
||||||
|
// 找到选中的设备并同步输入阻止状态
|
||||||
|
const selectedDevice = connectedDevices.find(d => d.id === deviceId)
|
||||||
|
if (selectedDevice && selectedDevice.inputBlocked !== undefined) {
|
||||||
|
dispatch(setDeviceInputBlocked(selectedDevice.inputBlocked))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (collapsed) {
|
||||||
|
return (
|
||||||
|
<Sider
|
||||||
|
width={80}
|
||||||
|
collapsed={true}
|
||||||
|
style={{
|
||||||
|
background: '#fff',
|
||||||
|
borderRight: '1px solid #f0f0f0'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ padding: '16px 8px' }}>
|
||||||
|
<Badge count={filteredDevices.length} size="small">
|
||||||
|
<MobileOutlined style={{ fontSize: '24px' }} />
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</Sider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sider
|
||||||
|
width={300}
|
||||||
|
style={{
|
||||||
|
background: '#fff',
|
||||||
|
borderRight: '1px solid #f0f0f0'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ padding: '16px' }}>
|
||||||
|
<Card
|
||||||
|
title={
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<MobileOutlined />
|
||||||
|
连接的设备
|
||||||
|
<Badge count={filteredDevices.length} size="small" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
size="small"
|
||||||
|
style={{ height: '100%' }}
|
||||||
|
styles={{ body: { padding: 0, maxHeight: 'calc(100vh - 200px)', overflow: 'auto' } }}
|
||||||
|
>
|
||||||
|
{filteredDevices.length === 0 ? (
|
||||||
|
<Empty
|
||||||
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
|
description="未找到匹配的设备"
|
||||||
|
style={{ padding: '40px 20px' }}
|
||||||
|
>
|
||||||
|
{connectionStatus !== 'connected' && (
|
||||||
|
<Button type="primary" size="small">
|
||||||
|
连接服务器
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Empty>
|
||||||
|
) : (
|
||||||
|
<List
|
||||||
|
dataSource={filteredDevices}
|
||||||
|
renderItem={(device) => (
|
||||||
|
<List.Item
|
||||||
|
style={{
|
||||||
|
padding: '12px 16px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
backgroundColor: selectedDeviceId === device.id ? '#e6f7ff' : 'transparent',
|
||||||
|
borderLeft: selectedDeviceId === device.id ? '3px solid #1890ff' : '3px solid transparent'
|
||||||
|
}}
|
||||||
|
onClick={() => handleDeviceSelect(device.id)}
|
||||||
|
>
|
||||||
|
<List.Item.Meta
|
||||||
|
avatar={
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<AndroidOutlined style={{ fontSize: '32px', color: '#1890ff' }} />
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: -2,
|
||||||
|
right: -2,
|
||||||
|
background: '#fff',
|
||||||
|
borderRadius: '50%',
|
||||||
|
border: '1px solid #f0f0f0'
|
||||||
|
}}>
|
||||||
|
{getDeviceStatusIcon(device.status)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
title={
|
||||||
|
<div style={{
|
||||||
|
fontWeight: selectedDeviceId === device.id ? 'bold' : 'normal',
|
||||||
|
color: selectedDeviceId === device.id ? '#1890ff' : 'inherit'
|
||||||
|
}}>
|
||||||
|
{device.name}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
description={
|
||||||
|
<div style={{ fontSize: '12px', color: '#666' }}>
|
||||||
|
<div>{device.model}</div>
|
||||||
|
<div>Android {device.osVersion}</div>
|
||||||
|
<div>{device.screenWidth}×{device.screenHeight}</div>
|
||||||
|
<div>IP: {device.publicIP || '未知'}</div>
|
||||||
|
<div>{formatLastSeen(device.lastSeen)}</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</Sider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Sidebar
|
||||||
172
src/components/LoginPage.tsx
Normal file
172
src/components/LoginPage.tsx
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
Button,
|
||||||
|
Typography,
|
||||||
|
Alert,
|
||||||
|
Row,
|
||||||
|
Col
|
||||||
|
} from 'antd'
|
||||||
|
import {
|
||||||
|
UserOutlined,
|
||||||
|
LockOutlined,
|
||||||
|
LoginOutlined,
|
||||||
|
MobileOutlined
|
||||||
|
} from '@ant-design/icons'
|
||||||
|
|
||||||
|
const { Title, Text } = Typography
|
||||||
|
|
||||||
|
interface LoginPageProps {
|
||||||
|
onLogin: (username: string, password: string) => Promise<void>
|
||||||
|
loading?: boolean
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录页面组件
|
||||||
|
*/
|
||||||
|
const LoginPage: React.FC<LoginPageProps> = ({ onLogin, loading = false, error }) => {
|
||||||
|
const [form] = Form.useForm()
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
|
||||||
|
const handleSubmit = async (values: { username: string; password: string }) => {
|
||||||
|
try {
|
||||||
|
setIsSubmitting(true)
|
||||||
|
await onLogin(values.username, values.password)
|
||||||
|
} catch (err) {
|
||||||
|
// 错误处理由父组件完成
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLoading = loading || isSubmitting
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
minHeight: '100vh',
|
||||||
|
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '20px'
|
||||||
|
}}>
|
||||||
|
<Row justify="center" style={{ width: '100%', maxWidth: '1200px' }}>
|
||||||
|
<Col xs={24} sm={20} md={16} lg={12} xl={8}>
|
||||||
|
<Card
|
||||||
|
style={{
|
||||||
|
borderRadius: '16px',
|
||||||
|
boxShadow: '0 20px 40px rgba(0,0,0,0.1)',
|
||||||
|
border: 'none',
|
||||||
|
overflow: 'hidden'
|
||||||
|
}}
|
||||||
|
styles={{ body: { padding: '48px 40px' } }}
|
||||||
|
>
|
||||||
|
<div style={{ textAlign: 'center', marginBottom: '40px' }}>
|
||||||
|
{/* Logo和标题 */}
|
||||||
|
<div style={{
|
||||||
|
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||||
|
borderRadius: '50%',
|
||||||
|
width: '80px',
|
||||||
|
height: '80px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
margin: '0 auto 24px'
|
||||||
|
}}>
|
||||||
|
<MobileOutlined style={{ fontSize: '40px', color: 'white' }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Title level={2} style={{ margin: '0 0 8px 0', fontWeight: 600 }}>
|
||||||
|
远程控制中心
|
||||||
|
</Title>
|
||||||
|
<Text style={{ color: '#666', fontSize: '16px' }}>
|
||||||
|
请登录以继续使用
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 错误提示 */}
|
||||||
|
{error && (
|
||||||
|
<Alert
|
||||||
|
message={error}
|
||||||
|
type="error"
|
||||||
|
showIcon
|
||||||
|
style={{
|
||||||
|
marginBottom: '24px',
|
||||||
|
borderRadius: '8px'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 登录表单 */}
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
onFinish={handleSubmit}
|
||||||
|
layout="vertical"
|
||||||
|
requiredMark={false}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
name="username"
|
||||||
|
label={<span style={{ fontWeight: 500 }}>用户名</span>}
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: '请输入用户名' },
|
||||||
|
{ min: 2, message: '用户名至少2个字符' }
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
prefix={<UserOutlined style={{ color: '#ccc' }} />}
|
||||||
|
placeholder="请输入用户名"
|
||||||
|
size="large"
|
||||||
|
style={{ borderRadius: '8px' }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="password"
|
||||||
|
label={<span style={{ fontWeight: 500 }}>密码</span>}
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: '请输入密码' },
|
||||||
|
{ min: 6, message: '密码至少6个字符' }
|
||||||
|
]}
|
||||||
|
style={{ marginBottom: '32px' }}
|
||||||
|
>
|
||||||
|
<Input.Password
|
||||||
|
prefix={<LockOutlined style={{ color: '#ccc' }} />}
|
||||||
|
placeholder="请输入密码"
|
||||||
|
size="large"
|
||||||
|
style={{ borderRadius: '8px' }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item style={{ marginBottom: 0 }}>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
htmlType="submit"
|
||||||
|
size="large"
|
||||||
|
icon={isLoading ? undefined : <LoginOutlined />}
|
||||||
|
loading={isLoading}
|
||||||
|
block
|
||||||
|
style={{
|
||||||
|
height: '48px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontSize: '16px',
|
||||||
|
fontWeight: 500,
|
||||||
|
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||||
|
border: 'none'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isLoading ? '登录中...' : '登录'}
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LoginPage
|
||||||
2362
src/components/RemoteControlApp.tsx
Normal file
2362
src/components/RemoteControlApp.tsx
Normal file
File diff suppressed because it is too large
Load Diff
78
src/index.css
Normal file
78
src/index.css
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-weight: 400;
|
||||||
|
|
||||||
|
color-scheme: light dark;
|
||||||
|
color: rgba(255, 255, 255, 0.87);
|
||||||
|
background-color: #242424;
|
||||||
|
|
||||||
|
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 {
|
||||||
|
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;
|
||||||
|
font-weight: 500;
|
||||||
|
font-family: inherit;
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.25s;
|
||||||
|
}
|
||||||
|
button:hover {
|
||||||
|
border-color: #646cff;
|
||||||
|
}
|
||||||
|
button:focus,
|
||||||
|
button:focus-visible {
|
||||||
|
outline: 4px auto -webkit-focus-ring-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
:root {
|
||||||
|
color: #213547;
|
||||||
|
background-color: #ffffff;
|
||||||
|
}
|
||||||
|
a:hover {
|
||||||
|
color: #747bff;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
}
|
||||||
|
}
|
||||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.tsx'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
226
src/services/apiClient.ts
Normal file
226
src/services/apiClient.ts
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
/**
|
||||||
|
* API客户端服务
|
||||||
|
* 提供统一的HTTP请求接口,自动处理认证token
|
||||||
|
*/
|
||||||
|
class ApiClient {
|
||||||
|
private baseURL: string
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// 判断hostname是否为IP地址
|
||||||
|
const isIPAddress = this.isIPAddress(window.location.hostname)
|
||||||
|
|
||||||
|
if (isIPAddress) {
|
||||||
|
// 如果是IP地址,添加3001端口
|
||||||
|
this.baseURL = `${window.location.protocol}//${window.location.hostname}:3001`
|
||||||
|
} else {
|
||||||
|
// 如果是域名,直接使用域名(通常域名会通过反向代理处理端口)
|
||||||
|
this.baseURL = `${window.location.protocol}//${window.location.hostname}`
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('API BaseURL:', this.baseURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断字符串是否为IP地址
|
||||||
|
*/
|
||||||
|
private isIPAddress(hostname: string): boolean {
|
||||||
|
// IPv4地址正则表达式
|
||||||
|
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/
|
||||||
|
|
||||||
|
// IPv6地址正则表达式(简化版)
|
||||||
|
const ipv6Regex = /^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$/
|
||||||
|
|
||||||
|
return ipv4Regex.test(hostname) || ipv6Regex.test(hostname)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取认证token
|
||||||
|
*/
|
||||||
|
private getAuthToken(): string | null {
|
||||||
|
return localStorage.getItem('auth_token')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建请求headers
|
||||||
|
*/
|
||||||
|
private createHeaders(customHeaders: Record<string, string> = {}): Record<string, string> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...customHeaders
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = this.getAuthToken()
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理响应
|
||||||
|
*/
|
||||||
|
private async handleResponse<T>(response: Response): Promise<T> {
|
||||||
|
if (!response.ok) {
|
||||||
|
// 如果是认证失败,清除本地token
|
||||||
|
if (response.status === 401) {
|
||||||
|
localStorage.removeItem('auth_token')
|
||||||
|
localStorage.removeItem('auth_user')
|
||||||
|
// 可以触发全局的登录状态重置事件
|
||||||
|
window.dispatchEvent(new CustomEvent('auth:token-expired'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorData = await response.json().catch(() => ({}))
|
||||||
|
throw new Error(errorData.message || `HTTP ${response.status}: ${response.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET请求
|
||||||
|
*/
|
||||||
|
async get<T>(endpoint: string, customHeaders?: Record<string, string>): Promise<T> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.baseURL}${endpoint}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: this.createHeaders(customHeaders),
|
||||||
|
})
|
||||||
|
|
||||||
|
return this.handleResponse<T>(response)
|
||||||
|
} catch (error: any) {
|
||||||
|
// 捕获网络错误(如 Failed to fetch)
|
||||||
|
if (error?.name === 'TypeError' && error?.message?.includes('fetch')) {
|
||||||
|
throw new Error(`网络连接失败: 无法连接到服务器 ${this.baseURL}。请检查服务器是否正常运行。`)
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST请求
|
||||||
|
*/
|
||||||
|
async post<T>(
|
||||||
|
endpoint: string,
|
||||||
|
data?: any,
|
||||||
|
customHeaders?: Record<string, string>
|
||||||
|
): Promise<T> {
|
||||||
|
try {
|
||||||
|
const headers = this.createHeaders(customHeaders)
|
||||||
|
|
||||||
|
let body: string | FormData | undefined
|
||||||
|
|
||||||
|
// 如果data是FormData,不设置Content-Type让浏览器自动设置
|
||||||
|
if (data instanceof FormData) {
|
||||||
|
body = data
|
||||||
|
delete headers['Content-Type']
|
||||||
|
} else if (data !== undefined) {
|
||||||
|
body = JSON.stringify(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${this.baseURL}${endpoint}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
})
|
||||||
|
|
||||||
|
return this.handleResponse<T>(response)
|
||||||
|
} catch (error: any) {
|
||||||
|
// 捕获网络错误(如 Failed to fetch)
|
||||||
|
if (error?.name === 'TypeError' && error?.message?.includes('fetch')) {
|
||||||
|
throw new Error(`网络连接失败: 无法连接到服务器 ${this.baseURL}。请检查服务器是否正常运行。`)
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT请求
|
||||||
|
*/
|
||||||
|
async put<T>(
|
||||||
|
endpoint: string,
|
||||||
|
data?: any,
|
||||||
|
customHeaders?: Record<string, string>
|
||||||
|
): Promise<T> {
|
||||||
|
const response = await fetch(`${this.baseURL}${endpoint}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: this.createHeaders(customHeaders),
|
||||||
|
body: data ? JSON.stringify(data) : undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
return this.handleResponse<T>(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE请求
|
||||||
|
*/
|
||||||
|
async delete<T>(endpoint: string, customHeaders?: Record<string, string>): Promise<T> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.baseURL}${endpoint}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: this.createHeaders(customHeaders),
|
||||||
|
})
|
||||||
|
|
||||||
|
return this.handleResponse<T>(response)
|
||||||
|
} catch (error: any) {
|
||||||
|
// 捕获网络错误(如 Failed to fetch)
|
||||||
|
if (error?.name === 'TypeError' && error?.message?.includes('fetch')) {
|
||||||
|
throw new Error(`网络连接失败: 无法连接到服务器 ${this.baseURL}。请检查服务器是否正常运行。`)
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传文件的POST请求
|
||||||
|
*/
|
||||||
|
async postFormData<T>(
|
||||||
|
endpoint: string,
|
||||||
|
formData: FormData,
|
||||||
|
customHeaders?: Record<string, string>
|
||||||
|
): Promise<T> {
|
||||||
|
return this.post<T>(endpoint, formData, customHeaders)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下载文件
|
||||||
|
*/
|
||||||
|
async downloadFile(endpoint: string, filename?: string): Promise<void> {
|
||||||
|
const response = await fetch(`${this.baseURL}${endpoint}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: this.createHeaders(),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`下载失败: ${response.status} ${response.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob()
|
||||||
|
const url = window.URL.createObjectURL(blob)
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
link.download = filename || 'download'
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
document.body.removeChild(link)
|
||||||
|
window.URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取base URL
|
||||||
|
*/
|
||||||
|
getBaseURL(): string {
|
||||||
|
return this.baseURL
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置base URL
|
||||||
|
*/
|
||||||
|
setBaseURL(url: string): void {
|
||||||
|
this.baseURL = url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建单例实例
|
||||||
|
export const apiClient = new ApiClient()
|
||||||
|
export default apiClient
|
||||||
348
src/store/slices/authSlice.ts
Normal file
348
src/store/slices/authSlice.ts
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
import { createSlice, createAsyncThunk, type PayloadAction } from '@reduxjs/toolkit'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户信息接口
|
||||||
|
*/
|
||||||
|
export interface User {
|
||||||
|
id: string
|
||||||
|
username: string
|
||||||
|
lastLoginAt?: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 认证状态接口
|
||||||
|
*/
|
||||||
|
interface AuthState {
|
||||||
|
isAuthenticated: boolean
|
||||||
|
token: string | null
|
||||||
|
user: User | null
|
||||||
|
loading: boolean
|
||||||
|
error: string | null
|
||||||
|
loginAttempts: number
|
||||||
|
lastLoginAttempt: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录请求参数接口
|
||||||
|
*/
|
||||||
|
interface LoginRequest {
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录响应接口
|
||||||
|
*/
|
||||||
|
interface LoginResponse {
|
||||||
|
success: boolean
|
||||||
|
message?: string
|
||||||
|
token?: string
|
||||||
|
user?: User
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Token验证响应接口
|
||||||
|
*/
|
||||||
|
interface VerifyTokenResponse {
|
||||||
|
valid: boolean
|
||||||
|
user?: User
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: AuthState = {
|
||||||
|
isAuthenticated: false,
|
||||||
|
token: null,
|
||||||
|
user: null,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
loginAttempts: 0,
|
||||||
|
lastLoginAttempt: null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断字符串是否为IP地址
|
||||||
|
*/
|
||||||
|
const isIPAddress = (hostname: string): boolean => {
|
||||||
|
// IPv4地址正则表达式
|
||||||
|
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/
|
||||||
|
|
||||||
|
// IPv6地址正则表达式(简化版)
|
||||||
|
const ipv6Regex = /^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$/
|
||||||
|
|
||||||
|
return ipv4Regex.test(hostname) || ipv6Regex.test(hostname)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取服务器URL
|
||||||
|
*/
|
||||||
|
const getServerUrl = (): string => {
|
||||||
|
// 判断hostname是否为IP地址
|
||||||
|
const isIP = isIPAddress(window.location.hostname)
|
||||||
|
|
||||||
|
if (isIP) {
|
||||||
|
// 如果是IP地址,添加3001端口
|
||||||
|
return `${window.location.protocol}//${window.location.hostname}:3001`
|
||||||
|
} else {
|
||||||
|
// 如果是域名,直接使用域名(通常域名会通过反向代理处理端口)
|
||||||
|
return `${window.location.protocol}//${window.location.hostname}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 异步登录操作
|
||||||
|
*/
|
||||||
|
export const login = createAsyncThunk<
|
||||||
|
LoginResponse,
|
||||||
|
LoginRequest,
|
||||||
|
{ rejectValue: string }
|
||||||
|
>('auth/login', async ({ username, password }, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const serverUrl = getServerUrl()
|
||||||
|
const response = await fetch(`${serverUrl}/api/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return rejectWithValue(data.message || '登录失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('登录请求失败:', error)
|
||||||
|
return rejectWithValue(error.message || '网络连接失败')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 异步验证token操作
|
||||||
|
*/
|
||||||
|
export const verifyToken = createAsyncThunk<
|
||||||
|
VerifyTokenResponse,
|
||||||
|
string,
|
||||||
|
{ rejectValue: string }
|
||||||
|
>('auth/verifyToken', async (token, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const serverUrl = getServerUrl()
|
||||||
|
const response = await fetch(`${serverUrl}/api/auth/verify`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return rejectWithValue(data.error || 'Token验证失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Token验证失败:', error)
|
||||||
|
return rejectWithValue(error.message || '网络连接失败')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 异步登出操作
|
||||||
|
*/
|
||||||
|
export const logout = createAsyncThunk<
|
||||||
|
void,
|
||||||
|
void,
|
||||||
|
{ rejectValue: string }
|
||||||
|
>('auth/logout', async () => {
|
||||||
|
try {
|
||||||
|
const serverUrl = getServerUrl()
|
||||||
|
const token = localStorage.getItem('auth_token')
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
await fetch(`${serverUrl}/api/auth/logout`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('登出请求失败:', error)
|
||||||
|
// 即使服务器登出失败,也继续清除本地状态
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 认证管理 Slice
|
||||||
|
*/
|
||||||
|
const authSlice = createSlice({
|
||||||
|
name: 'auth',
|
||||||
|
initialState,
|
||||||
|
reducers: {
|
||||||
|
// 清除错误
|
||||||
|
clearError: (state) => {
|
||||||
|
state.error = null
|
||||||
|
},
|
||||||
|
|
||||||
|
// 重置登录尝试次数
|
||||||
|
resetLoginAttempts: (state) => {
|
||||||
|
state.loginAttempts = 0
|
||||||
|
state.lastLoginAttempt = null
|
||||||
|
},
|
||||||
|
|
||||||
|
// 从本地存储恢复认证状态
|
||||||
|
restoreAuthState: (state) => {
|
||||||
|
const token = localStorage.getItem('auth_token')
|
||||||
|
const userStr = localStorage.getItem('auth_user')
|
||||||
|
|
||||||
|
if (token && userStr) {
|
||||||
|
try {
|
||||||
|
const user = JSON.parse(userStr)
|
||||||
|
state.token = token
|
||||||
|
state.user = user
|
||||||
|
state.isAuthenticated = true
|
||||||
|
} catch (error) {
|
||||||
|
console.error('恢复认证状态失败:', error)
|
||||||
|
localStorage.removeItem('auth_token')
|
||||||
|
localStorage.removeItem('auth_user')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 清除认证状态
|
||||||
|
clearAuthState: (state) => {
|
||||||
|
state.isAuthenticated = false
|
||||||
|
state.token = null
|
||||||
|
state.user = null
|
||||||
|
state.error = null
|
||||||
|
localStorage.removeItem('auth_token')
|
||||||
|
localStorage.removeItem('auth_user')
|
||||||
|
},
|
||||||
|
|
||||||
|
// 更新用户信息
|
||||||
|
updateUser: (state, action: PayloadAction<Partial<User>>) => {
|
||||||
|
if (state.user) {
|
||||||
|
state.user = { ...state.user, ...action.payload }
|
||||||
|
localStorage.setItem('auth_user', JSON.stringify(state.user))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
extraReducers: (builder) => {
|
||||||
|
// 登录
|
||||||
|
builder
|
||||||
|
.addCase(login.pending, (state) => {
|
||||||
|
state.loading = true
|
||||||
|
state.error = null
|
||||||
|
})
|
||||||
|
.addCase(login.fulfilled, (state, action) => {
|
||||||
|
state.loading = false
|
||||||
|
state.error = null
|
||||||
|
state.loginAttempts = 0
|
||||||
|
state.lastLoginAttempt = null
|
||||||
|
|
||||||
|
if (action.payload.success && action.payload.token && action.payload.user) {
|
||||||
|
state.isAuthenticated = true
|
||||||
|
state.token = action.payload.token
|
||||||
|
state.user = action.payload.user
|
||||||
|
|
||||||
|
// 保存到本地存储
|
||||||
|
localStorage.setItem('auth_token', action.payload.token)
|
||||||
|
localStorage.setItem('auth_user', JSON.stringify(action.payload.user))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.addCase(login.rejected, (state, action) => {
|
||||||
|
state.loading = false
|
||||||
|
state.error = action.payload || '登录失败'
|
||||||
|
state.loginAttempts += 1
|
||||||
|
state.lastLoginAttempt = Date.now()
|
||||||
|
state.isAuthenticated = false
|
||||||
|
state.token = null
|
||||||
|
state.user = null
|
||||||
|
})
|
||||||
|
|
||||||
|
// Token验证
|
||||||
|
builder
|
||||||
|
.addCase(verifyToken.pending, (state) => {
|
||||||
|
state.loading = true
|
||||||
|
state.error = null
|
||||||
|
})
|
||||||
|
.addCase(verifyToken.fulfilled, (state, action) => {
|
||||||
|
state.loading = false
|
||||||
|
state.error = null
|
||||||
|
|
||||||
|
if (action.payload.valid && action.payload.user) {
|
||||||
|
state.isAuthenticated = true
|
||||||
|
state.user = action.payload.user
|
||||||
|
// token已经在state中,不需要重新设置
|
||||||
|
} else {
|
||||||
|
// Token无效,清除状态
|
||||||
|
state.isAuthenticated = false
|
||||||
|
state.token = null
|
||||||
|
state.user = null
|
||||||
|
localStorage.removeItem('auth_token')
|
||||||
|
localStorage.removeItem('auth_user')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.addCase(verifyToken.rejected, (state, action) => {
|
||||||
|
state.loading = false
|
||||||
|
state.error = action.payload || 'Token验证失败'
|
||||||
|
state.isAuthenticated = false
|
||||||
|
state.token = null
|
||||||
|
state.user = null
|
||||||
|
localStorage.removeItem('auth_token')
|
||||||
|
localStorage.removeItem('auth_user')
|
||||||
|
})
|
||||||
|
|
||||||
|
// 登出
|
||||||
|
builder
|
||||||
|
.addCase(logout.pending, (state) => {
|
||||||
|
state.loading = true
|
||||||
|
})
|
||||||
|
.addCase(logout.fulfilled, (state) => {
|
||||||
|
state.loading = false
|
||||||
|
state.isAuthenticated = false
|
||||||
|
state.token = null
|
||||||
|
state.user = null
|
||||||
|
state.error = null
|
||||||
|
localStorage.removeItem('auth_token')
|
||||||
|
localStorage.removeItem('auth_user')
|
||||||
|
})
|
||||||
|
.addCase(logout.rejected, (state) => {
|
||||||
|
state.loading = false
|
||||||
|
// 即使登出失败,也清除本地状态
|
||||||
|
state.isAuthenticated = false
|
||||||
|
state.token = null
|
||||||
|
state.user = null
|
||||||
|
state.error = null
|
||||||
|
localStorage.removeItem('auth_token')
|
||||||
|
localStorage.removeItem('auth_user')
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const {
|
||||||
|
clearError,
|
||||||
|
resetLoginAttempts,
|
||||||
|
restoreAuthState,
|
||||||
|
clearAuthState,
|
||||||
|
updateUser
|
||||||
|
} = authSlice.actions
|
||||||
|
|
||||||
|
export default authSlice.reducer
|
||||||
|
|
||||||
|
// 选择器(先定义接口,避免循环导入)
|
||||||
|
interface RootStateForAuth {
|
||||||
|
auth: AuthState
|
||||||
|
}
|
||||||
|
|
||||||
|
export const selectAuth = (state: RootStateForAuth) => state.auth
|
||||||
|
export const selectIsAuthenticated = (state: RootStateForAuth) => state.auth.isAuthenticated
|
||||||
|
export const selectUser = (state: RootStateForAuth) => state.auth.user
|
||||||
|
export const selectToken = (state: RootStateForAuth) => state.auth.token
|
||||||
|
export const selectAuthLoading = (state: RootStateForAuth) => state.auth.loading
|
||||||
|
export const selectAuthError = (state: RootStateForAuth) => state.auth.error
|
||||||
140
src/store/slices/connectionSlice.ts
Normal file
140
src/store/slices/connectionSlice.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import { createSlice, type PayloadAction } from '@reduxjs/toolkit'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 连接状态
|
||||||
|
*/
|
||||||
|
export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 网络质量
|
||||||
|
*/
|
||||||
|
export interface NetworkQuality {
|
||||||
|
latency: number
|
||||||
|
bandwidth: number
|
||||||
|
packetLoss: number
|
||||||
|
quality: 'excellent' | 'good' | 'fair' | 'poor'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 连接状态管理
|
||||||
|
*/
|
||||||
|
interface ConnectionState {
|
||||||
|
status: ConnectionStatus
|
||||||
|
webSocket: any | null
|
||||||
|
serverUrl: string
|
||||||
|
isReconnecting: boolean
|
||||||
|
reconnectAttempts: number
|
||||||
|
maxReconnectAttempts: number
|
||||||
|
networkQuality: NetworkQuality | null
|
||||||
|
lastConnectedAt: number | null
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: ConnectionState = {
|
||||||
|
status: 'disconnected',
|
||||||
|
webSocket: null,
|
||||||
|
serverUrl: '',
|
||||||
|
isReconnecting: false,
|
||||||
|
reconnectAttempts: 0,
|
||||||
|
maxReconnectAttempts: 5,
|
||||||
|
networkQuality: null,
|
||||||
|
lastConnectedAt: null,
|
||||||
|
error: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 连接管理 Slice
|
||||||
|
*/
|
||||||
|
const connectionSlice = createSlice({
|
||||||
|
name: 'connection',
|
||||||
|
initialState,
|
||||||
|
reducers: {
|
||||||
|
// 设置连接状态
|
||||||
|
setConnectionStatus: (state, action: PayloadAction<ConnectionStatus>) => {
|
||||||
|
state.status = action.payload
|
||||||
|
|
||||||
|
if (action.payload === 'connected') {
|
||||||
|
state.lastConnectedAt = Date.now()
|
||||||
|
state.reconnectAttempts = 0
|
||||||
|
state.isReconnecting = false
|
||||||
|
state.error = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 设置WebSocket实例
|
||||||
|
setWebSocket: (state, action: PayloadAction<any | null>) => {
|
||||||
|
state.webSocket = action.payload
|
||||||
|
},
|
||||||
|
|
||||||
|
// 设置服务器URL
|
||||||
|
setServerUrl: (state, action: PayloadAction<string>) => {
|
||||||
|
state.serverUrl = action.payload
|
||||||
|
},
|
||||||
|
|
||||||
|
// 开始重连
|
||||||
|
startReconnecting: (state) => {
|
||||||
|
state.isReconnecting = true
|
||||||
|
state.status = 'connecting'
|
||||||
|
},
|
||||||
|
|
||||||
|
// 停止重连
|
||||||
|
stopReconnecting: (state) => {
|
||||||
|
state.isReconnecting = false
|
||||||
|
state.reconnectAttempts = 0
|
||||||
|
},
|
||||||
|
|
||||||
|
// 增加重连次数
|
||||||
|
incrementReconnectAttempts: (state) => {
|
||||||
|
state.reconnectAttempts += 1
|
||||||
|
},
|
||||||
|
|
||||||
|
// 重置重连次数
|
||||||
|
resetReconnectAttempts: (state) => {
|
||||||
|
state.reconnectAttempts = 0
|
||||||
|
},
|
||||||
|
|
||||||
|
// 更新网络质量
|
||||||
|
updateNetworkQuality: (state, action: PayloadAction<NetworkQuality>) => {
|
||||||
|
state.networkQuality = action.payload
|
||||||
|
},
|
||||||
|
|
||||||
|
// 设置连接错误
|
||||||
|
setConnectionError: (state, action: PayloadAction<string>) => {
|
||||||
|
state.error = action.payload
|
||||||
|
state.status = 'error'
|
||||||
|
state.isReconnecting = false
|
||||||
|
},
|
||||||
|
|
||||||
|
// 清除连接错误
|
||||||
|
clearConnectionError: (state) => {
|
||||||
|
state.error = null
|
||||||
|
},
|
||||||
|
|
||||||
|
// 重置连接状态
|
||||||
|
resetConnection: (state) => {
|
||||||
|
state.status = 'disconnected'
|
||||||
|
state.webSocket = null
|
||||||
|
state.isReconnecting = false
|
||||||
|
state.reconnectAttempts = 0
|
||||||
|
state.networkQuality = null
|
||||||
|
state.lastConnectedAt = null
|
||||||
|
state.error = null
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const {
|
||||||
|
setConnectionStatus,
|
||||||
|
setWebSocket,
|
||||||
|
setServerUrl,
|
||||||
|
startReconnecting,
|
||||||
|
stopReconnecting,
|
||||||
|
incrementReconnectAttempts,
|
||||||
|
resetReconnectAttempts,
|
||||||
|
updateNetworkQuality,
|
||||||
|
setConnectionError,
|
||||||
|
clearConnectionError,
|
||||||
|
resetConnection,
|
||||||
|
} = connectionSlice.actions
|
||||||
|
|
||||||
|
export default connectionSlice.reducer
|
||||||
385
src/store/slices/deviceSlice.ts
Normal file
385
src/store/slices/deviceSlice.ts
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
import { createSlice, type PayloadAction } from '@reduxjs/toolkit'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 屏幕阅读器配置
|
||||||
|
*/
|
||||||
|
export interface DeviceScreenReaderConfig {
|
||||||
|
enabled: boolean
|
||||||
|
autoRefresh: boolean
|
||||||
|
refreshInterval: number // 秒
|
||||||
|
showElementBounds: boolean
|
||||||
|
highlightClickable: boolean
|
||||||
|
showVirtualKeyboard: boolean // 显示虚拟按键
|
||||||
|
hierarchyData?: any // UI层次结构数据
|
||||||
|
loading: boolean
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设备信息接口
|
||||||
|
*/
|
||||||
|
export interface Device {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
model: string
|
||||||
|
osVersion: string
|
||||||
|
screenWidth: number
|
||||||
|
screenHeight: number
|
||||||
|
status: 'online' | 'offline' | 'connecting'
|
||||||
|
lastSeen: number
|
||||||
|
inputBlocked?: boolean
|
||||||
|
screenReader?: DeviceScreenReaderConfig
|
||||||
|
publicIP?: string
|
||||||
|
// 🆕 新增系统版本信息字段
|
||||||
|
systemVersionName?: string // 如"Android 11"、"Android 12"
|
||||||
|
romType?: string // 如"MIUI"、"ColorOS"、"原生Android"
|
||||||
|
romVersion?: string // 如"MIUI 12.5"、"ColorOS 11.1"
|
||||||
|
osBuildVersion?: string // 如"1.0.19.0.UMCCNXM"等完整构建版本号
|
||||||
|
// 🆕 新增APP和锁屏状态字段
|
||||||
|
appName?: string // 当前运行的APP名称
|
||||||
|
appVersion?: string // 当前运行的APP版本
|
||||||
|
appPackage?: string // 当前运行的APP包名
|
||||||
|
isLocked?: boolean // 设备锁屏状态
|
||||||
|
// 🆕 安装时间(连接时间)
|
||||||
|
connectedAt?: number // 安装时间/首次连接时间(毫秒时间戳)
|
||||||
|
// 🆕 备注字段
|
||||||
|
remark?: string // 设备备注
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设备状态接口
|
||||||
|
*/
|
||||||
|
export interface DeviceStatus {
|
||||||
|
cpu: number
|
||||||
|
memory: number
|
||||||
|
battery: number
|
||||||
|
networkSpeed: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设备筛选条件接口
|
||||||
|
*/
|
||||||
|
export interface DeviceFilter {
|
||||||
|
model?: string // 型号筛选
|
||||||
|
osVersion?: string // 系统版本筛选
|
||||||
|
appName?: string // APP名称筛选
|
||||||
|
isLocked?: boolean // 锁屏状态筛选
|
||||||
|
status?: 'online' | 'offline' | 'connecting' // 在线状态筛选
|
||||||
|
connectedAtRange?: { // 安装时间范围筛选
|
||||||
|
start?: number
|
||||||
|
end?: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设备状态管理
|
||||||
|
*/
|
||||||
|
interface DevicesState {
|
||||||
|
connectedDevices: Device[]
|
||||||
|
selectedDeviceId: string | null
|
||||||
|
deviceStatuses: Record<string, DeviceStatus>
|
||||||
|
isLoading: boolean
|
||||||
|
error: string | null
|
||||||
|
filter: DeviceFilter // 筛选条件
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: DevicesState = {
|
||||||
|
connectedDevices: [],
|
||||||
|
selectedDeviceId: null,
|
||||||
|
deviceStatuses: {},
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
filter: { }, // 默认只显示在线设备
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设备管理 Slice
|
||||||
|
*/
|
||||||
|
const deviceSlice = createSlice({
|
||||||
|
name: 'devices',
|
||||||
|
initialState,
|
||||||
|
reducers: {
|
||||||
|
// 添加设备
|
||||||
|
addDevice: (state, action: PayloadAction<Device>) => {
|
||||||
|
const existingIndex = state.connectedDevices.findIndex(
|
||||||
|
device => device.id === action.payload.id
|
||||||
|
)
|
||||||
|
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
// 更新现有设备
|
||||||
|
state.connectedDevices[existingIndex] = action.payload
|
||||||
|
} else {
|
||||||
|
// 添加新设备
|
||||||
|
state.connectedDevices.push(action.payload)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 移除设备
|
||||||
|
removeDevice: (state, action: PayloadAction<string>) => {
|
||||||
|
state.connectedDevices = state.connectedDevices.filter(
|
||||||
|
device => device.id !== action.payload
|
||||||
|
)
|
||||||
|
|
||||||
|
// 如果移除的是当前选中的设备,清除选择
|
||||||
|
if (state.selectedDeviceId === action.payload) {
|
||||||
|
state.selectedDeviceId = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除设备状态
|
||||||
|
delete state.deviceStatuses[action.payload]
|
||||||
|
},
|
||||||
|
|
||||||
|
// 选择设备
|
||||||
|
selectDevice: (state, action: PayloadAction<string>) => {
|
||||||
|
state.selectedDeviceId = action.payload
|
||||||
|
},
|
||||||
|
|
||||||
|
// 重置设备相关状态(当设备连接或重新连接时调用)
|
||||||
|
resetDeviceStates: (_state, _action: PayloadAction<string>) => {
|
||||||
|
// 这个action会在其他地方被监听,用于重置UI状态
|
||||||
|
},
|
||||||
|
|
||||||
|
// 清除设备选择
|
||||||
|
clearDeviceSelection: (state) => {
|
||||||
|
state.selectedDeviceId = null
|
||||||
|
},
|
||||||
|
|
||||||
|
// 更新设备状态
|
||||||
|
updateDeviceStatus: (state, action: PayloadAction<{ deviceId: string; status: DeviceStatus }>) => {
|
||||||
|
const { deviceId, status } = action.payload
|
||||||
|
state.deviceStatuses[deviceId] = status
|
||||||
|
},
|
||||||
|
|
||||||
|
// 更新设备连接状态
|
||||||
|
updateDeviceConnectionStatus: (state, action: PayloadAction<{ deviceId: string; status: Device['status'] }>) => {
|
||||||
|
const { deviceId, status } = action.payload
|
||||||
|
const device = state.connectedDevices.find(d => d.id === deviceId)
|
||||||
|
|
||||||
|
if (device) {
|
||||||
|
device.status = status
|
||||||
|
device.lastSeen = Date.now()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 更新设备输入阻止状态
|
||||||
|
updateDeviceInputBlocked: (state, action: PayloadAction<{ deviceId: string; inputBlocked: boolean }>) => {
|
||||||
|
const { deviceId, inputBlocked } = action.payload
|
||||||
|
const device = state.connectedDevices.find(d => d.id === deviceId)
|
||||||
|
|
||||||
|
if (device) {
|
||||||
|
device.inputBlocked = inputBlocked
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 启用设备屏幕阅读器
|
||||||
|
enableDeviceScreenReader: (state, action: PayloadAction<string>) => {
|
||||||
|
const deviceId = action.payload
|
||||||
|
const device = state.connectedDevices.find(d => d.id === deviceId)
|
||||||
|
|
||||||
|
if (device) {
|
||||||
|
if (!device.screenReader) {
|
||||||
|
device.screenReader = {
|
||||||
|
enabled: false,
|
||||||
|
autoRefresh: true,
|
||||||
|
refreshInterval: 1,
|
||||||
|
showElementBounds: true,
|
||||||
|
highlightClickable: true,
|
||||||
|
showVirtualKeyboard: false,
|
||||||
|
loading: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
device.screenReader.enabled = true
|
||||||
|
device.screenReader.loading = true
|
||||||
|
device.screenReader.error = undefined
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 禁用设备屏幕阅读器
|
||||||
|
disableDeviceScreenReader: (state, action: PayloadAction<string>) => {
|
||||||
|
const deviceId = action.payload
|
||||||
|
const device = state.connectedDevices.find(d => d.id === deviceId)
|
||||||
|
|
||||||
|
if (device && device.screenReader) {
|
||||||
|
device.screenReader.enabled = false
|
||||||
|
device.screenReader.loading = false
|
||||||
|
device.screenReader.hierarchyData = undefined
|
||||||
|
device.screenReader.error = undefined
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 更新设备屏幕阅读器配置
|
||||||
|
updateDeviceScreenReaderConfig: (state, action: PayloadAction<{ deviceId: string; config: Partial<DeviceScreenReaderConfig> }>) => {
|
||||||
|
const { deviceId, config } = action.payload
|
||||||
|
const device = state.connectedDevices.find(d => d.id === deviceId)
|
||||||
|
|
||||||
|
if (device) {
|
||||||
|
if (!device.screenReader) {
|
||||||
|
device.screenReader = {
|
||||||
|
enabled: false,
|
||||||
|
autoRefresh: true,
|
||||||
|
refreshInterval: 1,
|
||||||
|
showElementBounds: true,
|
||||||
|
highlightClickable: true,
|
||||||
|
showVirtualKeyboard: false,
|
||||||
|
loading: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (device.screenReader) {
|
||||||
|
Object.assign(device.screenReader, config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 更新设备锁屏状态
|
||||||
|
updateDeviceLockStatus: (state, action: PayloadAction<{ deviceId: string; isLocked: boolean }>) => {
|
||||||
|
const { deviceId, isLocked } = action.payload
|
||||||
|
const device = state.connectedDevices.find(d => d.id === deviceId)
|
||||||
|
|
||||||
|
if (device) {
|
||||||
|
device.isLocked = isLocked
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 更新设备备注
|
||||||
|
updateDeviceRemark: (state, action: PayloadAction<{ deviceId: string; remark: string }>) => {
|
||||||
|
const { deviceId, remark } = action.payload
|
||||||
|
const device = state.connectedDevices.find(d => d.id === deviceId)
|
||||||
|
|
||||||
|
if (device) {
|
||||||
|
device.remark = remark
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 设置设备屏幕阅读器层次结构数据
|
||||||
|
setDeviceScreenReaderHierarchy: (state, action: PayloadAction<{ deviceId: string; hierarchyData: any; error?: string }>) => {
|
||||||
|
const { deviceId, hierarchyData, error } = action.payload
|
||||||
|
const device = state.connectedDevices.find(d => d.id === deviceId)
|
||||||
|
|
||||||
|
if (device && device.screenReader) {
|
||||||
|
device.screenReader.hierarchyData = hierarchyData
|
||||||
|
device.screenReader.loading = false
|
||||||
|
device.screenReader.error = error
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 设置加载状态
|
||||||
|
setLoading: (state, action: PayloadAction<boolean>) => {
|
||||||
|
state.isLoading = action.payload
|
||||||
|
},
|
||||||
|
|
||||||
|
// 设置错误信息
|
||||||
|
setError: (state, action: PayloadAction<string | null>) => {
|
||||||
|
state.error = action.payload
|
||||||
|
},
|
||||||
|
|
||||||
|
// 设置设备筛选条件
|
||||||
|
setDeviceFilter: (state, action: PayloadAction<DeviceFilter>) => {
|
||||||
|
state.filter = action.payload
|
||||||
|
},
|
||||||
|
|
||||||
|
// 清除设备筛选条件(清除后恢复默认只显示在线设备)
|
||||||
|
clearDeviceFilter: (state) => {
|
||||||
|
state.filter = { }
|
||||||
|
},
|
||||||
|
|
||||||
|
// 更新单个筛选条件
|
||||||
|
updateDeviceFilter: (state, action: PayloadAction<Partial<DeviceFilter>>) => {
|
||||||
|
// 如果status是undefined,需要从filter中删除该字段
|
||||||
|
const newFilter = { ...state.filter, ...action.payload }
|
||||||
|
if (action.payload.status === undefined && 'status' in newFilter) {
|
||||||
|
delete newFilter.status
|
||||||
|
}
|
||||||
|
state.filter = newFilter
|
||||||
|
},
|
||||||
|
|
||||||
|
// 清除所有设备
|
||||||
|
clearDevices: (state) => {
|
||||||
|
state.connectedDevices = []
|
||||||
|
state.selectedDeviceId = null
|
||||||
|
state.deviceStatuses = {}
|
||||||
|
state.filter = { } // 恢复默认只显示在线设备
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const {
|
||||||
|
addDevice,
|
||||||
|
removeDevice,
|
||||||
|
selectDevice,
|
||||||
|
resetDeviceStates,
|
||||||
|
clearDeviceSelection,
|
||||||
|
updateDeviceStatus,
|
||||||
|
updateDeviceConnectionStatus,
|
||||||
|
updateDeviceInputBlocked,
|
||||||
|
enableDeviceScreenReader,
|
||||||
|
disableDeviceScreenReader,
|
||||||
|
updateDeviceScreenReaderConfig,
|
||||||
|
updateDeviceLockStatus,
|
||||||
|
updateDeviceRemark,
|
||||||
|
setDeviceScreenReaderHierarchy,
|
||||||
|
setLoading,
|
||||||
|
setError,
|
||||||
|
setDeviceFilter,
|
||||||
|
clearDeviceFilter,
|
||||||
|
updateDeviceFilter,
|
||||||
|
clearDevices,
|
||||||
|
} = deviceSlice.actions
|
||||||
|
|
||||||
|
export default deviceSlice.reducer
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 筛选设备列表的selector
|
||||||
|
*/
|
||||||
|
export const selectFilteredDevices = (state: { devices: DevicesState }) => {
|
||||||
|
const { connectedDevices, filter } = state.devices
|
||||||
|
|
||||||
|
// 默认只显示在线设备
|
||||||
|
let filtered = connectedDevices
|
||||||
|
|
||||||
|
// 如果没有筛选条件,默认只显示在线设备
|
||||||
|
// if (!filter || Object.keys(filter).length === 0) {
|
||||||
|
// return filtered.filter(device => device.status === 'online')
|
||||||
|
// }
|
||||||
|
|
||||||
|
return filtered.filter(device => {
|
||||||
|
// 型号筛选
|
||||||
|
if (filter.model && !device.model?.toLowerCase().includes(filter.model.toLowerCase())) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 系统版本筛选
|
||||||
|
if (filter.osVersion && !device.osVersion?.toLowerCase().includes(filter.osVersion.toLowerCase())) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// APP名称筛选
|
||||||
|
if (filter.appName && !device.appName?.toLowerCase().includes(filter.appName.toLowerCase())) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 锁屏状态筛选
|
||||||
|
if (filter.isLocked !== undefined && device.isLocked !== filter.isLocked) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 在线状态筛选(如果用户选择了状态筛选,则按选择筛选;否则显示全部)
|
||||||
|
if (filter.status && device.status !== filter.status) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 安装时间范围筛选
|
||||||
|
if (filter.connectedAtRange) {
|
||||||
|
const { start, end } = filter.connectedAtRange
|
||||||
|
const connectedAt = device.connectedAt || device.lastSeen
|
||||||
|
|
||||||
|
if (start && connectedAt < start) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (end && connectedAt > end) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
392
src/store/slices/uiSlice.ts
Normal file
392
src/store/slices/uiSlice.ts
Normal file
@@ -0,0 +1,392 @@
|
|||||||
|
import { createSlice, type PayloadAction } from '@reduxjs/toolkit'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主题模式
|
||||||
|
*/
|
||||||
|
export type ThemeMode = 'light' | 'dark' | 'auto'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 布局模式
|
||||||
|
*/
|
||||||
|
export type LayoutMode = 'desktop' | 'tablet' | 'mobile'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 控制面板配置
|
||||||
|
*/
|
||||||
|
export interface ControlPanelConfig {
|
||||||
|
showKeyboard: boolean
|
||||||
|
showGamepad: boolean
|
||||||
|
showQuickActions: boolean
|
||||||
|
showDeviceInfo: boolean
|
||||||
|
position: 'left' | 'right' | 'bottom'
|
||||||
|
collapsed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 屏幕显示配置
|
||||||
|
*/
|
||||||
|
export interface ScreenDisplayConfig {
|
||||||
|
fitMode: 'fit' | 'fill' | 'stretch' | 'original'
|
||||||
|
quality: 'low' | 'medium' | 'high' | 'ultra'
|
||||||
|
showTouchIndicator: boolean
|
||||||
|
enableSound: boolean
|
||||||
|
fullscreen: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 屏幕阅读器配置
|
||||||
|
*/
|
||||||
|
export interface ScreenReaderConfig {
|
||||||
|
enabled: boolean
|
||||||
|
autoRefresh: boolean
|
||||||
|
refreshInterval: number // 秒
|
||||||
|
showElementBounds: boolean
|
||||||
|
showElementHierarchy: boolean
|
||||||
|
highlightClickable: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 相册图片信息
|
||||||
|
*/
|
||||||
|
export interface GalleryImage {
|
||||||
|
id: string
|
||||||
|
deviceId: string
|
||||||
|
index: number
|
||||||
|
displayName: string
|
||||||
|
dateAdded: number
|
||||||
|
mimeType: string
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
size: number
|
||||||
|
contentUri: string
|
||||||
|
timestamp: number
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 相册状态
|
||||||
|
*/
|
||||||
|
export interface GalleryState {
|
||||||
|
images: GalleryImage[]
|
||||||
|
loading: boolean
|
||||||
|
visible: boolean
|
||||||
|
selectedImageId: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UI状态管理
|
||||||
|
*/
|
||||||
|
interface UiState {
|
||||||
|
theme: ThemeMode
|
||||||
|
layout: LayoutMode
|
||||||
|
controlPanel: ControlPanelConfig
|
||||||
|
screenDisplay: ScreenDisplayConfig
|
||||||
|
screenReader: ScreenReaderConfig
|
||||||
|
sidebarCollapsed: boolean
|
||||||
|
showSettings: boolean
|
||||||
|
showAbout: boolean
|
||||||
|
showDeviceList: boolean
|
||||||
|
loading: boolean
|
||||||
|
operationEnabled: boolean // 操作控制状态
|
||||||
|
deviceInputBlocked: boolean // 设备端输入阻止状态
|
||||||
|
cameraViewVisible: boolean // 摄像头显示区域可见性
|
||||||
|
cameraActive: boolean // 摄像头是否有数据流(在线)
|
||||||
|
gallery: GalleryState // 相册状态
|
||||||
|
notifications: Array<{
|
||||||
|
id: string
|
||||||
|
type: 'success' | 'warning' | 'error' | 'info'
|
||||||
|
title: string
|
||||||
|
message: string
|
||||||
|
duration?: number
|
||||||
|
timestamp: number
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: UiState = {
|
||||||
|
theme: 'auto',
|
||||||
|
layout: 'desktop',
|
||||||
|
controlPanel: {
|
||||||
|
showKeyboard: true,
|
||||||
|
showGamepad: false,
|
||||||
|
showQuickActions: true,
|
||||||
|
showDeviceInfo: true,
|
||||||
|
position: 'right',
|
||||||
|
collapsed: false,
|
||||||
|
},
|
||||||
|
screenDisplay: {
|
||||||
|
fitMode: 'fit',
|
||||||
|
quality: 'high',
|
||||||
|
showTouchIndicator: true,
|
||||||
|
enableSound: false,
|
||||||
|
fullscreen: false,
|
||||||
|
},
|
||||||
|
screenReader: {
|
||||||
|
enabled: false,
|
||||||
|
autoRefresh: true,
|
||||||
|
refreshInterval: 1,
|
||||||
|
showElementBounds: true,
|
||||||
|
showElementHierarchy: true,
|
||||||
|
highlightClickable: true,
|
||||||
|
},
|
||||||
|
sidebarCollapsed: false,
|
||||||
|
showSettings: false,
|
||||||
|
showAbout: false,
|
||||||
|
showDeviceList: false,
|
||||||
|
loading: false,
|
||||||
|
operationEnabled: true, // 默认允许操作
|
||||||
|
deviceInputBlocked: false, // 默认不阻止设备输入
|
||||||
|
cameraViewVisible: false, // 默认不显示摄像头区域
|
||||||
|
cameraActive: false, // 默认摄像头未激活
|
||||||
|
gallery: {
|
||||||
|
images: [],
|
||||||
|
loading: false,
|
||||||
|
visible: false,
|
||||||
|
selectedImageId: null,
|
||||||
|
},
|
||||||
|
notifications: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UI管理 Slice
|
||||||
|
*/
|
||||||
|
const uiSlice = createSlice({
|
||||||
|
name: 'ui',
|
||||||
|
initialState,
|
||||||
|
reducers: {
|
||||||
|
// 设置主题
|
||||||
|
setTheme: (state, action: PayloadAction<ThemeMode>) => {
|
||||||
|
state.theme = action.payload
|
||||||
|
},
|
||||||
|
|
||||||
|
// 设置布局模式
|
||||||
|
setLayout: (state, action: PayloadAction<LayoutMode>) => {
|
||||||
|
state.layout = action.payload
|
||||||
|
},
|
||||||
|
|
||||||
|
// 更新控制面板配置
|
||||||
|
updateControlPanel: (state, action: PayloadAction<Partial<ControlPanelConfig>>) => {
|
||||||
|
state.controlPanel = { ...state.controlPanel, ...action.payload }
|
||||||
|
},
|
||||||
|
|
||||||
|
// 更新屏幕显示配置
|
||||||
|
updateScreenDisplay: (state, action: PayloadAction<Partial<ScreenDisplayConfig>>) => {
|
||||||
|
state.screenDisplay = { ...state.screenDisplay, ...action.payload }
|
||||||
|
},
|
||||||
|
|
||||||
|
// 更新屏幕阅读器配置
|
||||||
|
updateScreenReader: (state, action: PayloadAction<Partial<ScreenReaderConfig>>) => {
|
||||||
|
state.screenReader = { ...state.screenReader, ...action.payload }
|
||||||
|
},
|
||||||
|
|
||||||
|
// 启用屏幕阅读器模式
|
||||||
|
enableScreenReader: (state) => {
|
||||||
|
state.screenReader.enabled = true
|
||||||
|
},
|
||||||
|
|
||||||
|
// 禁用屏幕阅读器模式
|
||||||
|
disableScreenReader: (state) => {
|
||||||
|
state.screenReader.enabled = false
|
||||||
|
},
|
||||||
|
|
||||||
|
// 切换屏幕阅读器模式
|
||||||
|
toggleScreenReader: (state) => {
|
||||||
|
state.screenReader.enabled = !state.screenReader.enabled
|
||||||
|
},
|
||||||
|
|
||||||
|
// 切换侧边栏
|
||||||
|
toggleSidebar: (state) => {
|
||||||
|
state.sidebarCollapsed = !state.sidebarCollapsed
|
||||||
|
},
|
||||||
|
|
||||||
|
// 设置侧边栏状态
|
||||||
|
setSidebarCollapsed: (state, action: PayloadAction<boolean>) => {
|
||||||
|
state.sidebarCollapsed = action.payload
|
||||||
|
},
|
||||||
|
|
||||||
|
// 显示设置对话框
|
||||||
|
showSettings: (state) => {
|
||||||
|
state.showSettings = true
|
||||||
|
},
|
||||||
|
|
||||||
|
// 隐藏设置对话框
|
||||||
|
hideSettings: (state) => {
|
||||||
|
state.showSettings = false
|
||||||
|
},
|
||||||
|
|
||||||
|
// 显示关于对话框
|
||||||
|
showAbout: (state) => {
|
||||||
|
state.showAbout = true
|
||||||
|
},
|
||||||
|
|
||||||
|
// 隐藏关于对话框
|
||||||
|
hideAbout: (state) => {
|
||||||
|
state.showAbout = false
|
||||||
|
},
|
||||||
|
|
||||||
|
// 显示设备列表
|
||||||
|
showDeviceList: (state) => {
|
||||||
|
state.showDeviceList = true
|
||||||
|
},
|
||||||
|
|
||||||
|
// 隐藏设备列表
|
||||||
|
hideDeviceList: (state) => {
|
||||||
|
state.showDeviceList = false
|
||||||
|
},
|
||||||
|
|
||||||
|
// 设置加载状态
|
||||||
|
setLoading: (state, action: PayloadAction<boolean>) => {
|
||||||
|
state.loading = action.payload
|
||||||
|
},
|
||||||
|
|
||||||
|
// 添加通知
|
||||||
|
addNotification: (state, action: PayloadAction<Omit<UiState['notifications'][0], 'id' | 'timestamp'>>) => {
|
||||||
|
const notification = {
|
||||||
|
...action.payload,
|
||||||
|
id: Date.now().toString(),
|
||||||
|
timestamp: Date.now(),
|
||||||
|
}
|
||||||
|
state.notifications.push(notification)
|
||||||
|
},
|
||||||
|
|
||||||
|
// 移除通知
|
||||||
|
removeNotification: (state, action: PayloadAction<string>) => {
|
||||||
|
state.notifications = state.notifications.filter(
|
||||||
|
notification => notification.id !== action.payload
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
|
// 清除所有通知
|
||||||
|
clearNotifications: (state) => {
|
||||||
|
state.notifications = []
|
||||||
|
},
|
||||||
|
|
||||||
|
// 切换全屏模式
|
||||||
|
toggleFullscreen: (state) => {
|
||||||
|
state.screenDisplay.fullscreen = !state.screenDisplay.fullscreen
|
||||||
|
},
|
||||||
|
|
||||||
|
// 切换控制面板
|
||||||
|
toggleControlPanel: (state) => {
|
||||||
|
state.controlPanel.collapsed = !state.controlPanel.collapsed
|
||||||
|
},
|
||||||
|
|
||||||
|
// 设置操作控制状态
|
||||||
|
setOperationEnabled: (state, action: PayloadAction<boolean>) => {
|
||||||
|
state.operationEnabled = action.payload
|
||||||
|
},
|
||||||
|
|
||||||
|
// 切换操作控制状态
|
||||||
|
toggleOperationEnabled: (state) => {
|
||||||
|
state.operationEnabled = !state.operationEnabled
|
||||||
|
},
|
||||||
|
|
||||||
|
// 设置设备输入阻止状态
|
||||||
|
setDeviceInputBlocked: (state, action: PayloadAction<boolean>) => {
|
||||||
|
state.deviceInputBlocked = action.payload
|
||||||
|
},
|
||||||
|
|
||||||
|
// 切换设备输入阻止状态
|
||||||
|
toggleDeviceInputBlocked: (state) => {
|
||||||
|
state.deviceInputBlocked = !state.deviceInputBlocked
|
||||||
|
},
|
||||||
|
|
||||||
|
// 设置摄像头显示区域可见性
|
||||||
|
setCameraViewVisible: (state, action: PayloadAction<boolean>) => {
|
||||||
|
state.cameraViewVisible = action.payload
|
||||||
|
},
|
||||||
|
|
||||||
|
// 切换摄像头显示区域可见性
|
||||||
|
toggleCameraViewVisible: (state) => {
|
||||||
|
state.cameraViewVisible = !state.cameraViewVisible
|
||||||
|
},
|
||||||
|
|
||||||
|
// 设置摄像头激活状态(基于数据流)
|
||||||
|
setCameraActive: (state, action: PayloadAction<boolean>) => {
|
||||||
|
state.cameraActive = action.payload
|
||||||
|
},
|
||||||
|
|
||||||
|
// 设置相册可见性
|
||||||
|
setGalleryVisible: (state, action: PayloadAction<boolean>) => {
|
||||||
|
state.gallery.visible = action.payload
|
||||||
|
},
|
||||||
|
|
||||||
|
// 设置相册加载状态
|
||||||
|
setGalleryLoading: (state, action: PayloadAction<boolean>) => {
|
||||||
|
state.gallery.loading = action.payload
|
||||||
|
},
|
||||||
|
|
||||||
|
// 添加相册图片
|
||||||
|
addGalleryImage: (state, action: PayloadAction<GalleryImage>) => {
|
||||||
|
const existingIndex = state.gallery.images.findIndex(img => img.id === action.payload.id)
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
state.gallery.images[existingIndex] = action.payload
|
||||||
|
} else {
|
||||||
|
state.gallery.images.push(action.payload)
|
||||||
|
}
|
||||||
|
// 按时间戳倒序排列
|
||||||
|
state.gallery.images.sort((a, b) => b.timestamp - a.timestamp)
|
||||||
|
},
|
||||||
|
|
||||||
|
// 设置相册图片列表
|
||||||
|
setGalleryImages: (state, action: PayloadAction<GalleryImage[]>) => {
|
||||||
|
state.gallery.images = action.payload.sort((a, b) => b.timestamp - a.timestamp)
|
||||||
|
},
|
||||||
|
|
||||||
|
// 选择相册图片
|
||||||
|
selectGalleryImage: (state, action: PayloadAction<string | null>) => {
|
||||||
|
state.gallery.selectedImageId = action.payload
|
||||||
|
},
|
||||||
|
|
||||||
|
// 清空相册
|
||||||
|
clearGallery: (state) => {
|
||||||
|
state.gallery.images = []
|
||||||
|
state.gallery.selectedImageId = null
|
||||||
|
},
|
||||||
|
},
|
||||||
|
extraReducers: (builder) => {
|
||||||
|
// 监听设备状态重置,重置设备输入阻止状态
|
||||||
|
builder.addCase('devices/resetDeviceStates', (state) => {
|
||||||
|
state.deviceInputBlocked = false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const {
|
||||||
|
setTheme,
|
||||||
|
setLayout,
|
||||||
|
updateControlPanel,
|
||||||
|
updateScreenDisplay,
|
||||||
|
updateScreenReader,
|
||||||
|
enableScreenReader,
|
||||||
|
disableScreenReader,
|
||||||
|
toggleScreenReader,
|
||||||
|
toggleSidebar,
|
||||||
|
setSidebarCollapsed,
|
||||||
|
showSettings,
|
||||||
|
hideSettings,
|
||||||
|
showAbout,
|
||||||
|
hideAbout,
|
||||||
|
showDeviceList,
|
||||||
|
hideDeviceList,
|
||||||
|
setLoading,
|
||||||
|
addNotification,
|
||||||
|
removeNotification,
|
||||||
|
clearNotifications,
|
||||||
|
toggleFullscreen,
|
||||||
|
toggleControlPanel,
|
||||||
|
setOperationEnabled,
|
||||||
|
toggleOperationEnabled,
|
||||||
|
setDeviceInputBlocked,
|
||||||
|
toggleDeviceInputBlocked,
|
||||||
|
setCameraViewVisible,
|
||||||
|
toggleCameraViewVisible,
|
||||||
|
setCameraActive,
|
||||||
|
setGalleryVisible,
|
||||||
|
setGalleryLoading,
|
||||||
|
addGalleryImage,
|
||||||
|
setGalleryImages,
|
||||||
|
selectGalleryImage,
|
||||||
|
clearGallery,
|
||||||
|
} = uiSlice.actions
|
||||||
|
|
||||||
|
export default uiSlice.reducer
|
||||||
27
src/store/store.ts
Normal file
27
src/store/store.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { configureStore } from '@reduxjs/toolkit'
|
||||||
|
import deviceSlice from './slices/deviceSlice'
|
||||||
|
import connectionSlice from './slices/connectionSlice'
|
||||||
|
import uiSlice from './slices/uiSlice'
|
||||||
|
import authSlice from './slices/authSlice'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redux Store 配置
|
||||||
|
*/
|
||||||
|
export const store = configureStore({
|
||||||
|
reducer: {
|
||||||
|
devices: deviceSlice,
|
||||||
|
connection: connectionSlice,
|
||||||
|
ui: uiSlice,
|
||||||
|
auth: authSlice,
|
||||||
|
},
|
||||||
|
middleware: (getDefaultMiddleware) =>
|
||||||
|
getDefaultMiddleware({
|
||||||
|
serializableCheck: {
|
||||||
|
ignoredActions: ['connection/setWebSocket'],
|
||||||
|
ignoredPaths: ['connection.webSocket'],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type RootState = ReturnType<typeof store.getState>
|
||||||
|
export type AppDispatch = typeof store.dispatch
|
||||||
248
src/utils/CoordinateMapper.ts
Normal file
248
src/utils/CoordinateMapper.ts
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
/**
|
||||||
|
* 坐标映射工具类 - 用于测试和验证坐标转换的准确性
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface DeviceInfo {
|
||||||
|
density: number
|
||||||
|
densityDpi: number
|
||||||
|
hasNavigationBar: boolean
|
||||||
|
aspectRatio: number
|
||||||
|
realScreenSize: { width: number; height: number }
|
||||||
|
appScreenSize: { width: number; height: number }
|
||||||
|
navigationBarSize: { width: number; height: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CoordinateTestResult {
|
||||||
|
success: boolean
|
||||||
|
accuracy: number // 准确度百分比
|
||||||
|
avgError: number // 平均误差(像素)
|
||||||
|
maxError: number // 最大误差(像素)
|
||||||
|
testPoints: Array<{
|
||||||
|
input: { x: number; y: number }
|
||||||
|
expected: { x: number; y: number }
|
||||||
|
actual: { x: number; y: number }
|
||||||
|
error: number
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CoordinateMapper {
|
||||||
|
/**
|
||||||
|
* 测试坐标映射准确性
|
||||||
|
*/
|
||||||
|
static testCoordinateMapping(
|
||||||
|
deviceWidth: number,
|
||||||
|
deviceHeight: number,
|
||||||
|
displayWidth: number,
|
||||||
|
displayHeight: number,
|
||||||
|
deviceInfo?: DeviceInfo
|
||||||
|
): CoordinateTestResult {
|
||||||
|
console.log('🧪 开始坐标映射准确性测试')
|
||||||
|
|
||||||
|
// 生成测试点
|
||||||
|
const testPoints = [
|
||||||
|
// 屏幕角落
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: deviceWidth - 1, y: 0 },
|
||||||
|
{ x: 0, y: deviceHeight - 1 },
|
||||||
|
{ x: deviceWidth - 1, y: deviceHeight - 1 },
|
||||||
|
|
||||||
|
// 屏幕中心
|
||||||
|
{ x: deviceWidth / 2, y: deviceHeight / 2 },
|
||||||
|
|
||||||
|
// 常见按钮位置
|
||||||
|
{ x: deviceWidth * 0.8, y: deviceHeight * 0.9 }, // 右下角按钮
|
||||||
|
{ x: deviceWidth * 0.5, y: deviceHeight * 0.8 }, // 底部中央按钮
|
||||||
|
{ x: deviceWidth * 0.2, y: deviceHeight * 0.1 }, // 左上角按钮
|
||||||
|
|
||||||
|
// 键盘区域
|
||||||
|
{ x: deviceWidth * 0.25, y: deviceHeight * 0.6 },
|
||||||
|
{ x: deviceWidth * 0.5, y: deviceHeight * 0.6 },
|
||||||
|
{ x: deviceWidth * 0.75, y: deviceHeight * 0.6 },
|
||||||
|
|
||||||
|
// 导航栏区域(如果有)
|
||||||
|
...(deviceInfo?.hasNavigationBar ? [
|
||||||
|
{ x: deviceWidth * 0.2, y: deviceHeight + 50 },
|
||||||
|
{ x: deviceWidth * 0.5, y: deviceHeight + 50 },
|
||||||
|
{ x: deviceWidth * 0.8, y: deviceHeight + 50 }
|
||||||
|
] : [])
|
||||||
|
]
|
||||||
|
|
||||||
|
const results: CoordinateTestResult['testPoints'] = []
|
||||||
|
let totalError = 0
|
||||||
|
let maxError = 0
|
||||||
|
|
||||||
|
for (const point of testPoints) {
|
||||||
|
// 模拟正向转换:设备坐标 → 显示坐标
|
||||||
|
const displayCoords = this.deviceToDisplay(point.x, point.y, deviceWidth, deviceHeight, displayWidth, displayHeight)
|
||||||
|
|
||||||
|
// 模拟反向转换:显示坐标 → 设备坐标
|
||||||
|
const backToDevice = this.displayToDevice(displayCoords.x, displayCoords.y, deviceWidth, deviceHeight, displayWidth, displayHeight, deviceInfo)
|
||||||
|
|
||||||
|
// 计算误差
|
||||||
|
const error = Math.sqrt(Math.pow(point.x - backToDevice.x, 2) + Math.pow(point.y - backToDevice.y, 2))
|
||||||
|
totalError += error
|
||||||
|
maxError = Math.max(maxError, error)
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
input: point,
|
||||||
|
expected: point,
|
||||||
|
actual: backToDevice,
|
||||||
|
error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const avgError = totalError / testPoints.length
|
||||||
|
const accuracy = Math.max(0, 100 - (avgError / Math.min(deviceWidth, deviceHeight) * 100))
|
||||||
|
|
||||||
|
const result: CoordinateTestResult = {
|
||||||
|
success: avgError < 5, // 平均误差小于5像素认为成功
|
||||||
|
accuracy,
|
||||||
|
avgError,
|
||||||
|
maxError,
|
||||||
|
testPoints: results
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('🧪 坐标映射测试结果:', {
|
||||||
|
success: result.success,
|
||||||
|
accuracy: `${accuracy.toFixed(2)}%`,
|
||||||
|
avgError: `${avgError.toFixed(2)}px`,
|
||||||
|
maxError: `${maxError.toFixed(2)}px`,
|
||||||
|
testPointsCount: testPoints.length
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设备坐标转换为显示坐标
|
||||||
|
*/
|
||||||
|
private static deviceToDisplay(
|
||||||
|
deviceX: number,
|
||||||
|
deviceY: number,
|
||||||
|
deviceWidth: number,
|
||||||
|
deviceHeight: number,
|
||||||
|
displayWidth: number,
|
||||||
|
displayHeight: number
|
||||||
|
): { x: number; y: number } {
|
||||||
|
const scaleX = displayWidth / deviceWidth
|
||||||
|
const scaleY = displayHeight / deviceHeight
|
||||||
|
const scale = Math.min(scaleX, scaleY)
|
||||||
|
|
||||||
|
const actualDisplayWidth = deviceWidth * scale
|
||||||
|
const actualDisplayHeight = deviceHeight * scale
|
||||||
|
const offsetX = (displayWidth - actualDisplayWidth) / 2
|
||||||
|
const offsetY = (displayHeight - actualDisplayHeight) / 2
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: deviceX * scale + offsetX,
|
||||||
|
y: deviceY * scale + offsetY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 显示坐标转换为设备坐标(增强版本)
|
||||||
|
*/
|
||||||
|
private static displayToDevice(
|
||||||
|
displayX: number,
|
||||||
|
displayY: number,
|
||||||
|
deviceWidth: number,
|
||||||
|
deviceHeight: number,
|
||||||
|
displayWidth: number,
|
||||||
|
displayHeight: number,
|
||||||
|
deviceInfo?: DeviceInfo
|
||||||
|
): { x: number; y: number } {
|
||||||
|
const scaleX = displayWidth / deviceWidth
|
||||||
|
const scaleY = displayHeight / deviceHeight
|
||||||
|
const scale = Math.min(scaleX, scaleY)
|
||||||
|
|
||||||
|
const actualDisplayWidth = deviceWidth * scale
|
||||||
|
const actualDisplayHeight = deviceHeight * scale
|
||||||
|
const offsetX = (displayWidth - actualDisplayWidth) / 2
|
||||||
|
const offsetY = (displayHeight - actualDisplayHeight) / 2
|
||||||
|
|
||||||
|
const adjustedX = displayX - offsetX
|
||||||
|
const adjustedY = displayY - offsetY
|
||||||
|
|
||||||
|
let deviceX = adjustedX / scale
|
||||||
|
let deviceY = adjustedY / scale
|
||||||
|
|
||||||
|
// 应用设备特性修正
|
||||||
|
if (deviceInfo) {
|
||||||
|
const density = deviceInfo.density
|
||||||
|
if (density > 2) {
|
||||||
|
deviceX = Math.round(deviceX * 10) / 10
|
||||||
|
deviceY = Math.round(deviceY * 10) / 10
|
||||||
|
} else {
|
||||||
|
deviceX = Math.round(deviceX)
|
||||||
|
deviceY = Math.round(deviceY)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导航栏区域修正
|
||||||
|
if (deviceInfo.hasNavigationBar && deviceInfo.navigationBarSize.height > 0) {
|
||||||
|
const navBarHeight = deviceInfo.navigationBarSize.height
|
||||||
|
const threshold = deviceHeight - navBarHeight
|
||||||
|
if (deviceY > threshold) {
|
||||||
|
deviceY = Math.min(deviceY, deviceHeight + Math.min(navBarHeight, 150))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: Math.max(0, Math.min(deviceWidth, deviceX)),
|
||||||
|
y: Math.max(0, Math.min(deviceHeight, deviceY))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成坐标映射诊断报告
|
||||||
|
*/
|
||||||
|
static generateDiagnosticReport(
|
||||||
|
deviceWidth: number,
|
||||||
|
deviceHeight: number,
|
||||||
|
displayWidth: number,
|
||||||
|
displayHeight: number,
|
||||||
|
deviceInfo?: DeviceInfo
|
||||||
|
): string {
|
||||||
|
const testResult = this.testCoordinateMapping(deviceWidth, deviceHeight, displayWidth, displayHeight, deviceInfo)
|
||||||
|
|
||||||
|
let report = `📊 坐标映射诊断报告\n`
|
||||||
|
report += `==========================================\n`
|
||||||
|
report += `设备分辨率: ${deviceWidth}x${deviceHeight}\n`
|
||||||
|
report += `显示分辨率: ${displayWidth}x${displayHeight}\n`
|
||||||
|
report += `设备信息: ${deviceInfo ? '已提供' : '未提供'}\n`
|
||||||
|
report += `\n`
|
||||||
|
report += `测试结果:\n`
|
||||||
|
report += `- 测试状态: ${testResult.success ? '✅ 通过' : '❌ 失败'}\n`
|
||||||
|
report += `- 准确度: ${testResult.accuracy.toFixed(2)}%\n`
|
||||||
|
report += `- 平均误差: ${testResult.avgError.toFixed(2)}px\n`
|
||||||
|
report += `- 最大误差: ${testResult.maxError.toFixed(2)}px\n`
|
||||||
|
report += `- 测试点数: ${testResult.testPoints.length}\n`
|
||||||
|
report += `\n`
|
||||||
|
|
||||||
|
if (!testResult.success) {
|
||||||
|
report += `❌ 问题点分析:\n`
|
||||||
|
testResult.testPoints
|
||||||
|
.filter(p => p.error > 5)
|
||||||
|
.forEach((p, i) => {
|
||||||
|
report += ` ${i + 1}. 输入(${p.input.x.toFixed(1)}, ${p.input.y.toFixed(1)}) → 输出(${p.actual.x.toFixed(1)}, ${p.actual.y.toFixed(1)}) 误差: ${p.error.toFixed(2)}px\n`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deviceInfo) {
|
||||||
|
report += `\n📱 设备特征:\n`
|
||||||
|
report += `- 密度: ${deviceInfo.density}\n`
|
||||||
|
report += `- DPI: ${deviceInfo.densityDpi}\n`
|
||||||
|
report += `- 长宽比: ${deviceInfo.aspectRatio.toFixed(3)}\n`
|
||||||
|
report += `- 虚拟按键: ${deviceInfo.hasNavigationBar ? '是' : '否'}\n`
|
||||||
|
if (deviceInfo.hasNavigationBar) {
|
||||||
|
report += `- 导航栏尺寸: ${deviceInfo.navigationBarSize.width}x${deviceInfo.navigationBarSize.height}\n`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
report += `==========================================\n`
|
||||||
|
|
||||||
|
return report
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CoordinateMapper
|
||||||
160
src/utils/CoordinateMappingConfig.ts
Normal file
160
src/utils/CoordinateMappingConfig.ts
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
/**
|
||||||
|
* 坐标映射配置系统 - 渐进式功能启用
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface CoordinateMappingConfig {
|
||||||
|
// 基础功能(始终启用)
|
||||||
|
enableBasicMapping: boolean
|
||||||
|
|
||||||
|
// 高精度功能(可选启用)
|
||||||
|
enableSubPixelPrecision: boolean
|
||||||
|
enableNavigationBarDetection: boolean
|
||||||
|
enableDensityCorrection: boolean
|
||||||
|
enableAspectRatioCorrection: boolean
|
||||||
|
|
||||||
|
// 调试功能
|
||||||
|
enableDetailedLogging: boolean
|
||||||
|
enableCoordinateValidation: boolean
|
||||||
|
enablePerformanceMonitoring: boolean
|
||||||
|
|
||||||
|
// 容错设置
|
||||||
|
fallbackToBasicOnError: boolean
|
||||||
|
maxCoordinateError: number // 最大允许的坐标误差(像素)
|
||||||
|
|
||||||
|
// 设备特定优化
|
||||||
|
enableDeviceSpecificOptimizations: boolean
|
||||||
|
minimumScreenSize: { width: number; height: number }
|
||||||
|
maximumScreenSize: { width: number; height: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_COORDINATE_MAPPING_CONFIG: CoordinateMappingConfig = {
|
||||||
|
// 基础功能
|
||||||
|
enableBasicMapping: true,
|
||||||
|
|
||||||
|
// 高精度功能(逐步启用)
|
||||||
|
enableSubPixelPrecision: false, // 🚩 第一阶段:关闭
|
||||||
|
enableNavigationBarDetection: true, // ✅ 相对安全
|
||||||
|
enableDensityCorrection: true, // ✅ 相对安全
|
||||||
|
enableAspectRatioCorrection: true, // ✅ 相对安全
|
||||||
|
|
||||||
|
// 调试功能
|
||||||
|
enableDetailedLogging: false, // 默认关闭,需要时开启
|
||||||
|
enableCoordinateValidation: true,
|
||||||
|
enablePerformanceMonitoring: false,
|
||||||
|
|
||||||
|
// 容错设置
|
||||||
|
fallbackToBasicOnError: true,
|
||||||
|
maxCoordinateError: 10, // 允许10像素误差
|
||||||
|
|
||||||
|
// 设备特定优化
|
||||||
|
enableDeviceSpecificOptimizations: false, // 🚩 第一阶段:关闭
|
||||||
|
minimumScreenSize: { width: 240, height: 320 },
|
||||||
|
maximumScreenSize: { width: 4096, height: 8192 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据设备特征自动调整配置
|
||||||
|
*/
|
||||||
|
export function createOptimizedConfig(
|
||||||
|
deviceWidth: number,
|
||||||
|
deviceHeight: number,
|
||||||
|
_userAgent?: string
|
||||||
|
): CoordinateMappingConfig {
|
||||||
|
const config = { ...DEFAULT_COORDINATE_MAPPING_CONFIG }
|
||||||
|
|
||||||
|
// 基于屏幕尺寸调整
|
||||||
|
const screenArea = deviceWidth * deviceHeight
|
||||||
|
const aspectRatio = Math.max(deviceWidth, deviceHeight) / Math.min(deviceWidth, deviceHeight)
|
||||||
|
|
||||||
|
// 高分辨率设备可以启用亚像素精度
|
||||||
|
if (screenArea > 2073600) { // 1080p以上
|
||||||
|
config.enableSubPixelPrecision = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 长屏设备通常有导航栏
|
||||||
|
if (aspectRatio > 2.0) {
|
||||||
|
config.enableNavigationBarDetection = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 超大屏幕需要更高的容错性
|
||||||
|
if (deviceWidth > 1440 || deviceHeight > 2560) {
|
||||||
|
config.maxCoordinateError = 15
|
||||||
|
}
|
||||||
|
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证坐标映射配置的安全性
|
||||||
|
*/
|
||||||
|
export function validateConfig(config: CoordinateMappingConfig): {
|
||||||
|
isValid: boolean
|
||||||
|
warnings: string[]
|
||||||
|
errors: string[]
|
||||||
|
} {
|
||||||
|
const warnings: string[] = []
|
||||||
|
const errors: string[] = []
|
||||||
|
|
||||||
|
// 检查基础配置
|
||||||
|
if (!config.enableBasicMapping) {
|
||||||
|
errors.push('基础坐标映射不能被禁用')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查容错设置
|
||||||
|
if (config.maxCoordinateError < 1) {
|
||||||
|
warnings.push('坐标误差容忍度过低,可能导致映射失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.maxCoordinateError > 50) {
|
||||||
|
warnings.push('坐标误差容忍度过高,可能影响精度')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查屏幕尺寸范围
|
||||||
|
if (config.minimumScreenSize.width < 100 || config.minimumScreenSize.height < 100) {
|
||||||
|
errors.push('最小屏幕尺寸设置过小')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.maximumScreenSize.width > 10000 || config.maximumScreenSize.height > 10000) {
|
||||||
|
warnings.push('最大屏幕尺寸设置过大')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isValid: errors.length === 0,
|
||||||
|
warnings,
|
||||||
|
errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取功能启用状态的摘要
|
||||||
|
*/
|
||||||
|
export function getConfigSummary(config: CoordinateMappingConfig): string {
|
||||||
|
const enabledFeatures: string[] = []
|
||||||
|
const disabledFeatures: string[] = []
|
||||||
|
|
||||||
|
const features = [
|
||||||
|
{ key: 'enableSubPixelPrecision', name: '亚像素精度' },
|
||||||
|
{ key: 'enableNavigationBarDetection', name: '导航栏检测' },
|
||||||
|
{ key: 'enableDensityCorrection', name: '密度修正' },
|
||||||
|
{ key: 'enableAspectRatioCorrection', name: '长宽比修正' },
|
||||||
|
{ key: 'enableDeviceSpecificOptimizations', name: '设备特定优化' }
|
||||||
|
]
|
||||||
|
|
||||||
|
features.forEach(feature => {
|
||||||
|
if (config[feature.key as keyof CoordinateMappingConfig]) {
|
||||||
|
enabledFeatures.push(feature.name)
|
||||||
|
} else {
|
||||||
|
disabledFeatures.push(feature.name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
let summary = `坐标映射配置摘要:\n`
|
||||||
|
summary += `✅ 已启用: ${enabledFeatures.join(', ') || '无'}\n`
|
||||||
|
summary += `❌ 已禁用: ${disabledFeatures.join(', ') || '无'}\n`
|
||||||
|
summary += `🎯 误差容忍: ${config.maxCoordinateError}px\n`
|
||||||
|
summary += `🔧 回退模式: ${config.fallbackToBasicOnError ? '启用' : '禁用'}`
|
||||||
|
|
||||||
|
return summary
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DEFAULT_COORDINATE_MAPPING_CONFIG
|
||||||
425
src/utils/SafeCoordinateMapper.ts
Normal file
425
src/utils/SafeCoordinateMapper.ts
Normal file
@@ -0,0 +1,425 @@
|
|||||||
|
/**
|
||||||
|
* 安全的坐标映射工具类 - 渐进式增强
|
||||||
|
*
|
||||||
|
* 设计原则:
|
||||||
|
* 1. 基础功能始终可用
|
||||||
|
* 2. 高级功能可选启用
|
||||||
|
* 3. 错误自动回退到基础功能
|
||||||
|
* 4. 详细的性能监控和错误日志
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
type CoordinateMappingConfig,
|
||||||
|
DEFAULT_COORDINATE_MAPPING_CONFIG,
|
||||||
|
validateConfig
|
||||||
|
} from './CoordinateMappingConfig'
|
||||||
|
|
||||||
|
export interface DeviceInfo {
|
||||||
|
density: number
|
||||||
|
densityDpi: number
|
||||||
|
hasNavigationBar: boolean
|
||||||
|
aspectRatio: number
|
||||||
|
realScreenSize: { width: number; height: number }
|
||||||
|
appScreenSize: { width: number; height: number }
|
||||||
|
navigationBarSize: { width: number; height: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CoordinateMappingResult {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
metadata: {
|
||||||
|
scale: number
|
||||||
|
density: number
|
||||||
|
actualDisplayArea: { width: number; height: number }
|
||||||
|
offset: { x: number; y: number }
|
||||||
|
aspectRatioDiff: number
|
||||||
|
processingTime: number
|
||||||
|
method: string
|
||||||
|
errors: string[]
|
||||||
|
warnings: string[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SafeCoordinateMapper {
|
||||||
|
private config: CoordinateMappingConfig
|
||||||
|
private performanceStats: {
|
||||||
|
totalMappings: number
|
||||||
|
successfulMappings: number
|
||||||
|
failedMappings: number
|
||||||
|
averageProcessingTime: number
|
||||||
|
errorRates: { [key: string]: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(config?: Partial<CoordinateMappingConfig>) {
|
||||||
|
this.config = { ...DEFAULT_COORDINATE_MAPPING_CONFIG, ...config }
|
||||||
|
this.performanceStats = {
|
||||||
|
totalMappings: 0,
|
||||||
|
successfulMappings: 0,
|
||||||
|
failedMappings: 0,
|
||||||
|
averageProcessingTime: 0,
|
||||||
|
errorRates: {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证配置
|
||||||
|
const validation = validateConfig(this.config)
|
||||||
|
if (!validation.isValid) {
|
||||||
|
console.error('SafeCoordinateMapper配置无效:', validation.errors)
|
||||||
|
throw new Error('Invalid coordinate mapping configuration')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validation.warnings.length > 0) {
|
||||||
|
console.warn('SafeCoordinateMapper配置警告:', validation.warnings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安全的坐标映射 - 主要入口点
|
||||||
|
*/
|
||||||
|
mapCoordinates(
|
||||||
|
canvasX: number,
|
||||||
|
canvasY: number,
|
||||||
|
canvasElement: HTMLCanvasElement,
|
||||||
|
deviceWidth: number,
|
||||||
|
deviceHeight: number,
|
||||||
|
deviceInfo?: DeviceInfo
|
||||||
|
): CoordinateMappingResult | null {
|
||||||
|
const startTime = performance.now()
|
||||||
|
const errors: string[] = []
|
||||||
|
const warnings: string[] = []
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.performanceStats.totalMappings++
|
||||||
|
|
||||||
|
// 🔒 阶段1:基础验证
|
||||||
|
const basicValidation = this.validateBasicInputs(
|
||||||
|
canvasX, canvasY, canvasElement, deviceWidth, deviceHeight
|
||||||
|
)
|
||||||
|
if (!basicValidation.isValid) {
|
||||||
|
errors.push(...basicValidation.errors)
|
||||||
|
warnings.push(...basicValidation.warnings)
|
||||||
|
|
||||||
|
if (this.config.fallbackToBasicOnError) {
|
||||||
|
return this.performBasicMapping(
|
||||||
|
canvasX, canvasY, canvasElement, deviceWidth, deviceHeight,
|
||||||
|
startTime, 'basic_fallback', errors, warnings
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔒 阶段2:基础坐标映射
|
||||||
|
const basicResult = this.performBasicMapping(
|
||||||
|
canvasX, canvasY, canvasElement, deviceWidth, deviceHeight,
|
||||||
|
startTime, 'basic', errors, warnings
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!basicResult) {
|
||||||
|
this.performanceStats.failedMappings++
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔒 阶段3:渐进式增强
|
||||||
|
const enhancedResult = this.applyEnhancements(
|
||||||
|
basicResult, deviceInfo, deviceWidth, deviceHeight
|
||||||
|
)
|
||||||
|
|
||||||
|
// 🔒 阶段4:最终验证和统计
|
||||||
|
const finalResult = this.validateAndFinalizeMappingResult(
|
||||||
|
enhancedResult, deviceWidth, deviceHeight, startTime
|
||||||
|
)
|
||||||
|
|
||||||
|
this.performanceStats.successfulMappings++
|
||||||
|
this.updatePerformanceStats(finalResult.metadata.processingTime)
|
||||||
|
|
||||||
|
return finalResult
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
this.performanceStats.failedMappings++
|
||||||
|
const errorMessage = `坐标映射失败: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||||
|
console.error(errorMessage, error)
|
||||||
|
|
||||||
|
if (this.config.fallbackToBasicOnError) {
|
||||||
|
return this.performBasicMapping(
|
||||||
|
canvasX, canvasY, canvasElement, deviceWidth, deviceHeight,
|
||||||
|
startTime, 'error_fallback', [errorMessage], warnings
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🔒 基础输入验证
|
||||||
|
*/
|
||||||
|
private validateBasicInputs(
|
||||||
|
canvasX: number,
|
||||||
|
canvasY: number,
|
||||||
|
canvasElement: HTMLCanvasElement,
|
||||||
|
deviceWidth: number,
|
||||||
|
deviceHeight: number
|
||||||
|
): { isValid: boolean; errors: string[]; warnings: string[] } {
|
||||||
|
const errors: string[] = []
|
||||||
|
const warnings: string[] = []
|
||||||
|
|
||||||
|
// 检查基础参数
|
||||||
|
if (!canvasElement || !canvasElement.parentElement) {
|
||||||
|
errors.push('Canvas元素或其容器不存在')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deviceWidth <= 0 || deviceHeight <= 0) {
|
||||||
|
errors.push(`设备尺寸无效: ${deviceWidth}×${deviceHeight}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (canvasX < 0 || canvasY < 0) {
|
||||||
|
warnings.push('Canvas坐标为负数')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查设备尺寸范围
|
||||||
|
if (deviceWidth < this.config.minimumScreenSize.width ||
|
||||||
|
deviceHeight < this.config.minimumScreenSize.height) {
|
||||||
|
warnings.push('设备尺寸过小')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deviceWidth > this.config.maximumScreenSize.width ||
|
||||||
|
deviceHeight > this.config.maximumScreenSize.height) {
|
||||||
|
warnings.push('设备尺寸过大')
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isValid: errors.length === 0, errors, warnings }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🔒 基础坐标映射 - 始终可用的核心功能
|
||||||
|
*/
|
||||||
|
private performBasicMapping(
|
||||||
|
canvasX: number,
|
||||||
|
canvasY: number,
|
||||||
|
canvasElement: HTMLCanvasElement,
|
||||||
|
deviceWidth: number,
|
||||||
|
deviceHeight: number,
|
||||||
|
startTime: number,
|
||||||
|
method: string,
|
||||||
|
errors: string[],
|
||||||
|
warnings: string[]
|
||||||
|
): CoordinateMappingResult | null {
|
||||||
|
try {
|
||||||
|
const container = canvasElement.parentElement!
|
||||||
|
const containerRect = container.getBoundingClientRect()
|
||||||
|
|
||||||
|
const displayWidth = containerRect.width
|
||||||
|
const displayHeight = containerRect.height
|
||||||
|
|
||||||
|
// 基础缩放计算
|
||||||
|
const scaleX = displayWidth / deviceWidth
|
||||||
|
const scaleY = displayHeight / deviceHeight
|
||||||
|
const scale = Math.min(scaleX, scaleY)
|
||||||
|
|
||||||
|
// 基础显示区域计算
|
||||||
|
const actualDisplayWidth = deviceWidth * scale
|
||||||
|
const actualDisplayHeight = deviceHeight * scale
|
||||||
|
const offsetX = (displayWidth - actualDisplayWidth) / 2
|
||||||
|
const offsetY = (displayHeight - actualDisplayHeight) / 2
|
||||||
|
|
||||||
|
// 基础坐标转换
|
||||||
|
const adjustedCanvasX = canvasX - offsetX
|
||||||
|
const adjustedCanvasY = canvasY - offsetY
|
||||||
|
|
||||||
|
// 检查是否在显示区域内
|
||||||
|
if (adjustedCanvasX < 0 || adjustedCanvasX > actualDisplayWidth ||
|
||||||
|
adjustedCanvasY < 0 || adjustedCanvasY > actualDisplayHeight) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const deviceX = adjustedCanvasX / scale
|
||||||
|
const deviceY = adjustedCanvasY / scale
|
||||||
|
|
||||||
|
// 基础坐标限制
|
||||||
|
const clampedX = Math.max(0, Math.min(deviceWidth, Math.round(deviceX)))
|
||||||
|
const clampedY = Math.max(0, Math.min(deviceHeight, Math.round(deviceY)))
|
||||||
|
|
||||||
|
const processingTime = performance.now() - startTime
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: clampedX,
|
||||||
|
y: clampedY,
|
||||||
|
metadata: {
|
||||||
|
scale,
|
||||||
|
density: 1.0, // 基础密度
|
||||||
|
actualDisplayArea: { width: actualDisplayWidth, height: actualDisplayHeight },
|
||||||
|
offset: { x: offsetX, y: offsetY },
|
||||||
|
aspectRatioDiff: Math.abs((deviceWidth / deviceHeight) - (displayWidth / displayHeight)),
|
||||||
|
processingTime,
|
||||||
|
method,
|
||||||
|
errors: [...errors],
|
||||||
|
warnings: [...warnings]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('基础坐标映射失败:', error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🔒 渐进式增强 - 根据配置启用高级功能
|
||||||
|
*/
|
||||||
|
private applyEnhancements(
|
||||||
|
basicResult: CoordinateMappingResult,
|
||||||
|
deviceInfo?: DeviceInfo,
|
||||||
|
deviceWidth?: number,
|
||||||
|
deviceHeight?: number
|
||||||
|
): CoordinateMappingResult {
|
||||||
|
const enhanced = { ...basicResult }
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 🔧 增强1:密度修正
|
||||||
|
if (this.config.enableDensityCorrection && deviceInfo) {
|
||||||
|
enhanced.metadata.density = deviceInfo.density
|
||||||
|
enhanced.metadata.method += '+density'
|
||||||
|
|
||||||
|
// 高密度设备的亚像素精度
|
||||||
|
if (this.config.enableSubPixelPrecision && deviceInfo.density > 2) {
|
||||||
|
enhanced.x = Math.round(enhanced.x * 10) / 10
|
||||||
|
enhanced.y = Math.round(enhanced.y * 10) / 10
|
||||||
|
enhanced.metadata.method += '+subpixel'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔧 增强2:导航栏检测
|
||||||
|
if (this.config.enableNavigationBarDetection && deviceInfo && deviceHeight) {
|
||||||
|
if (deviceInfo.hasNavigationBar && deviceInfo.navigationBarSize?.height > 0) {
|
||||||
|
const navBarHeight = deviceInfo.navigationBarSize.height
|
||||||
|
const navigationBarThreshold = deviceHeight - navBarHeight
|
||||||
|
|
||||||
|
if (enhanced.y > navigationBarThreshold) {
|
||||||
|
enhanced.y = Math.min(enhanced.y, deviceHeight + Math.min(navBarHeight, 150))
|
||||||
|
enhanced.metadata.method += '+navbar'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔧 增强3:长宽比修正
|
||||||
|
if (this.config.enableAspectRatioCorrection && deviceInfo) {
|
||||||
|
const aspectRatioDiff = Math.abs(deviceInfo.aspectRatio - (deviceWidth! / deviceHeight!))
|
||||||
|
if (aspectRatioDiff > 0.1) {
|
||||||
|
enhanced.metadata.warnings.push(`长宽比差异较大: ${aspectRatioDiff.toFixed(3)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔧 增强4:设备特定优化
|
||||||
|
if (this.config.enableDeviceSpecificOptimizations && deviceInfo && deviceWidth && deviceHeight) {
|
||||||
|
const screenArea = deviceWidth * deviceHeight
|
||||||
|
if (screenArea > 2073600) { // 1080p以上
|
||||||
|
enhanced.metadata.method += '+highres'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
enhanced.metadata.errors.push(`增强处理失败: ${error instanceof Error ? error.message : 'Unknown error'}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return enhanced
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🔒 最终验证和结果处理
|
||||||
|
*/
|
||||||
|
private validateAndFinalizeMappingResult(
|
||||||
|
result: CoordinateMappingResult,
|
||||||
|
deviceWidth: number,
|
||||||
|
deviceHeight: number,
|
||||||
|
startTime: number
|
||||||
|
): CoordinateMappingResult {
|
||||||
|
// 最终坐标验证
|
||||||
|
if (this.config.enableCoordinateValidation) {
|
||||||
|
const errorX = Math.abs(result.x - Math.round(result.x))
|
||||||
|
const errorY = Math.abs(result.y - Math.round(result.y))
|
||||||
|
|
||||||
|
if (errorX > this.config.maxCoordinateError || errorY > this.config.maxCoordinateError) {
|
||||||
|
result.metadata.warnings.push(`坐标误差过大: (${errorX.toFixed(2)}, ${errorY.toFixed(2)})`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保坐标在边界内
|
||||||
|
result.x = Math.max(0, Math.min(deviceWidth, result.x))
|
||||||
|
result.y = Math.max(0, Math.min(deviceHeight, result.y))
|
||||||
|
|
||||||
|
// 更新处理时间
|
||||||
|
result.metadata.processingTime = performance.now() - startTime
|
||||||
|
|
||||||
|
// 详细日志
|
||||||
|
if (this.config.enableDetailedLogging) {
|
||||||
|
console.log('SafeCoordinateMapper映射完成:', {
|
||||||
|
coordinates: { x: result.x, y: result.y },
|
||||||
|
metadata: result.metadata,
|
||||||
|
config: this.config,
|
||||||
|
stats: this.performanceStats
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新性能统计
|
||||||
|
*/
|
||||||
|
private updatePerformanceStats(processingTime: number) {
|
||||||
|
if (this.config.enablePerformanceMonitoring) {
|
||||||
|
const totalTime = this.performanceStats.averageProcessingTime * (this.performanceStats.successfulMappings - 1)
|
||||||
|
this.performanceStats.averageProcessingTime = (totalTime + processingTime) / this.performanceStats.successfulMappings
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取性能统计报告
|
||||||
|
*/
|
||||||
|
getPerformanceReport(): string {
|
||||||
|
const successRate = this.performanceStats.totalMappings > 0
|
||||||
|
? (this.performanceStats.successfulMappings / this.performanceStats.totalMappings * 100).toFixed(1)
|
||||||
|
: '0'
|
||||||
|
|
||||||
|
return `
|
||||||
|
SafeCoordinateMapper性能报告:
|
||||||
|
📊 总映射次数: ${this.performanceStats.totalMappings}
|
||||||
|
✅ 成功次数: ${this.performanceStats.successfulMappings}
|
||||||
|
❌ 失败次数: ${this.performanceStats.failedMappings}
|
||||||
|
📈 成功率: ${successRate}%
|
||||||
|
⏱️ 平均处理时间: ${this.performanceStats.averageProcessingTime.toFixed(2)}ms
|
||||||
|
🔧 回退模式: ${this.config.fallbackToBasicOnError ? '启用' : '禁用'}
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新配置
|
||||||
|
*/
|
||||||
|
updateConfig(newConfig: Partial<CoordinateMappingConfig>) {
|
||||||
|
const updatedConfig = { ...this.config, ...newConfig }
|
||||||
|
const validation = validateConfig(updatedConfig)
|
||||||
|
|
||||||
|
if (!validation.isValid) {
|
||||||
|
console.error('配置更新失败:', validation.errors)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
this.config = updatedConfig
|
||||||
|
console.log('SafeCoordinateMapper配置已更新')
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重置性能统计
|
||||||
|
*/
|
||||||
|
resetPerformanceStats() {
|
||||||
|
this.performanceStats = {
|
||||||
|
totalMappings: 0,
|
||||||
|
successfulMappings: 0,
|
||||||
|
failedMappings: 0,
|
||||||
|
averageProcessingTime: 0,
|
||||||
|
errorRates: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SafeCoordinateMapper
|
||||||
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
27
tsconfig.app.json
Normal file
27
tsconfig.app.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
25
tsconfig.node.json
Normal file
25
tsconfig.node.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
62
vite.config.ts
Normal file
62
vite.config.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0', // 允许外部访问
|
||||||
|
port: 5173,
|
||||||
|
open: false, // 服务器环境不自动打开浏览器
|
||||||
|
cors: true, // 启用CORS
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
port: 5173,
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
// 🔧 优化构建配置,解决大块警告
|
||||||
|
chunkSizeWarningLimit: 1000, // 提高块大小警告限制到1MB
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
// 📦 手动代码分割,优化加载性能
|
||||||
|
manualChunks: {
|
||||||
|
// React相关库单独打包
|
||||||
|
'react-vendor': ['react', 'react-dom'],
|
||||||
|
// Redux相关库单独打包
|
||||||
|
'redux-vendor': ['@reduxjs/toolkit', 'react-redux'],
|
||||||
|
// UI库单独打包
|
||||||
|
'ui-vendor': ['antd'],
|
||||||
|
// Socket.IO单独打包
|
||||||
|
'socket-vendor': ['socket.io-client'],
|
||||||
|
},
|
||||||
|
// 📁 优化文件命名
|
||||||
|
chunkFileNames: 'assets/js/[name]-[hash].js',
|
||||||
|
entryFileNames: 'assets/js/[name]-[hash].js',
|
||||||
|
assetFileNames: 'assets/[ext]/[name]-[hash].[ext]',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// 🚀 构建优化
|
||||||
|
sourcemap: false, // 生产环境不生成sourcemap
|
||||||
|
minify: 'terser', // 使用terser压缩
|
||||||
|
terserOptions: {
|
||||||
|
compress: {
|
||||||
|
drop_console: true, // 移除console
|
||||||
|
drop_debugger: true, // 移除debugger
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// 📊 资源优化
|
||||||
|
assetsInlineLimit: 4096, // 小于4KB的资源内联为base64
|
||||||
|
},
|
||||||
|
// 🔧 依赖优化
|
||||||
|
optimizeDeps: {
|
||||||
|
include: [
|
||||||
|
'react',
|
||||||
|
'react-dom',
|
||||||
|
'@reduxjs/toolkit',
|
||||||
|
'react-redux',
|
||||||
|
'antd',
|
||||||
|
'socket.io-client'
|
||||||
|
],
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user