This commit is contained in:
wdvipa
2026-02-09 16:33:52 +08:00
parent 52c6322a24
commit 28040495c8
63 changed files with 26743 additions and 301 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
node_modules

1
dist/assets/css/index-DbTgNF2L.css vendored Normal file
View 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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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

Binary file not shown.

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
View 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>

View File

@@ -2,9 +2,9 @@
<html lang="zh-CN">
<head>
<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" />
<title>Android远程控制 - Web客户端</title>
<title>Hi远程控制 - Web客户端</title>
</head>
<body>
<div id="root"></div>

766
node_modules/.package-lock.json generated vendored
View File

@@ -404,6 +404,456 @@
"integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==",
"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": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.9.tgz",
@@ -908,6 +1358,306 @@
"dev": true,
"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": {
"version": "4.50.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.50.1.tgz",
@@ -2092,6 +2842,22 @@
"dev": true,
"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": {
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",

View File

@@ -1,77 +1,86 @@
{
"hash": "9654558f",
"configHash": "61db09f5",
"lockfileHash": "8df68275",
"browserHash": "de883d5f",
"hash": "3570225d",
"configHash": "632304f4",
"lockfileHash": "80049c3e",
"browserHash": "517aea31",
"optimized": {
"react": {
"src": "../../react/index.js",
"file": "react.js",
"fileHash": "89247346",
"fileHash": "27c38da4",
"needsInterop": true
},
"react-dom": {
"src": "../../react-dom/index.js",
"file": "react-dom.js",
"fileHash": "0fcfb03c",
"fileHash": "6df64ba4",
"needsInterop": true
},
"@reduxjs/toolkit": {
"src": "../../@reduxjs/toolkit/dist/redux-toolkit.modern.mjs",
"file": "@reduxjs_toolkit.js",
"fileHash": "203e5993",
"fileHash": "9e3eb961",
"needsInterop": false
},
"react-redux": {
"src": "../../react-redux/dist/react-redux.mjs",
"file": "react-redux.js",
"fileHash": "89b531fc",
"fileHash": "612e123d",
"needsInterop": false
},
"antd": {
"src": "../../antd/es/index.js",
"file": "antd.js",
"fileHash": "deb25580",
"fileHash": "19f2bf41",
"needsInterop": false
},
"socket.io-client": {
"src": "../../socket.io-client/build/esm/index.js",
"file": "socket__io-client.js",
"fileHash": "249374af",
"fileHash": "6a923393",
"needsInterop": false
},
"react/jsx-dev-runtime": {
"src": "../../react/jsx-dev-runtime.js",
"file": "react_jsx-dev-runtime.js",
"fileHash": "abc93921",
"fileHash": "561d21fd",
"needsInterop": true
},
"react/jsx-runtime": {
"src": "../../react/jsx-runtime.js",
"file": "react_jsx-runtime.js",
"fileHash": "e422ec20",
"fileHash": "d004368c",
"needsInterop": true
},
"@ant-design/icons": {
"src": "../../@ant-design/icons/es/index.js",
"file": "@ant-design_icons.js",
"fileHash": "5fb36740",
"fileHash": "61c40504",
"needsInterop": false
},
"antd/locale/zh_CN": {
"src": "../../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
},
"react-dom/client": {
"src": "../../react-dom/client.js",
"file": "react-dom_client.js",
"fileHash": "d230ee6e",
"fileHash": "d4fda170",
"needsInterop": true
}
},
"chunks": {
"chunk-624ZMHH6": {
"file": "chunk-624ZMHH6.js"
},
"chunk-5Q2RTODE": {
"file": "chunk-5Q2RTODE.js"
},

283
node_modules/.vite/deps/antd.js generated vendored
View File

@@ -1,4 +1,7 @@
"use client";
import {
require_dayjs_min
} from "./chunk-624ZMHH6.js";
import {
BarsOutlined_default,
CalendarOutlined_default,
@@ -98,286 +101,6 @@ import {
__toESM
} 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
var require_weekday = __commonJS({
"node_modules/dayjs/plugin/weekday.js"(exports, module2) {

File diff suppressed because one or more lines are too long

4712
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

34
package.json Normal file
View 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

Binary file not shown.

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

BIN
public/kb.apk Normal file

Binary file not shown.

1
public/vite.svg Normal file
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

View 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

View 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

View 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

View 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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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
}}
>
4PIN输入
</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
}}
>
6PIN输入
</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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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

View 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

View 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

View 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

View 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

File diff suppressed because it is too large Load Diff

78
src/index.css Normal file
View 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
View 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
View 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

View 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

View 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

View 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
View 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
View 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

View 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

View 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

View 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
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

27
tsconfig.app.json Normal file
View 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
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

25
tsconfig.node.json Normal file
View 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
View 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'
],
}
})