mirror of
https://github.com/sipeed/NanoKVM.git
synced 2026-09-11 00:22:56 -05:00
create web
This commit is contained in:
17
.gitignore
vendored
Normal file
17
.gitignore
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
*.log
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
web/node_modules
|
||||||
|
web/dist
|
||||||
|
web/web
|
||||||
|
|
||||||
|
.DS_Store
|
||||||
|
*.local
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
9
web/.editorconfig
Normal file
9
web/.editorconfig
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
20
web/.eslintrc.cjs
Normal file
20
web/.eslintrc.cjs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
module.exports = {
|
||||||
|
root: true,
|
||||||
|
env: { browser: true, es2020: true },
|
||||||
|
extends: [
|
||||||
|
'eslint:recommended',
|
||||||
|
'plugin:@typescript-eslint/recommended',
|
||||||
|
'plugin:react-hooks/recommended',
|
||||||
|
'prettier',
|
||||||
|
],
|
||||||
|
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
||||||
|
parser: '@typescript-eslint/parser',
|
||||||
|
plugins: ['react-refresh'],
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
|
'react-refresh/only-export-components': [
|
||||||
|
'warn',
|
||||||
|
{ allowConstantExport: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
1
web/.prettierignore
Normal file
1
web/.prettierignore
Normal file
@@ -0,0 +1 @@
|
|||||||
|
*.hbs
|
||||||
35
web/.prettierrc.yaml
Normal file
35
web/.prettierrc.yaml
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
singleQuote: true
|
||||||
|
trailingComma: none
|
||||||
|
printWidth: 100
|
||||||
|
tabWidth: 2
|
||||||
|
bracketSpacing: true
|
||||||
|
importOrder:
|
||||||
|
- ^(react/(.*)$)|^(react$)
|
||||||
|
- ^(next/(.*)$)|^(next$)
|
||||||
|
- <THIRD_PARTY_MODULES>
|
||||||
|
- ''
|
||||||
|
- ^types$
|
||||||
|
- ^@/assets(.*)$
|
||||||
|
- ^@/api/(.*)$
|
||||||
|
- ^@/types$
|
||||||
|
- ^@/lib/(.*)$
|
||||||
|
- ^@/jotai/(.*)$
|
||||||
|
- ^@/hooks/(.*)$
|
||||||
|
- ^@/components/(.*)$
|
||||||
|
- ^@/pages/(.*)$
|
||||||
|
- ^@/styles/(.*)$
|
||||||
|
- ''
|
||||||
|
- '^[./]'
|
||||||
|
importOrderSeparation: false
|
||||||
|
importOrderSortSpecifiers: true
|
||||||
|
importOrderBuiltinModulesToTop: true
|
||||||
|
importOrderParserPlugins:
|
||||||
|
- typescript
|
||||||
|
- jsx
|
||||||
|
- tsx
|
||||||
|
- decorators-legacy
|
||||||
|
importOrderMergeDuplicateImports: true
|
||||||
|
importOrderCombineTypeAndValueImports: true
|
||||||
|
plugins:
|
||||||
|
- '@ianvs/prettier-plugin-sort-imports'
|
||||||
|
- prettier-plugin-tailwindcss
|
||||||
34
web/README.md
Normal file
34
web/README.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# NanoKVM Frontend
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
```shell
|
||||||
|
src
|
||||||
|
├── api // backend api
|
||||||
|
├── assets // static resouces
|
||||||
|
├── components // public components
|
||||||
|
├── i18n // i18n
|
||||||
|
├── jotai // Global jotai variables
|
||||||
|
├── lib // lib
|
||||||
|
├── pages // web pages
|
||||||
|
│ ├── auth // login and password
|
||||||
|
│ ├── desktop // remote desktop
|
||||||
|
│ └── terminal // web terminal
|
||||||
|
├── router.tsx // routers
|
||||||
|
└── types // types
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
Build:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
cd web
|
||||||
|
pnpm install
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
Once compilation is complete, the `dist` folder will be generated. Rename the folder to `web` and upload it to the `/kvmapp/server/` directory in NanoKVM.
|
||||||
|
Then, restart the service by executing `/etc/init.d/S95nanokvm` in NanoKVM.
|
||||||
|
|
||||||
|
Additionally, browser may have old version cache. If you can't open the page, try a force refresh or clear the cache.
|
||||||
36
web/README_ZH.md
Normal file
36
web/README_ZH.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# NanoKVM 前端页面
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```shell
|
||||||
|
src
|
||||||
|
├── api // 后端接口
|
||||||
|
├── assets // 资源文件
|
||||||
|
├── components // 公共组件
|
||||||
|
├── i18n // 多语言
|
||||||
|
├── jotai // 全局 jotai 变量
|
||||||
|
├── lib // lib
|
||||||
|
├── pages // 页面
|
||||||
|
│ ├── auth // 鉴权页面
|
||||||
|
│ ├── desktop // 远程桌面
|
||||||
|
│ └── terminal // 终端
|
||||||
|
├── router.tsx // 路由
|
||||||
|
└── types // 类型定义
|
||||||
|
```
|
||||||
|
|
||||||
|
## 部署
|
||||||
|
|
||||||
|
编译:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
cd web
|
||||||
|
pnpm install
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
编译完成后会生成 `dist` 文件夹,将该文件夹重命名为 `web` 并上传到 NanoKVM 的 `/kvmapp/server/` 目录下。
|
||||||
|
然后在 NanoKVM 中执行 `/etc/init.d/S95nanokvm restart` 重启服务即可。
|
||||||
|
|
||||||
|
|
||||||
|
注意:更新 web 目录后,浏览器可能会有缓存。如果遇到打不开页面的情况,请强制刷新或清空缓存。
|
||||||
|
|
||||||
13
web/index.html
Normal file
13
web/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/sipeed.ico" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>NanoKVM</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
62
web/package.json
Normal file
62
web/package.json
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
{
|
||||||
|
"name": "web",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@ant-design/icons": "^5.3.7",
|
||||||
|
"@xterm/addon-attach": "^0.11.0",
|
||||||
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
|
"@xterm/xterm": "^5.5.0",
|
||||||
|
"antd": "^5.19.2",
|
||||||
|
"axios": "^1.7.2",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"crypto-js": "^4.2.0",
|
||||||
|
"i18next": "^23.11.5",
|
||||||
|
"jotai": "^2.9.0",
|
||||||
|
"js-cookie": "^3.0.5",
|
||||||
|
"lucide-react": "^0.408.0",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-error-boundary": "^4.0.13",
|
||||||
|
"react-helmet-async": "^2.0.5",
|
||||||
|
"react-i18next": "^14.1.2",
|
||||||
|
"react-responsive": "^10.0.0",
|
||||||
|
"react-router-dom": "^6.23.1",
|
||||||
|
"react-simple-keyboard": "^3.7.124",
|
||||||
|
"semver": "^7.6.3",
|
||||||
|
"vaul": "^0.9.1",
|
||||||
|
"websocket": "^1.0.35"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@ianvs/prettier-plugin-sort-imports": "^4.2.1",
|
||||||
|
"@types/crypto-js": "^4.2.2",
|
||||||
|
"@types/js-cookie": "^3.0.6",
|
||||||
|
"@types/react": "^18.2.66",
|
||||||
|
"@types/react-dom": "^18.2.22",
|
||||||
|
"@types/semver": "^7.5.8",
|
||||||
|
"@types/websocket": "^1.0.10",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^7.10.0",
|
||||||
|
"@typescript-eslint/parser": "^7.10.0",
|
||||||
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
|
"autoprefixer": "^10.4.19",
|
||||||
|
"eslint": "^8.57.0",
|
||||||
|
"eslint-config-prettier": "^9.1.0",
|
||||||
|
"eslint-plugin-react": "^7.34.1",
|
||||||
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.6",
|
||||||
|
"postcss": "^8.4.38",
|
||||||
|
"prettier": "^3.2.5",
|
||||||
|
"prettier-plugin-tailwindcss": "^0.5.14",
|
||||||
|
"tailwindcss": "^3.4.3",
|
||||||
|
"typescript": "^5.2.2",
|
||||||
|
"vite": "^5.2.0",
|
||||||
|
"vite-tsconfig-paths": "^4.3.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
5130
web/pnpm-lock.yaml
generated
Normal file
5130
web/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
6
web/postcss.config.js
Normal file
6
web/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
BIN
web/public/sipeed.ico
Normal file
BIN
web/public/sipeed.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
17
web/src/api/auth.ts
Normal file
17
web/src/api/auth.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { http } from '@/lib/http';
|
||||||
|
|
||||||
|
export function login(username: string, password: string) {
|
||||||
|
const data = {
|
||||||
|
username,
|
||||||
|
password
|
||||||
|
};
|
||||||
|
return http.post('/api/auth/login', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function changePassword(username: string, password: string) {
|
||||||
|
const data = {
|
||||||
|
username,
|
||||||
|
password
|
||||||
|
};
|
||||||
|
return http.post('/api/auth/password', data);
|
||||||
|
}
|
||||||
25
web/src/api/firmware.ts
Normal file
25
web/src/api/firmware.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { http } from '@/lib/http.ts';
|
||||||
|
|
||||||
|
// get firmware version
|
||||||
|
export function getVersion() {
|
||||||
|
return http.get('/api/firmware/version');
|
||||||
|
}
|
||||||
|
|
||||||
|
// update firmware to latest version
|
||||||
|
export function update() {
|
||||||
|
return http.request({
|
||||||
|
method: 'post',
|
||||||
|
url: '/api/firmware/update',
|
||||||
|
timeout: 3 * 60 * 1000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if the lib exists
|
||||||
|
export function getLib() {
|
||||||
|
return http.get('/api/firmware/lib');
|
||||||
|
}
|
||||||
|
|
||||||
|
// update lib
|
||||||
|
export function updateLib() {
|
||||||
|
return http.post('/api/firmware/lib/update');
|
||||||
|
}
|
||||||
37
web/src/api/network.ts
Normal file
37
web/src/api/network.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { http } from '@/lib/http.ts';
|
||||||
|
|
||||||
|
// wake on lan
|
||||||
|
export function wol(mac: string) {
|
||||||
|
const data = {
|
||||||
|
mac
|
||||||
|
};
|
||||||
|
return http.post('/api/network/wol', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// get wake-on-lan macs history
|
||||||
|
export function getWolMacs() {
|
||||||
|
return http.get('/api/network/wol/mac');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteWolMac(mac: string) {
|
||||||
|
return http.request({
|
||||||
|
method: 'delete',
|
||||||
|
url: '/api/network/wol/mac',
|
||||||
|
data: { mac }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if tailscale exists
|
||||||
|
export function getTailscale() {
|
||||||
|
return http.get('/api/network/tailscale');
|
||||||
|
}
|
||||||
|
|
||||||
|
// install tailscale
|
||||||
|
export function installTailscale() {
|
||||||
|
return http.post('/api/network/tailscale/install');
|
||||||
|
}
|
||||||
|
|
||||||
|
// run tailscale
|
||||||
|
export function runTailscale() {
|
||||||
|
return http.post('/api/network/tailscale/run');
|
||||||
|
}
|
||||||
37
web/src/api/script.ts
Normal file
37
web/src/api/script.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { http } from '@/lib/http.ts';
|
||||||
|
|
||||||
|
export function uploadScript(formData: FormData) {
|
||||||
|
return http.request({
|
||||||
|
url: '/api/vm/script/upload',
|
||||||
|
method: 'post',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data'
|
||||||
|
},
|
||||||
|
data: formData
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runScript(name: string, type: string) {
|
||||||
|
const data = {
|
||||||
|
name,
|
||||||
|
type
|
||||||
|
};
|
||||||
|
|
||||||
|
return http.request({
|
||||||
|
url: '/api/vm/script/run',
|
||||||
|
method: 'post',
|
||||||
|
data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getScripts() {
|
||||||
|
return http.get('/api/vm/script');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteScript(name: string) {
|
||||||
|
return http.request({
|
||||||
|
url: '/api/vm/script',
|
||||||
|
method: 'delete',
|
||||||
|
data: { name }
|
||||||
|
});
|
||||||
|
}
|
||||||
24
web/src/api/storage.ts
Normal file
24
web/src/api/storage.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { http } from '@/lib/http.ts';
|
||||||
|
|
||||||
|
// get image list
|
||||||
|
export function getImages() {
|
||||||
|
return http.get('/api/storage/images');
|
||||||
|
}
|
||||||
|
|
||||||
|
// get mounted image
|
||||||
|
export function getMountedImage() {
|
||||||
|
return http.get('/api/storage/images/mounted');
|
||||||
|
}
|
||||||
|
|
||||||
|
// mount/unmount image
|
||||||
|
export function mountImage(file?: string) {
|
||||||
|
const data = {
|
||||||
|
file: file ? file : ''
|
||||||
|
};
|
||||||
|
return http.post('/api/storage/image/mount', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// reset hid
|
||||||
|
export function resetHid() {
|
||||||
|
return http.post('/api/storage/hid/reset');
|
||||||
|
}
|
||||||
16
web/src/api/stream.ts
Normal file
16
web/src/api/stream.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { http } from '@/lib/http.ts';
|
||||||
|
|
||||||
|
// get whether the frame detect is enabled
|
||||||
|
export function getFrameDetect() {
|
||||||
|
return http.get('/api/stream/mjpeg/detect');
|
||||||
|
}
|
||||||
|
|
||||||
|
// enable/disable frame detect
|
||||||
|
export function updateFrameDetect() {
|
||||||
|
return http.post('/api/stream/mjpeg/detect');
|
||||||
|
}
|
||||||
|
|
||||||
|
// pause frame detect for a while (prevent a black screen when opening the page for the first time)
|
||||||
|
export function stopFrameDetect() {
|
||||||
|
return http.post('/api/stream/mjpeg/detect/stop');
|
||||||
|
}
|
||||||
15
web/src/api/virtual-device.ts
Normal file
15
web/src/api/virtual-device.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { http } from '@/lib/http.ts';
|
||||||
|
|
||||||
|
// get virtual devices status
|
||||||
|
export function getVirtualDevice() {
|
||||||
|
return http.get('/api/vm/device/virtual');
|
||||||
|
}
|
||||||
|
|
||||||
|
// mount/unmount virtual device
|
||||||
|
export function updateVirtualDevice(device: string) {
|
||||||
|
const data = {
|
||||||
|
device
|
||||||
|
};
|
||||||
|
|
||||||
|
return http.post('/api/vm/device/virtual', data);
|
||||||
|
}
|
||||||
29
web/src/api/vm.ts
Normal file
29
web/src/api/vm.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { http } from '@/lib/http.ts';
|
||||||
|
|
||||||
|
// get NanoKVM information
|
||||||
|
export function getInfo() {
|
||||||
|
return http.get('/api/vm/info');
|
||||||
|
}
|
||||||
|
|
||||||
|
// set gpio value
|
||||||
|
export function setGpio(type: string, duration: number) {
|
||||||
|
const data = {
|
||||||
|
type,
|
||||||
|
duration
|
||||||
|
};
|
||||||
|
return http.post('/api/vm/gpio', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// get led gpio enabled
|
||||||
|
export function getLedGpio() {
|
||||||
|
return http.get('/api/vm/gpio/led');
|
||||||
|
}
|
||||||
|
|
||||||
|
// update screen arguments
|
||||||
|
export function updateScreen(type: string, value: number) {
|
||||||
|
const data = {
|
||||||
|
type,
|
||||||
|
value
|
||||||
|
};
|
||||||
|
return http.post('/api/vm/screen', data);
|
||||||
|
}
|
||||||
1
web/src/assets/images/monitor-x.svg
Normal file
1
web/src/assets/images/monitor-x.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#737373" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-monitor-x"><path d="m14.5 12.5-5-5"/><path d="m9.5 12.5 5-5"/><rect width="20" height="14" x="2" y="3" rx="2"/><path d="M12 17v4"/><path d="M8 21h8"/></svg>
|
||||||
|
After Width: | Height: | Size: 354 B |
23
web/src/assets/styles/index.css
Normal file
23
web/src/assets/styles/index.css
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 10px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: #000000;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #555;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #444;
|
||||||
|
}
|
||||||
97
web/src/assets/styles/keyboard.css
Normal file
97
web/src/assets/styles/keyboard.css
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
.keyboardContainer {
|
||||||
|
display: flex;
|
||||||
|
background-color: #e5e5e5;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0 auto;
|
||||||
|
border-radius: 0 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-keyboard.hg-theme-default {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-keyboard-main.simple-keyboard {
|
||||||
|
width: 640px;
|
||||||
|
min-width: 640px;
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-keyboard-main.simple-keyboard .hg-button {
|
||||||
|
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-keyboard-main.simple-keyboard .hg-row:first-child {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-keyboard-arrows.simple-keyboard {
|
||||||
|
align-self: flex-end;
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-keyboard .hg-button.selectedButton {
|
||||||
|
background: rgba(5, 25, 70, 0.53);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-keyboard .hg-button.emptySpace {
|
||||||
|
pointer-events: none;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-keyboard-arrows .hg-row {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-keyboard-arrows .hg-button {
|
||||||
|
width: 50px;
|
||||||
|
flex-grow: 0;
|
||||||
|
justify-content: center;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.controlArrows {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-flow: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-keyboard-control.simple-keyboard {
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-keyboard-control.simple-keyboard .hg-row:first-child {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-keyboard-control .hg-button {
|
||||||
|
width: 50px;
|
||||||
|
flex-grow: 0;
|
||||||
|
justify-content: center;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 14px;
|
||||||
|
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hg-button.hg-functionBtn.hg-button-space {
|
||||||
|
width: 350px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-keyboard.hg-layout-default .hg-button.hg-highlight {
|
||||||
|
background: rgb(37 99 235);
|
||||||
|
border-bottom: 1px solid #2563eb;
|
||||||
|
box-shadow: 0 1px 3px 0 rgb(37 99 235 / 0.1), 0 1px 2px -1px rgb(37 99 235 / 0.1);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-keyboard.hg-layout-default .hg-button.hg-double{
|
||||||
|
text-align: center;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 16px;
|
||||||
|
}
|
||||||
14
web/src/components/auth.tsx
Normal file
14
web/src/components/auth.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { ReactNode } from 'react';
|
||||||
|
import { Navigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { existToken } from '@/lib/cookie.ts';
|
||||||
|
|
||||||
|
export const ProtectedRoute = ({ children }: { children: ReactNode }) => {
|
||||||
|
const hasToken = existToken();
|
||||||
|
|
||||||
|
if (!hasToken) {
|
||||||
|
return <Navigate to={'/auth/login'} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return children;
|
||||||
|
};
|
||||||
20
web/src/components/head.tsx
Normal file
20
web/src/components/head.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { Helmet, HelmetData } from 'react-helmet-async';
|
||||||
|
|
||||||
|
type HeadProps = {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const helmetData = new HelmetData({});
|
||||||
|
|
||||||
|
export const Head = ({ title = '', description = '' }: HeadProps = {}) => {
|
||||||
|
return (
|
||||||
|
<Helmet
|
||||||
|
helmetData={helmetData}
|
||||||
|
title={title ? `${title} - NanoKVM` : undefined}
|
||||||
|
defaultTitle="NanoKVM"
|
||||||
|
>
|
||||||
|
<meta name="description" content={description} />
|
||||||
|
</Helmet>
|
||||||
|
);
|
||||||
|
};
|
||||||
17
web/src/components/main-error.tsx
Normal file
17
web/src/components/main-error.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { Button } from 'antd';
|
||||||
|
|
||||||
|
export const MainError = () => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex h-screen w-screen flex-col items-center justify-center space-y-5"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
<h2 className="text-lg font-semibold text-red-500">
|
||||||
|
Sorry, there seems to be some problems :(
|
||||||
|
</h2>
|
||||||
|
<Button type="primary" danger onClick={() => window.location.assign(window.location.origin)}>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
9
web/src/components/root.tsx
Normal file
9
web/src/components/root.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Outlet } from 'react-router-dom';
|
||||||
|
|
||||||
|
export const Root = () => {
|
||||||
|
return (
|
||||||
|
<div className="h-screen w-screen">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
144
web/src/i18n/en.ts
Normal file
144
web/src/i18n/en.ts
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
export const en = {
|
||||||
|
translation: {
|
||||||
|
language: 'Language',
|
||||||
|
changePassword: 'Change Password',
|
||||||
|
logout: 'Logout',
|
||||||
|
keyboard: 'Keyboard',
|
||||||
|
images: 'Images',
|
||||||
|
loading: 'Loading',
|
||||||
|
empty: 'Nothing Found',
|
||||||
|
settings: 'Settings',
|
||||||
|
showMouse: 'Show mouse',
|
||||||
|
hideMouse: 'Hide mouse',
|
||||||
|
power: 'Power',
|
||||||
|
reset: 'Reset',
|
||||||
|
powerShort: 'Power (short click)',
|
||||||
|
powerLong: 'Power (long click)',
|
||||||
|
hddLed: 'HDD LED',
|
||||||
|
checkLibFailed: 'Failed to check runtime library, please try again',
|
||||||
|
updateLibFailed: 'Failed to update runtime library, please try again',
|
||||||
|
updatingLib: 'Updating runtime library. Please refresh the page after updating.',
|
||||||
|
checkForUpdate: 'Check for Update',
|
||||||
|
head: {
|
||||||
|
desktop: 'Remote Desktop',
|
||||||
|
login: 'Login',
|
||||||
|
changePassword: 'Change Password',
|
||||||
|
terminal: 'Terminal'
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
login: 'Login',
|
||||||
|
placeholderUsername: 'Please enter username',
|
||||||
|
placeholderPassword: 'please enter password',
|
||||||
|
placeholderPassword2: 'please enter password again',
|
||||||
|
noEmptyUsername: 'username can not be empty',
|
||||||
|
noEmptyPassword: 'password cant not be empty',
|
||||||
|
noAccount: 'Failed to get user information, please refresh web page or reset password',
|
||||||
|
invalidUser: 'invalid username or password',
|
||||||
|
error: 'unexpected error',
|
||||||
|
changePassword: 'Change Password',
|
||||||
|
differentPassword: 'password do not match',
|
||||||
|
illegalUsername: 'username contains illegal characters',
|
||||||
|
illegalPassword: 'password contains illegal characters',
|
||||||
|
forgetPassword: 'Forget Password',
|
||||||
|
resetPassword: 'Reset Password',
|
||||||
|
reset1: 'If you have forgotten the password, please follow the steps to reset it:',
|
||||||
|
reset2: '1. Log into the NanoKVM device via SSH;',
|
||||||
|
reset3: '2. Delete the file in the device: ',
|
||||||
|
reset4: '3. Use the default account to login: ',
|
||||||
|
ok: 'Ok',
|
||||||
|
cancel: 'Cancel'
|
||||||
|
},
|
||||||
|
screen: {
|
||||||
|
resolution: 'Resolution',
|
||||||
|
fps: 'FPS',
|
||||||
|
customizeFps: 'Customize',
|
||||||
|
quality: 'Quality',
|
||||||
|
frameDetect: 'Frame Detect',
|
||||||
|
frameDetectTip:
|
||||||
|
"Calculate the difference between frames. Stop transmitting video stream when no changes are detected on the remote host's screen."
|
||||||
|
},
|
||||||
|
cursor: {
|
||||||
|
default: 'Default cursor',
|
||||||
|
pointer: 'Pointer cursor',
|
||||||
|
cell: 'Cell cursor',
|
||||||
|
text: 'Text cursor',
|
||||||
|
grab: 'Grab cursor',
|
||||||
|
hide: 'Hide cursor',
|
||||||
|
mode: 'Mouse mode',
|
||||||
|
absolute: 'Absolute mode',
|
||||||
|
relative: 'Relative mode',
|
||||||
|
requestPointer: 'Using relative mode. Please click desktop to get mouse pointer.',
|
||||||
|
resetHid: 'Reset HID'
|
||||||
|
},
|
||||||
|
script: {
|
||||||
|
title: 'Script',
|
||||||
|
upload: 'Upload',
|
||||||
|
run: 'Run',
|
||||||
|
runBackground: 'Run Background',
|
||||||
|
runFailed: 'Run failed',
|
||||||
|
attention: 'Attention',
|
||||||
|
delDesc: 'Are you sure to delete this file?',
|
||||||
|
confirm: 'Yes',
|
||||||
|
cancel: 'No',
|
||||||
|
delete: 'Delete',
|
||||||
|
close: 'Close'
|
||||||
|
},
|
||||||
|
terminal: {
|
||||||
|
title: 'Terminal',
|
||||||
|
nanokvm: 'NanoKVM Terminal',
|
||||||
|
serial: 'Serial Port Terminal',
|
||||||
|
serialPort: 'Serial Port',
|
||||||
|
serialPortPlaceholder: 'Please enter the serial port',
|
||||||
|
baudrate: 'Baud rate',
|
||||||
|
confirm: 'Ok'
|
||||||
|
},
|
||||||
|
wol: {
|
||||||
|
sending: 'Sending command...',
|
||||||
|
sent: 'Command sent',
|
||||||
|
input: 'Please enter the MAC',
|
||||||
|
ok: 'Ok'
|
||||||
|
},
|
||||||
|
about: {
|
||||||
|
title: 'About NanoKVM',
|
||||||
|
information: 'Information',
|
||||||
|
ip: 'IP',
|
||||||
|
mdns: 'mDNS',
|
||||||
|
firmware: 'Application Version',
|
||||||
|
image: 'Image Version',
|
||||||
|
deviceKey: 'Device Key',
|
||||||
|
queryFailed: 'Query failed',
|
||||||
|
community: 'Community'
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
title: 'Check for Update',
|
||||||
|
queryFailed: 'Get version failed',
|
||||||
|
updateFailed: 'Update failed. Please retry.',
|
||||||
|
isLatest: 'You already have the latest version.',
|
||||||
|
available: 'An update is available. Are you sure to update?',
|
||||||
|
updating: 'Update started. Please wait...',
|
||||||
|
confirm: 'Confirm',
|
||||||
|
cancel: 'Cancel'
|
||||||
|
},
|
||||||
|
virtualDevice: {
|
||||||
|
network: 'Virtual Network',
|
||||||
|
usb: 'Virtual USB'
|
||||||
|
},
|
||||||
|
tailscale: {
|
||||||
|
loading: 'Loading...',
|
||||||
|
notInstalled: 'Tailscale not found! Please install.',
|
||||||
|
install: 'Install',
|
||||||
|
installing: 'Installing...',
|
||||||
|
urlPeriod: 'This url is valid for 10 minutes',
|
||||||
|
installed: 'Tailscale already installed. Please login.',
|
||||||
|
login: 'Login',
|
||||||
|
failed: 'Install failed',
|
||||||
|
retry: 'Please refresh and try again. Or try to install manually',
|
||||||
|
download: 'Download the',
|
||||||
|
package: 'installation package',
|
||||||
|
unzip: 'and unzip it',
|
||||||
|
upTailscale: 'Upload tailscale to NanoKVM directory /usr/bin/',
|
||||||
|
upTailscaled: 'Upload tailscaled to NanoKVM directory /usr/sbin/',
|
||||||
|
refresh: 'Refresh current page'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
23
web/src/i18n/index.ts
Normal file
23
web/src/i18n/index.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import i18n from 'i18next';
|
||||||
|
import { initReactI18next } from 'react-i18next';
|
||||||
|
|
||||||
|
import { getLanguage } from '@/lib/localstorage.ts';
|
||||||
|
|
||||||
|
import { en } from './en';
|
||||||
|
import { zh } from './zh';
|
||||||
|
|
||||||
|
const lng = getLanguage();
|
||||||
|
|
||||||
|
i18n
|
||||||
|
.use(initReactI18next) // passes i18n down to react-i18next
|
||||||
|
.init({
|
||||||
|
resources: { zh, en },
|
||||||
|
lng,
|
||||||
|
|
||||||
|
interpolation: {
|
||||||
|
escapeValue: false // react already safes from xss
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then();
|
||||||
|
|
||||||
|
export default i18n;
|
||||||
143
web/src/i18n/zh.ts
Normal file
143
web/src/i18n/zh.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
export const zh = {
|
||||||
|
translation: {
|
||||||
|
currentVersion: '版本',
|
||||||
|
latestVersion: '最新版本:',
|
||||||
|
changePassword: '修改密码',
|
||||||
|
logout: '登出',
|
||||||
|
keyboard: '键盘',
|
||||||
|
images: '镜像',
|
||||||
|
loading: '加载中',
|
||||||
|
empty: '无镜像文件',
|
||||||
|
settings: '设置',
|
||||||
|
showMouse: '显示鼠标',
|
||||||
|
hideMouse: '隐藏鼠标',
|
||||||
|
power: '电源',
|
||||||
|
reset: '重启',
|
||||||
|
powerShort: '电源(短按)',
|
||||||
|
powerLong: '电源(长按)',
|
||||||
|
hddLed: '硬盘指示灯',
|
||||||
|
checkLibFailed: '检查运行库失败,请重试',
|
||||||
|
updateLibFailed: '更新运行库失败,请重试',
|
||||||
|
updatingLib: '正在更新运行库。更新完成后请刷新页面。',
|
||||||
|
head: {
|
||||||
|
desktop: '远程桌面',
|
||||||
|
login: '登录',
|
||||||
|
changePassword: '修改密码',
|
||||||
|
terminal: '终端'
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
login: '登录',
|
||||||
|
placeholderUsername: '请输入用户名',
|
||||||
|
placeholderPassword: '请输入密码',
|
||||||
|
placeholderPassword2: '请再次输入密码',
|
||||||
|
noEmptyUsername: '用户名不能为空',
|
||||||
|
noEmptyPassword: '密码不能为空',
|
||||||
|
noAccount: '获取用户信息失败,请刷新重试或重置密码',
|
||||||
|
invalidUser: '用户名或密码错误',
|
||||||
|
error: '未知错误',
|
||||||
|
changePassword: '修改密码',
|
||||||
|
differentPassword: '两次密码不一致',
|
||||||
|
illegalUsername: '用户名中包含非法字符',
|
||||||
|
illegalPassword: '密码中包含非法字符',
|
||||||
|
forgetPassword: '忘记密码',
|
||||||
|
resetPassword: '重置密码',
|
||||||
|
reset1: '如果您忘记了登录密码,请按照以下步骤重置密码:',
|
||||||
|
reset2: '1. 通过 SSH 登录到您的 NanoKVM 设备',
|
||||||
|
reset3: '2. 删除设备中的文件:',
|
||||||
|
reset4: '3. 使用默认的账号登录: ',
|
||||||
|
ok: '确定',
|
||||||
|
cancel: '取消'
|
||||||
|
},
|
||||||
|
screen: {
|
||||||
|
resolution: '分辨率',
|
||||||
|
fps: '帧率',
|
||||||
|
customizeFps: '自定义',
|
||||||
|
quality: '图像质量',
|
||||||
|
frameDetect: '帧差检测',
|
||||||
|
frameDetectTip: '计算帧之间的差异,当检测到远程主机画面不变时,停止传输视频流'
|
||||||
|
},
|
||||||
|
cursor: {
|
||||||
|
default: '默认指针',
|
||||||
|
pointer: '悬浮指针',
|
||||||
|
cell: '单元指针',
|
||||||
|
text: '文本指针',
|
||||||
|
grab: '抓取指针',
|
||||||
|
hide: '隐藏指针',
|
||||||
|
mode: '鼠标模式',
|
||||||
|
absolute: '绝对模式',
|
||||||
|
relative: '相对模式',
|
||||||
|
requestPointer: '正在使用鼠标相对模式,请点击桌面获取鼠标指针。',
|
||||||
|
resetHid: '重置 HID'
|
||||||
|
},
|
||||||
|
script: {
|
||||||
|
title: '脚本',
|
||||||
|
upload: '上传',
|
||||||
|
run: '运行',
|
||||||
|
runBackground: '后台运行',
|
||||||
|
runFailed: '运行失败',
|
||||||
|
attention: '注意',
|
||||||
|
delDesc: '确定要删除该文件吗?',
|
||||||
|
confirm: '确定',
|
||||||
|
cancel: '取消',
|
||||||
|
delete: '删除',
|
||||||
|
close: '关闭'
|
||||||
|
},
|
||||||
|
terminal: {
|
||||||
|
title: '终端',
|
||||||
|
nanokvm: 'NanoKVM 终端',
|
||||||
|
serial: '串口终端',
|
||||||
|
serialPort: '串口',
|
||||||
|
serialPortPlaceholder: '请输入串口',
|
||||||
|
baudrate: '波特率',
|
||||||
|
confirm: '确定'
|
||||||
|
},
|
||||||
|
wol: {
|
||||||
|
sending: '指令发送中...',
|
||||||
|
sent: '指令已发送',
|
||||||
|
input: '请输入MAC地址',
|
||||||
|
ok: '确定'
|
||||||
|
},
|
||||||
|
about: {
|
||||||
|
title: '关于 NanoKVM',
|
||||||
|
information: '信息',
|
||||||
|
ip: 'IP',
|
||||||
|
mdns: 'mDNS',
|
||||||
|
firmware: '应用版本',
|
||||||
|
image: '镜像版本',
|
||||||
|
deviceKey: '设备码',
|
||||||
|
queryFailed: '查询失败',
|
||||||
|
community: '社区'
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
title: '检查更新',
|
||||||
|
queryFailed: '获取版本号失败',
|
||||||
|
updateFailed: '更新失败,请重试',
|
||||||
|
isLatest: '已经是最新版本。',
|
||||||
|
available: '有新的可用版本,确定要更新吗?',
|
||||||
|
updating: '更新中,请稍候...',
|
||||||
|
confirm: '确定',
|
||||||
|
cancel: '取消'
|
||||||
|
},
|
||||||
|
virtualDevice: {
|
||||||
|
network: '虚拟网口',
|
||||||
|
usb: '虚拟U盘'
|
||||||
|
},
|
||||||
|
tailscale: {
|
||||||
|
loading: '加载中...',
|
||||||
|
notInstalled: '未检测到 Tailscale,请先安装',
|
||||||
|
install: '安装',
|
||||||
|
installing: '安装中...',
|
||||||
|
urlPeriod: '该链接10分钟内有效',
|
||||||
|
installed: 'Tailscale 已安装,请执行登录操作',
|
||||||
|
login: '登录',
|
||||||
|
failed: '安装失败',
|
||||||
|
retry: '请刷新后重试,或尝试手动安装',
|
||||||
|
download: '下载',
|
||||||
|
package: '安装包',
|
||||||
|
unzip: '并解压',
|
||||||
|
upTailscale: '将 tailscale 文件上传到 /usr/bin/ 目录',
|
||||||
|
upTailscaled: '将 tailscaled 文件上传到 /usr/sbin/ 目录',
|
||||||
|
refresh: '刷新页面'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
7
web/src/jotai/keyboard.ts
Normal file
7
web/src/jotai/keyboard.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { atom } from 'jotai';
|
||||||
|
|
||||||
|
// is the keyboard enabled (Disable keyboard events when input is required)
|
||||||
|
export const isKeyboardEnableAtom = atom(true);
|
||||||
|
|
||||||
|
// is the virtual keyboard opened
|
||||||
|
export const isKeyboardOpenAtom = atom(false);
|
||||||
7
web/src/jotai/mouse.ts
Normal file
7
web/src/jotai/mouse.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { atom } from 'jotai';
|
||||||
|
|
||||||
|
// mouse cursor style
|
||||||
|
export const mouseStyleAtom = atom('cursor-default');
|
||||||
|
|
||||||
|
// mouse mode: absolute or relative
|
||||||
|
export const mouseModeAtom = atom('absolute');
|
||||||
6
web/src/jotai/resolution.ts
Normal file
6
web/src/jotai/resolution.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { atom } from 'jotai';
|
||||||
|
|
||||||
|
import { Resolution } from '@/types';
|
||||||
|
|
||||||
|
// remote host screen resolution
|
||||||
|
export const resolutionAtom = atom<Resolution | null>(null);
|
||||||
4
web/src/jotai/settings.ts
Normal file
4
web/src/jotai/settings.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { atom } from 'jotai';
|
||||||
|
|
||||||
|
// is settings popover opened
|
||||||
|
export const isSettingsOpenAtom = atom(false);
|
||||||
23
web/src/lib/cookie.ts
Normal file
23
web/src/lib/cookie.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import Cookies from 'js-cookie';
|
||||||
|
|
||||||
|
const COOKIE_TOKEN_KEY = 'nano-kvm-token';
|
||||||
|
|
||||||
|
export function existToken() {
|
||||||
|
const token = Cookies.get(COOKIE_TOKEN_KEY);
|
||||||
|
return !!token;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getToken() {
|
||||||
|
const token = Cookies.get(COOKIE_TOKEN_KEY);
|
||||||
|
if (!token) return null;
|
||||||
|
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setToken(token: string) {
|
||||||
|
Cookies.set(COOKIE_TOKEN_KEY, token, { expires: 30 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeToken() {
|
||||||
|
Cookies.remove(COOKIE_TOKEN_KEY);
|
||||||
|
}
|
||||||
14
web/src/lib/encrypt.ts
Normal file
14
web/src/lib/encrypt.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import CryptoJS from 'crypto-js';
|
||||||
|
|
||||||
|
const SECRET_KEY = 'nanokvm-sipeed-2024';
|
||||||
|
|
||||||
|
export function encrypt(data: string) {
|
||||||
|
const dataEncrypt = CryptoJS.AES.encrypt(data, SECRET_KEY).toString();
|
||||||
|
return encodeURIComponent(dataEncrypt);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decrypt(data: string) {
|
||||||
|
const bytes = CryptoJS.AES.decrypt(data, SECRET_KEY);
|
||||||
|
const dataDecrypt = bytes.toString(CryptoJS.enc.Utf8);
|
||||||
|
return decodeURIComponent(dataDecrypt);
|
||||||
|
}
|
||||||
74
web/src/lib/http.ts
Normal file
74
web/src/lib/http.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';
|
||||||
|
|
||||||
|
import { removeToken } from '@/lib/cookie.ts';
|
||||||
|
|
||||||
|
type Response = {
|
||||||
|
code: number;
|
||||||
|
msg: string;
|
||||||
|
data: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
class Http {
|
||||||
|
private instance: AxiosInstance;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.instance = axios.create({
|
||||||
|
baseURL: this.getBaseURL(),
|
||||||
|
withCredentials: true,
|
||||||
|
timeout: 60 * 1000
|
||||||
|
});
|
||||||
|
|
||||||
|
this.setInterceptors();
|
||||||
|
}
|
||||||
|
|
||||||
|
private setInterceptors() {
|
||||||
|
this.instance.interceptors.request.use((config) => {
|
||||||
|
if (config.headers) {
|
||||||
|
config.headers.Accept = 'application/json';
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.instance.interceptors.response.use(
|
||||||
|
(response) => {
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
console.log(error);
|
||||||
|
const code = error.response?.status;
|
||||||
|
if (code === 401) {
|
||||||
|
removeToken();
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getBaseURL() {
|
||||||
|
return `${window.location.protocol}//${window.location.host}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get(url: string, params?: any): Promise<Response> {
|
||||||
|
return this.instance.request({
|
||||||
|
method: 'get',
|
||||||
|
url,
|
||||||
|
params
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public post(url: string, data?: any): Promise<Response> {
|
||||||
|
return this.instance.request({
|
||||||
|
method: 'post',
|
||||||
|
url,
|
||||||
|
data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public request(config: AxiosRequestConfig): Promise<Response> {
|
||||||
|
return this.instance.request(config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const http = new Http();
|
||||||
117
web/src/lib/localstorage.ts
Normal file
117
web/src/lib/localstorage.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { Resolution } from '@/types';
|
||||||
|
|
||||||
|
const LANGUAGE_KEY = 'nano-kvm-language';
|
||||||
|
const RESOLUTION_KEY = 'nano-kvm-resolution';
|
||||||
|
const FPS_KEY = 'nano-kvm-fps';
|
||||||
|
const QUALITY_KEY = 'nano-kvm-quality';
|
||||||
|
const MOUSE_STYLE_KEY = 'nano-kvm-mouse-style';
|
||||||
|
const MOUSE_MODE_KEY = 'nano-kvm-mouse-mode';
|
||||||
|
const SKIP_UPDATE_KEY = 'nano-kvm-check-update';
|
||||||
|
|
||||||
|
type Item = {
|
||||||
|
value: string;
|
||||||
|
expiry: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// set the value with expiration time (unit: milliseconds)
|
||||||
|
function setWithExpiry(key: string, value: string, ttl: number) {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const item: Item = {
|
||||||
|
value: value,
|
||||||
|
expiry: now.getTime() + ttl
|
||||||
|
};
|
||||||
|
|
||||||
|
localStorage.setItem(key, JSON.stringify(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
// get the value with expiration time
|
||||||
|
function getWithExpiry(key: string) {
|
||||||
|
const itemStr = localStorage.getItem(key);
|
||||||
|
if (!itemStr) return null;
|
||||||
|
|
||||||
|
const item: Item = JSON.parse(itemStr);
|
||||||
|
const now = new Date();
|
||||||
|
if (now.getTime() > item.expiry) {
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return item.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLanguage() {
|
||||||
|
const lang = localStorage.getItem(LANGUAGE_KEY);
|
||||||
|
if (lang) return lang;
|
||||||
|
|
||||||
|
if (navigator.language.indexOf('zh') > -1) {
|
||||||
|
return 'zh';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'en';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setLanguage(language: string) {
|
||||||
|
localStorage.setItem(LANGUAGE_KEY, language);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getResolution(): Resolution | null {
|
||||||
|
const resolution = localStorage.getItem(RESOLUTION_KEY);
|
||||||
|
if (resolution) {
|
||||||
|
const obj = JSON.parse(window.atob(resolution));
|
||||||
|
return obj as Resolution;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setResolution(resolution: Resolution) {
|
||||||
|
localStorage.setItem(RESOLUTION_KEY, window.btoa(JSON.stringify(resolution)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFps() {
|
||||||
|
const fps = localStorage.getItem(FPS_KEY);
|
||||||
|
return fps ? Number(fps) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setFps(fps: number) {
|
||||||
|
localStorage.setItem(FPS_KEY, String(fps));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getQuality() {
|
||||||
|
const quality = localStorage.getItem(QUALITY_KEY);
|
||||||
|
return quality ? Number(quality) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setQuality(quality: number) {
|
||||||
|
localStorage.setItem(QUALITY_KEY, String(quality));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMouseStyle() {
|
||||||
|
return localStorage.getItem(MOUSE_STYLE_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setMouseStyle(mouse: string) {
|
||||||
|
localStorage.setItem(MOUSE_STYLE_KEY, mouse);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMouseMode() {
|
||||||
|
return localStorage.getItem(MOUSE_MODE_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setMouseMode(mouse: string) {
|
||||||
|
localStorage.setItem(MOUSE_MODE_KEY, mouse);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSkipUpdate() {
|
||||||
|
const skip = getWithExpiry(SKIP_UPDATE_KEY);
|
||||||
|
if (skip) {
|
||||||
|
return Boolean(skip);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSkipUpdate(skip: boolean) {
|
||||||
|
const expiry = 3 * 24 * 60 * 60 * 1000; // 3天
|
||||||
|
setWithExpiry(SKIP_UPDATE_KEY, String(skip), expiry);
|
||||||
|
}
|
||||||
39
web/src/lib/websocket.ts
Normal file
39
web/src/lib/websocket.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { w3cwebsocket as W3cWebSocket } from 'websocket';
|
||||||
|
|
||||||
|
class WsClient {
|
||||||
|
private instance: W3cWebSocket;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
const url = this.getUrl();
|
||||||
|
this.instance = new W3cWebSocket(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getUrl() {
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
return `${protocol}//${window.location.host}/api/ws`;
|
||||||
|
}
|
||||||
|
|
||||||
|
public connect() {
|
||||||
|
this.close();
|
||||||
|
|
||||||
|
const url = this.getUrl();
|
||||||
|
this.instance = new W3cWebSocket(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
public send(data: number[]) {
|
||||||
|
if (this.instance.readyState !== W3cWebSocket.OPEN) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = JSON.stringify(data);
|
||||||
|
this.instance.send(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public close() {
|
||||||
|
if (this.instance.readyState === W3cWebSocket.OPEN) {
|
||||||
|
this.instance.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const client = new WsClient();
|
||||||
32
web/src/main.tsx
Normal file
32
web/src/main.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import React, { Suspense } from 'react';
|
||||||
|
import { ConfigProvider, Spin, theme } from 'antd';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import { ErrorBoundary } from 'react-error-boundary';
|
||||||
|
import { HelmetProvider } from 'react-helmet-async';
|
||||||
|
import { RouterProvider } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { MainError } from './components/main-error.tsx';
|
||||||
|
import { router } from './router';
|
||||||
|
|
||||||
|
import './i18n';
|
||||||
|
import './assets/styles/index.css';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div className="flex h-screen w-screen items-center justify-center">
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ErrorBoundary FallbackComponent={MainError}>
|
||||||
|
<HelmetProvider>
|
||||||
|
<ConfigProvider theme={{ algorithm: theme.darkAlgorithm }}>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</ConfigProvider>
|
||||||
|
</HelmetProvider>
|
||||||
|
</ErrorBoundary>
|
||||||
|
</Suspense>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
106
web/src/pages/auth/login/index.tsx
Normal file
106
web/src/pages/auth/login/index.tsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { LockOutlined, UserOutlined } from '@ant-design/icons';
|
||||||
|
import { Button, Form, Input } from 'antd';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
import * as api from '@/api/auth.ts';
|
||||||
|
import { existToken, setToken } from '@/lib/cookie.ts';
|
||||||
|
import { encrypt } from '@/lib/encrypt.ts';
|
||||||
|
import { Head } from '@/components/head.tsx';
|
||||||
|
|
||||||
|
import { Tips } from './tips.tsx';
|
||||||
|
|
||||||
|
export const Login = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [msg, setMsg] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (existToken()) {
|
||||||
|
navigate('/', { replace: true });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (msg) {
|
||||||
|
setTimeout(() => setMsg(''), 3000);
|
||||||
|
}
|
||||||
|
}, [msg]);
|
||||||
|
|
||||||
|
function login(values: any) {
|
||||||
|
const username = values.username;
|
||||||
|
const password = encrypt(values.password);
|
||||||
|
|
||||||
|
api
|
||||||
|
.login(username, password)
|
||||||
|
.then((rsp: any) => {
|
||||||
|
if (rsp.code !== 0) {
|
||||||
|
if (rsp.code === -3) {
|
||||||
|
setMsg(t('auth.noAccount'));
|
||||||
|
} else if (rsp.code === -4) {
|
||||||
|
setMsg(t('auth.invalidUser'));
|
||||||
|
} else {
|
||||||
|
setMsg(t('auth.error'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setMsg('');
|
||||||
|
setToken(rsp.data.token);
|
||||||
|
|
||||||
|
navigate('/', { replace: true });
|
||||||
|
window.location.reload();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setMsg(t('auth.error'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head title={t('head.login')} />
|
||||||
|
|
||||||
|
<div className="flex h-screen w-screen flex-col items-center justify-center">
|
||||||
|
<h2 className="text-xl font-semibold text-neutral-100">{t('auth.login')}</h2>
|
||||||
|
|
||||||
|
<Form
|
||||||
|
style={{ minWidth: 300, maxWidth: 500 }}
|
||||||
|
initialValues={{ remember: true }}
|
||||||
|
onFinish={login}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
name="username"
|
||||||
|
rules={[{ required: true, message: t('auth.noEmptyUsername'), min: 1 }]}
|
||||||
|
>
|
||||||
|
<Input prefix={<UserOutlined />} placeholder={t('auth.placeholderUsername')} />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="password"
|
||||||
|
rules={[{ required: true, message: t('auth.noEmptyPassword'), min: 1 }]}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
prefix={<LockOutlined />}
|
||||||
|
type="password"
|
||||||
|
placeholder={t('auth.placeholderPassword')}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<div className="text-red-500">{msg}</div>
|
||||||
|
|
||||||
|
<Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit" className="w-full">
|
||||||
|
{t('auth.ok')}
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<div className="flex justify-end pb-4 text-sm">
|
||||||
|
<Tips />
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
57
web/src/pages/auth/login/tips.tsx
Normal file
57
web/src/pages/auth/login/tips.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Button, Modal, Typography } from 'antd';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
export const Tips = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const showModal = () => {
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hideModal = () => {
|
||||||
|
setIsModalOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className="cursor-pointer text-neutral-500 underline underline-offset-4"
|
||||||
|
onClick={showModal}
|
||||||
|
>
|
||||||
|
{t('auth.forgetPassword')}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={t('auth.resetPassword')}
|
||||||
|
open={isModalOpen}
|
||||||
|
onCancel={hideModal}
|
||||||
|
closeIcon={null}
|
||||||
|
footer={null}
|
||||||
|
centered={true}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col space-y-1 pt-1">
|
||||||
|
<div>{t('auth.reset1')}</div>
|
||||||
|
<div>{t('auth.reset2')}</div>
|
||||||
|
<div>
|
||||||
|
{t('auth.reset3')}
|
||||||
|
<Text code={true}>/etc/kvm/pwd</Text>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{t('auth.reset4')}
|
||||||
|
<Text code={true}>admin/admin</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-center pb-3 pt-10">
|
||||||
|
<Button type="primary" className="w-24" onClick={hideModal}>
|
||||||
|
{t('auth.ok')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
117
web/src/pages/auth/password/index.tsx
Normal file
117
web/src/pages/auth/password/index.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { LockOutlined, UserOutlined } from '@ant-design/icons';
|
||||||
|
import { Button, Form, Input } from 'antd';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
import * as api from '@/api/auth.ts';
|
||||||
|
import { removeToken } from '@/lib/cookie.ts';
|
||||||
|
import { encrypt } from '@/lib/encrypt.ts';
|
||||||
|
import { Head } from '@/components/head.tsx';
|
||||||
|
|
||||||
|
export const Password = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [msg, setMsg] = useState('');
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (msg) {
|
||||||
|
setTimeout(() => setMsg(''), 3000);
|
||||||
|
}
|
||||||
|
}, [msg]);
|
||||||
|
|
||||||
|
function changePassword(values: any) {
|
||||||
|
if (values.password !== values.password2) {
|
||||||
|
setMsg(t('auth.differentPassword'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!validateString(values.username)) {
|
||||||
|
setMsg(t('auth.illegalUsername'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!validateString(values.password)) {
|
||||||
|
setMsg('auth.illegalPassword');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const username = values.username;
|
||||||
|
const password = encrypt(values.password);
|
||||||
|
|
||||||
|
api
|
||||||
|
.changePassword(username, password)
|
||||||
|
.then((rsp: any) => {
|
||||||
|
if (rsp.code !== 0) {
|
||||||
|
setMsg(t('auth.error'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
removeToken();
|
||||||
|
navigate('/auth/login', { replace: true });
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setMsg(t('auth.error'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateString(str: string) {
|
||||||
|
const regex = /['"\\/]/;
|
||||||
|
return !regex.test(str);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head title={t('head.changePassword')} />
|
||||||
|
|
||||||
|
<div className="flex h-screen w-screen flex-col items-center justify-center">
|
||||||
|
<h2 className="text-xl font-semibold text-neutral-100">{t('auth.changePassword')}</h2>
|
||||||
|
|
||||||
|
<Form
|
||||||
|
style={{ minWidth: 300, maxWidth: 500 }}
|
||||||
|
initialValues={{ remember: true }}
|
||||||
|
onFinish={changePassword}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
name="username"
|
||||||
|
rules={[{ required: true, message: t('auth.noEmptyUsername'), min: 1 }]}
|
||||||
|
>
|
||||||
|
<Input prefix={<UserOutlined />} placeholder={t('auth.placeholderUsername')} />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="password"
|
||||||
|
rules={[{ required: true, message: t('auth.noEmptyPassword'), min: 1 }]}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
prefix={<LockOutlined />}
|
||||||
|
type="password"
|
||||||
|
placeholder={t('auth.placeholderPassword')}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="password2"
|
||||||
|
rules={[{ required: true, message: t('auth.noEmptyPassword'), min: 1 }]}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
prefix={<LockOutlined />}
|
||||||
|
type="password"
|
||||||
|
placeholder={t('auth.placeholderPassword2')}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<span className="text-red-500">{msg}</span>
|
||||||
|
<Form.Item>
|
||||||
|
<div className="flex w-full space-x-2">
|
||||||
|
<Button type="primary" htmlType="submit" className="w-1/2">
|
||||||
|
{t('auth.ok')}
|
||||||
|
</Button>
|
||||||
|
<Button className="w-1/2" onClick={() => navigate('/')}>
|
||||||
|
{t('auth.cancel')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
113
web/src/pages/desktop/index.tsx
Normal file
113
web/src/pages/desktop/index.tsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { message, Spin } from 'antd';
|
||||||
|
import { useAtom, useAtomValue } from 'jotai';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useMediaQuery } from 'react-responsive';
|
||||||
|
|
||||||
|
import * as api from '@/api/firmware.ts';
|
||||||
|
import { getResolution } from '@/lib/localstorage.ts';
|
||||||
|
import { client } from '@/lib/websocket.ts';
|
||||||
|
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
|
||||||
|
import { resolutionAtom } from '@/jotai/resolution.ts';
|
||||||
|
import { Head } from '@/components/head.tsx';
|
||||||
|
|
||||||
|
import { Keyboard } from './keyboard';
|
||||||
|
import { VirtualKeyboard } from './keyboard/virtual-keyboard';
|
||||||
|
import { Menu } from './menu';
|
||||||
|
import { MenuPhone } from './menu-phone';
|
||||||
|
import { Mouse } from './mouse';
|
||||||
|
import { Screen } from './screen';
|
||||||
|
|
||||||
|
export const Desktop = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const isBigScreen = useMediaQuery({ minWidth: 850 });
|
||||||
|
const [messageApi, contextHolder] = message.useMessage();
|
||||||
|
|
||||||
|
const isKeyboardEnable = useAtomValue(isKeyboardEnableAtom);
|
||||||
|
const [resolution, setResolution] = useAtom(resolutionAtom);
|
||||||
|
|
||||||
|
const [isUpdating, setIsUpdating] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getLib();
|
||||||
|
|
||||||
|
const cookieResolution = getResolution();
|
||||||
|
setResolution(cookieResolution ? cookieResolution : { width: 1280, height: 720 });
|
||||||
|
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
client.send([0]);
|
||||||
|
}, 1000 * 60);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(timer);
|
||||||
|
client.close();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function getLib() {
|
||||||
|
api.getLib().then((rsp) => {
|
||||||
|
if (rsp.code !== 0) {
|
||||||
|
showMessage(t('checkLibFailed'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rsp.data.exist) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadLib();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新 lib
|
||||||
|
function downloadLib() {
|
||||||
|
setIsUpdating(true);
|
||||||
|
|
||||||
|
api
|
||||||
|
.updateLib()
|
||||||
|
.then((rsp) => {
|
||||||
|
if (rsp.code !== 0) {
|
||||||
|
showMessage(t('updateLibFailed'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, 6000);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsUpdating(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showMessage(content: string) {
|
||||||
|
messageApi.open({
|
||||||
|
type: 'warning',
|
||||||
|
content,
|
||||||
|
duration: 10,
|
||||||
|
style: {
|
||||||
|
marginTop: '8vh'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head title={t('head.desktop')} />
|
||||||
|
|
||||||
|
{contextHolder}
|
||||||
|
<Spin spinning={isUpdating} tip={t('updatingLib')} size="large" fullscreen />
|
||||||
|
|
||||||
|
{resolution && (
|
||||||
|
<>
|
||||||
|
{isBigScreen ? <Menu /> : <MenuPhone />}
|
||||||
|
<Screen />
|
||||||
|
<Mouse />
|
||||||
|
{isKeyboardEnable && <Keyboard />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<VirtualKeyboard isBigScreen={isBigScreen} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
66
web/src/pages/desktop/keyboard/index.tsx
Normal file
66
web/src/pages/desktop/keyboard/index.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
import { client } from '@/lib/websocket.ts';
|
||||||
|
|
||||||
|
import { KeyboardCodes } from './mappings.ts';
|
||||||
|
|
||||||
|
export const Keyboard = () => {
|
||||||
|
const metaRef = useRef(false);
|
||||||
|
|
||||||
|
// listen keyboard events
|
||||||
|
useEffect(() => {
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
window.addEventListener('keyup', handleKeyUp);
|
||||||
|
|
||||||
|
// press button
|
||||||
|
function handleKeyDown(event: any) {
|
||||||
|
disableEvent(event);
|
||||||
|
|
||||||
|
if (event.code === 'MetaLeft' || event.code === 'MetaRight') {
|
||||||
|
metaRef.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
metaRef.current = false;
|
||||||
|
|
||||||
|
const code = KeyboardCodes.get(event.code);
|
||||||
|
if (!code) {
|
||||||
|
console.log('unknown code: ', event.code);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctrl = event.ctrlKey ? 1 : 0;
|
||||||
|
const shift = event.shiftKey ? 1 : 0;
|
||||||
|
const alt = event.altKey ? 1 : 0;
|
||||||
|
const meta = event.metaKey ? 1 : 0;
|
||||||
|
|
||||||
|
const data = [1, code, ctrl, shift, alt, meta];
|
||||||
|
client.send(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// release button
|
||||||
|
function handleKeyUp(event: any) {
|
||||||
|
disableEvent(event);
|
||||||
|
|
||||||
|
if (metaRef.current) {
|
||||||
|
client.send([1, KeyboardCodes.get('MetaLeft')!, 0, 0, 0, 1]);
|
||||||
|
metaRef.current = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = [1, 0, 0, 0, 0, 0];
|
||||||
|
client.send(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
window.removeEventListener('keyup', handleKeyUp);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// disable the default keyboard events
|
||||||
|
function disableEvent(event: any) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
|
||||||
|
return <></>;
|
||||||
|
};
|
||||||
119
web/src/pages/desktop/keyboard/mappings.ts
Normal file
119
web/src/pages/desktop/keyboard/mappings.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
export const KeyboardCodes: Map<string, number> = new Map([
|
||||||
|
['KeyA', 4],
|
||||||
|
['KeyB', 5],
|
||||||
|
['KeyC', 6],
|
||||||
|
['KeyD', 7],
|
||||||
|
['KeyE', 8],
|
||||||
|
['KeyF', 9],
|
||||||
|
['KeyG', 10],
|
||||||
|
['KeyH', 11],
|
||||||
|
['KeyI', 12],
|
||||||
|
['KeyJ', 13],
|
||||||
|
['KeyK', 14],
|
||||||
|
['KeyL', 15],
|
||||||
|
['KeyM', 16],
|
||||||
|
['KeyN', 17],
|
||||||
|
['KeyO', 18],
|
||||||
|
['KeyP', 19],
|
||||||
|
['KeyQ', 20],
|
||||||
|
['KeyR', 21],
|
||||||
|
['KeyS', 22],
|
||||||
|
['KeyT', 23],
|
||||||
|
['KeyU', 24],
|
||||||
|
['KeyV', 25],
|
||||||
|
['KeyW', 26],
|
||||||
|
['KeyX', 27],
|
||||||
|
['KeyY', 28],
|
||||||
|
['KeyZ', 29],
|
||||||
|
|
||||||
|
['Digit1', 30],
|
||||||
|
['Digit2', 31],
|
||||||
|
['Digit3', 32],
|
||||||
|
['Digit4', 33],
|
||||||
|
['Digit5', 34],
|
||||||
|
['Digit6', 35],
|
||||||
|
['Digit7', 36],
|
||||||
|
['Digit8', 37],
|
||||||
|
['Digit9', 38],
|
||||||
|
['Digit0', 39],
|
||||||
|
|
||||||
|
['Enter', 40],
|
||||||
|
['Escape', 41],
|
||||||
|
['Backspace', 42],
|
||||||
|
['Tab', 43],
|
||||||
|
['Space', 44],
|
||||||
|
['Minus', 45],
|
||||||
|
['Equal', 46],
|
||||||
|
['BracketLeft', 47],
|
||||||
|
['BracketRight', 48],
|
||||||
|
['Backslash', 49],
|
||||||
|
['IntlBackslash', 49],
|
||||||
|
|
||||||
|
['Semicolon', 51],
|
||||||
|
['Quote', 52],
|
||||||
|
['Backquote', 53],
|
||||||
|
['KeyTilde', 53],
|
||||||
|
['Comma', 54],
|
||||||
|
['Period', 55],
|
||||||
|
['KeyDot', 55],
|
||||||
|
['Slash', 56],
|
||||||
|
['CapsLock', 57],
|
||||||
|
|
||||||
|
['F1', 58],
|
||||||
|
['F2', 59],
|
||||||
|
['F3', 60],
|
||||||
|
['F4', 61],
|
||||||
|
['F5', 62],
|
||||||
|
['F6', 63],
|
||||||
|
['F7', 64],
|
||||||
|
['F8', 65],
|
||||||
|
['F9', 66],
|
||||||
|
['F10', 67],
|
||||||
|
['F11', 68],
|
||||||
|
['F12', 69],
|
||||||
|
['F13', 70],
|
||||||
|
|
||||||
|
['PrintScreen', 70],
|
||||||
|
['ScrollLock', 71],
|
||||||
|
['Pause', 72],
|
||||||
|
['Insert', 73],
|
||||||
|
['Home', 74],
|
||||||
|
['PageUp', 75],
|
||||||
|
['Delete', 76],
|
||||||
|
['End', 77],
|
||||||
|
['PageDown', 78],
|
||||||
|
['ArrowRight', 79],
|
||||||
|
['ArrowLeft', 80],
|
||||||
|
['ArrowDown', 81],
|
||||||
|
['ArrowUp', 82],
|
||||||
|
|
||||||
|
['NumLock', 83],
|
||||||
|
['NumpadDivide', 84],
|
||||||
|
['NumpadMultiply', 85],
|
||||||
|
['NumpadSubtract', 86],
|
||||||
|
['NumpadAdd', 87],
|
||||||
|
['NumpadEnter', 88],
|
||||||
|
['Numpad1', 89],
|
||||||
|
['Numpad2', 90],
|
||||||
|
['Numpad3', 91],
|
||||||
|
['Numpad4', 92],
|
||||||
|
['Numpad5', 93],
|
||||||
|
['Numpad6', 94],
|
||||||
|
['Numpad7', 95],
|
||||||
|
['Numpad8', 96],
|
||||||
|
['Numpad9', 97],
|
||||||
|
['Numpad0', 98],
|
||||||
|
['NumpadDecimal', 99],
|
||||||
|
['KeyKpDot', 99],
|
||||||
|
|
||||||
|
['ControlLeft', 224],
|
||||||
|
['ShiftLeft', 225],
|
||||||
|
['AltLeft', 226],
|
||||||
|
['MetaLeft', 227],
|
||||||
|
['WinLeft', 227],
|
||||||
|
['ControlRight', 228],
|
||||||
|
['ShiftRight', 229],
|
||||||
|
['AltRight', 230],
|
||||||
|
['MetaRight', 231],
|
||||||
|
['WinRight', 231]
|
||||||
|
]);
|
||||||
185
web/src/pages/desktop/keyboard/virtual-keyboard.tsx
Normal file
185
web/src/pages/desktop/keyboard/virtual-keyboard.tsx
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { useAtom } from 'jotai';
|
||||||
|
import { XIcon } from 'lucide-react';
|
||||||
|
import Keyboard, { KeyboardButtonTheme } from 'react-simple-keyboard';
|
||||||
|
import { Drawer } from 'vaul';
|
||||||
|
|
||||||
|
import 'react-simple-keyboard/build/css/index.css';
|
||||||
|
import '@/assets/styles/keyboard.css';
|
||||||
|
|
||||||
|
import { client } from '@/lib/websocket.ts';
|
||||||
|
import { isKeyboardOpenAtom } from '@/jotai/keyboard.ts';
|
||||||
|
|
||||||
|
import { KeyboardCodes } from './mappings.ts';
|
||||||
|
import {
|
||||||
|
doubleKeys,
|
||||||
|
keyboardArrowsOptions,
|
||||||
|
keyboardControlPadOptions,
|
||||||
|
keyboardOptions,
|
||||||
|
modifierKeys,
|
||||||
|
specialKeyMap
|
||||||
|
} from './virtual-keys.ts';
|
||||||
|
|
||||||
|
type KeyboardProps = {
|
||||||
|
isBigScreen: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const VirtualKeyboard = ({ isBigScreen }: KeyboardProps) => {
|
||||||
|
const [isKeyboardOpen, setIsKeyboardOpen] = useAtom(isKeyboardOpenAtom);
|
||||||
|
|
||||||
|
const [isCapsLock, setIsCapsLock] = useState(false);
|
||||||
|
const [activeModifierKeys, setActiveModifierKeys] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const keyboardRef = useRef<any>(null);
|
||||||
|
|
||||||
|
function onKeyPress(key: string) {
|
||||||
|
if (modifierKeys.includes(key)) {
|
||||||
|
if (activeModifierKeys.includes(key)) {
|
||||||
|
sendModifierKeyDown();
|
||||||
|
sendModifierKeyUp();
|
||||||
|
} else {
|
||||||
|
setActiveModifierKeys([...activeModifierKeys, key]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (key === '{capslock}') {
|
||||||
|
setIsCapsLock(!isCapsLock);
|
||||||
|
}
|
||||||
|
|
||||||
|
sendKeydown(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeyReleased(key: string) {
|
||||||
|
if (modifierKeys.includes(key)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendKeyup();
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendKeydown(key: string) {
|
||||||
|
const specialKey = specialKeyMap.get(key);
|
||||||
|
const code = KeyboardCodes.get(specialKey ? specialKey : key);
|
||||||
|
if (!code) {
|
||||||
|
console.log('unknown code: ', key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const modifiers = sendModifierKeyDown();
|
||||||
|
|
||||||
|
client.send([1, code, ...modifiers]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendKeyup() {
|
||||||
|
sendModifierKeyUp();
|
||||||
|
client.send([1, 0, 0, 0, 0, 0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendModifierKeyDown() {
|
||||||
|
let ctrl = 0;
|
||||||
|
let shift = 0;
|
||||||
|
let alt = 0;
|
||||||
|
let meta = 0;
|
||||||
|
|
||||||
|
activeModifierKeys.forEach((modifierKey) => {
|
||||||
|
const specialKey = specialKeyMap.get(modifierKey)!;
|
||||||
|
const code = KeyboardCodes.get(specialKey)!;
|
||||||
|
|
||||||
|
if ([224, 228].includes(code)) {
|
||||||
|
ctrl = 1;
|
||||||
|
} else if ([225, 229].includes(code)) {
|
||||||
|
shift = 1;
|
||||||
|
} else if ([226, 230].includes(code)) {
|
||||||
|
alt = 1;
|
||||||
|
} else if ([227, 231].includes(code)) {
|
||||||
|
meta = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
client.send([1, code, ctrl, shift, alt, meta]);
|
||||||
|
});
|
||||||
|
|
||||||
|
return [ctrl, shift, alt, meta];
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendModifierKeyUp() {
|
||||||
|
if (activeModifierKeys.length === 0) return;
|
||||||
|
|
||||||
|
activeModifierKeys.forEach(() => {
|
||||||
|
client.send([1, 0, 0, 0, 0, 0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
setActiveModifierKeys([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getButtonTheme(): KeyboardButtonTheme[] {
|
||||||
|
const theme = [{ class: 'hg-double', buttons: doubleKeys.join(' ') }];
|
||||||
|
|
||||||
|
const activeKeys = [...activeModifierKeys];
|
||||||
|
if (isCapsLock) {
|
||||||
|
activeKeys.push('{capslock}');
|
||||||
|
}
|
||||||
|
if (activeKeys.length > 0) {
|
||||||
|
theme.push({ class: 'hg-highlight', buttons: activeKeys.join(' ') });
|
||||||
|
}
|
||||||
|
|
||||||
|
return theme;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer.Root open={isKeyboardOpen} onOpenChange={setIsKeyboardOpen} modal={false}>
|
||||||
|
<Drawer.Portal>
|
||||||
|
<Drawer.Content
|
||||||
|
className={clsx(
|
||||||
|
'fixed bottom-0 left-0 right-0 z-[999] mx-auto overflow-hidden rounded bg-white outline-none',
|
||||||
|
isBigScreen ? 'w-[820px]' : 'w-[650px]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* header */}
|
||||||
|
<div className="flex items-center justify-between px-3 py-1">
|
||||||
|
<div className="w-[100px] text-sm font-bold text-neutral-500">Keyboard</div>
|
||||||
|
<div className="h-1.5 w-12 flex-shrink-0 rounded-full bg-zinc-300" />
|
||||||
|
<div className="flex w-[100px] items-center justify-end">
|
||||||
|
<div
|
||||||
|
className="flex h-[20px] w-[20px] cursor-pointer items-center justify-center rounded text-neutral-600 hover:bg-neutral-300 hover:text-white"
|
||||||
|
onClick={() => setIsKeyboardOpen(false)}
|
||||||
|
>
|
||||||
|
<XIcon size={18} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="h-px flex-shrink-0 border-b bg-neutral-300" />
|
||||||
|
|
||||||
|
<div data-vaul-no-drag className="keyboardContainer w-full">
|
||||||
|
{/* main keyboard */}
|
||||||
|
<Keyboard
|
||||||
|
buttonTheme={getButtonTheme()}
|
||||||
|
keyboardRef={(r) => (keyboardRef.current = r)}
|
||||||
|
onKeyPress={onKeyPress}
|
||||||
|
onKeyReleased={onKeyReleased}
|
||||||
|
{...keyboardOptions}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* control keyboard */}
|
||||||
|
{isBigScreen && (
|
||||||
|
<div className="controlArrows">
|
||||||
|
<Keyboard
|
||||||
|
onKeyPress={onKeyPress}
|
||||||
|
onKeyReleased={onKeyReleased}
|
||||||
|
{...keyboardControlPadOptions}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Keyboard
|
||||||
|
onKeyPress={onKeyPress}
|
||||||
|
onKeyReleased={onKeyReleased}
|
||||||
|
{...keyboardArrowsOptions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Drawer.Content>
|
||||||
|
<Drawer.Overlay />
|
||||||
|
</Drawer.Portal>
|
||||||
|
</Drawer.Root>
|
||||||
|
);
|
||||||
|
};
|
||||||
182
web/src/pages/desktop/keyboard/virtual-keys.ts
Normal file
182
web/src/pages/desktop/keyboard/virtual-keys.ts
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
// main keys
|
||||||
|
export const keyboardOptions = {
|
||||||
|
theme: 'simple-keyboard hg-theme-default hg-layout-default',
|
||||||
|
baseClass: 'simple-keyboard-main',
|
||||||
|
layout: {
|
||||||
|
default: [
|
||||||
|
'{escape} F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12',
|
||||||
|
'Backquote Digit1 Digit2 Digit3 Digit4 Digit5 Digit6 Digit7 Digit8 Digit9 Digit0 Minus Equal {backspace}',
|
||||||
|
'{tab} KeyQ KeyW KeyE KeyR KeyT KeyY KeyU KeyI KeyO KeyP BracketLeft BracketRight Backslash',
|
||||||
|
'{capslock} KeyA KeyS KeyD KeyF KeyG KeyH KeyJ KeyK KeyL Semicolon Quote {enter}',
|
||||||
|
'{shiftleft} KeyZ KeyX KeyC KeyV KeyB KeyN KeyM Comma Period Slash {shiftright}',
|
||||||
|
'{controlleft} {altleft} {metaleft} {space} {metaright} {altright}'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
display: {
|
||||||
|
'{escape}': 'Esc',
|
||||||
|
Backquote: '~<br/>`',
|
||||||
|
Digit1: '!<br/>1',
|
||||||
|
Digit2: '@<br/>2',
|
||||||
|
Digit3: '#<br/>3',
|
||||||
|
Digit4: '$<br/>4',
|
||||||
|
Digit5: '%<br/>5',
|
||||||
|
Digit6: '^<br/>6',
|
||||||
|
Digit7: '&<br/>7',
|
||||||
|
Digit8: '*<br/>8',
|
||||||
|
Digit9: '(<br/>9',
|
||||||
|
Digit0: ')<br/>0',
|
||||||
|
Minus: '_<br/>-',
|
||||||
|
Equal: '+<br/>=',
|
||||||
|
'{backspace}': 'Backspace',
|
||||||
|
|
||||||
|
'{tab}': 'Tab',
|
||||||
|
KeyQ: 'Q',
|
||||||
|
KeyW: 'W',
|
||||||
|
KeyE: 'E',
|
||||||
|
KeyR: 'R',
|
||||||
|
KeyT: 'T',
|
||||||
|
KeyY: 'Y',
|
||||||
|
KeyU: 'U',
|
||||||
|
KeyI: 'I',
|
||||||
|
KeyO: 'O',
|
||||||
|
KeyP: 'P',
|
||||||
|
BracketLeft: '{<br/>[',
|
||||||
|
BracketRight: '}<br/>]',
|
||||||
|
Backslash: '|<br>\\',
|
||||||
|
|
||||||
|
'{capslock}': 'Caps',
|
||||||
|
KeyA: 'A',
|
||||||
|
KeyS: 'S',
|
||||||
|
KeyD: 'D',
|
||||||
|
KeyF: 'F',
|
||||||
|
KeyG: 'G',
|
||||||
|
KeyH: 'H',
|
||||||
|
KeyJ: 'J',
|
||||||
|
KeyK: 'K',
|
||||||
|
KeyL: 'L',
|
||||||
|
Semicolon: ':<br/>;',
|
||||||
|
Quote: '"<br/>\'',
|
||||||
|
'{enter}': 'Enter',
|
||||||
|
|
||||||
|
'{shiftleft}': 'Shift',
|
||||||
|
KeyZ: 'Z',
|
||||||
|
KeyX: 'X',
|
||||||
|
KeyC: 'C',
|
||||||
|
KeyV: 'V',
|
||||||
|
KeyB: 'B',
|
||||||
|
KeyN: 'N',
|
||||||
|
KeyM: 'M',
|
||||||
|
Comma: '<<br/>,',
|
||||||
|
Period: '><br/>.',
|
||||||
|
Slash: '?<br/>/',
|
||||||
|
'{shiftright}': 'Shift',
|
||||||
|
|
||||||
|
'{controlleft}': 'Ctrl',
|
||||||
|
'{altleft}': 'Alt',
|
||||||
|
'{metaleft}': 'Cmd',
|
||||||
|
'{space}': 'Space',
|
||||||
|
'{metaright}': 'Cmd',
|
||||||
|
'{altright}': 'Alt'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// control keys
|
||||||
|
export const keyboardControlPadOptions = {
|
||||||
|
theme: 'simple-keyboard hg-theme-default hg-layout-default',
|
||||||
|
baseClass: 'simple-keyboard-control',
|
||||||
|
layout: {
|
||||||
|
default: [
|
||||||
|
'{prtscr} {scrolllock} {pause}',
|
||||||
|
'{insert} {home} {pageup}',
|
||||||
|
'{delete} {end} {pagedown}'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
display: {
|
||||||
|
'{prtscr}': 'PrtScr',
|
||||||
|
'{scrolllock}': 'Lock',
|
||||||
|
'{pause}': 'Pause',
|
||||||
|
'{insert}': 'Ins',
|
||||||
|
'{home}': 'Home',
|
||||||
|
'{pageup}': 'PgUp',
|
||||||
|
'{delete}': 'Del',
|
||||||
|
'{end}': 'End',
|
||||||
|
'{pagedown}': 'PgDn'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// arrow keys
|
||||||
|
export const keyboardArrowsOptions = {
|
||||||
|
theme: 'simple-keyboard hg-theme-default hg-layout-default',
|
||||||
|
baseClass: 'simple-keyboard-arrows',
|
||||||
|
layout: {
|
||||||
|
default: ['{arrowup}', '{arrowleft} {arrowdown} {arrowright}']
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// keys require special mapping
|
||||||
|
export const specialKeyMap = new Map([
|
||||||
|
['{escape}', 'Escape'],
|
||||||
|
['{backspace}', 'Backspace'],
|
||||||
|
['{tab}', 'Tab'],
|
||||||
|
['{capslock}', 'CapsLock'],
|
||||||
|
['{enter}', 'Enter'],
|
||||||
|
['{shiftleft}', 'ShiftLeft'],
|
||||||
|
['{shiftright}', 'ShiftRight'],
|
||||||
|
['{controlleft}', 'ControlLeft'],
|
||||||
|
['{altleft}', 'AltLeft'],
|
||||||
|
['{metaleft}', 'MetaLeft'],
|
||||||
|
['{space}', 'Space'],
|
||||||
|
['{metaright}', 'MetaRight'],
|
||||||
|
['{altright}', 'AltRight'],
|
||||||
|
['{prtscr}', 'PrintScreen'],
|
||||||
|
['{scrolllock}', 'ScrollLock'],
|
||||||
|
['{pause}', 'Pause'],
|
||||||
|
['{insert}', 'Insert'],
|
||||||
|
['{home}', 'Home'],
|
||||||
|
['{pageup}', 'PageUp'],
|
||||||
|
['{delete}', 'Delete'],
|
||||||
|
['{end}', 'End'],
|
||||||
|
['{pagedown}', 'PageDown'],
|
||||||
|
['{arrowright}', 'ArrowRight'],
|
||||||
|
['{arrowleft}', 'ArrowLeft'],
|
||||||
|
['{arrowdown}', 'ArrowDown'],
|
||||||
|
['{arrowup}', 'ArrowUp']
|
||||||
|
]);
|
||||||
|
|
||||||
|
// modifier keys
|
||||||
|
export const modifierKeys = [
|
||||||
|
'{shiftleft}',
|
||||||
|
'{controlleft}',
|
||||||
|
'{altleft}',
|
||||||
|
'{metaleft}',
|
||||||
|
'{shiftright}',
|
||||||
|
'{controlright}',
|
||||||
|
'{altright}',
|
||||||
|
'{metaright}'
|
||||||
|
];
|
||||||
|
|
||||||
|
// double line display buttons
|
||||||
|
export const doubleKeys = [
|
||||||
|
'Backquote',
|
||||||
|
'Digit1',
|
||||||
|
'Digit2',
|
||||||
|
'Digit3',
|
||||||
|
'Digit4',
|
||||||
|
'Digit5',
|
||||||
|
'Digit6',
|
||||||
|
'Digit7',
|
||||||
|
'Digit8',
|
||||||
|
'Digit9',
|
||||||
|
'Digit0',
|
||||||
|
'Minus',
|
||||||
|
'Equal',
|
||||||
|
'BracketLeft',
|
||||||
|
'BracketRight',
|
||||||
|
'Backslash',
|
||||||
|
'Semicolon',
|
||||||
|
'Quote',
|
||||||
|
'Comma',
|
||||||
|
'Period',
|
||||||
|
'Slash'
|
||||||
|
];
|
||||||
51
web/src/pages/desktop/menu-phone/index.tsx
Normal file
51
web/src/pages/desktop/menu-phone/index.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { MenuIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Keyboard } from './keyboard';
|
||||||
|
import { Mouse } from './mouse';
|
||||||
|
import { Power } from './power';
|
||||||
|
import { Screen } from './screen';
|
||||||
|
import { Script } from './script';
|
||||||
|
import { Settings } from './settings';
|
||||||
|
import { Storage } from './storage';
|
||||||
|
import { Terminal } from './terminal';
|
||||||
|
import { Wol } from './wol';
|
||||||
|
|
||||||
|
export const MenuPhone = () => {
|
||||||
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed left-[10px] top-[10px] z-[1000]">
|
||||||
|
<div className="sticky top-[10px]">
|
||||||
|
{!isMenuOpen && (
|
||||||
|
<div
|
||||||
|
className="flex h-[32px] items-center justify-center rounded bg-neutral-800/80 px-2 text-white"
|
||||||
|
onClick={() => setIsMenuOpen((o) => !o)}
|
||||||
|
>
|
||||||
|
<MenuIcon size={18} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={clsx('flex-col rounded bg-neutral-800/90', isMenuOpen ? 'flex' : 'hidden')}>
|
||||||
|
<div
|
||||||
|
className="flex h-[32px] rotate-90 items-center justify-center text-white"
|
||||||
|
onClick={() => setIsMenuOpen((o) => !o)}
|
||||||
|
>
|
||||||
|
<MenuIcon size={18} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Settings />
|
||||||
|
<Screen />
|
||||||
|
<Keyboard />
|
||||||
|
<Mouse />
|
||||||
|
<Storage />
|
||||||
|
<Script />
|
||||||
|
<Terminal />
|
||||||
|
<Wol />
|
||||||
|
<Power />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
19
web/src/pages/desktop/menu-phone/keyboard.tsx
Normal file
19
web/src/pages/desktop/menu-phone/keyboard.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { useSetAtom } from 'jotai';
|
||||||
|
import { KeyboardIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { isKeyboardOpenAtom } from '@/jotai/keyboard.ts';
|
||||||
|
|
||||||
|
export const Keyboard = () => {
|
||||||
|
const setIsKeyboardOpen = useSetAtom(isKeyboardOpenAtom);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="flex h-[30px] cursor-pointer items-center justify-center rounded px-2 text-neutral-300 hover:bg-neutral-700"
|
||||||
|
onClick={() => setIsKeyboardOpen((o) => !o)}
|
||||||
|
>
|
||||||
|
<KeyboardIcon size={18} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
153
web/src/pages/desktop/menu-phone/mouse.tsx
Normal file
153
web/src/pages/desktop/menu-phone/mouse.tsx
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Divider, Popover } from 'antd';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { useAtom } from 'jotai';
|
||||||
|
import {
|
||||||
|
CheckIcon,
|
||||||
|
EyeOffIcon,
|
||||||
|
HandIcon,
|
||||||
|
MouseIcon,
|
||||||
|
MousePointerIcon,
|
||||||
|
PlusIcon,
|
||||||
|
RefreshCwIcon,
|
||||||
|
SquareMousePointerIcon,
|
||||||
|
TextCursorIcon
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import * as api from '@/api/storage';
|
||||||
|
import * as ls from '@/lib/localstorage';
|
||||||
|
import { client } from '@/lib/websocket';
|
||||||
|
import { mouseModeAtom, mouseStyleAtom } from '@/jotai/mouse';
|
||||||
|
|
||||||
|
export const Mouse = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [mouseStyle, setMouseStyle] = useAtom(mouseStyleAtom);
|
||||||
|
const [mouseMode, setMouseMode] = useAtom(mouseModeAtom);
|
||||||
|
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||||
|
|
||||||
|
const mouseStyles = [
|
||||||
|
{ name: t('cursor.default'), icon: <MousePointerIcon size={14} />, value: 'cursor-default' },
|
||||||
|
{ name: t('cursor.grab'), icon: <HandIcon size={14} />, value: 'cursor-grab' },
|
||||||
|
{ name: t('cursor.cell'), icon: <PlusIcon size={14} />, value: 'cursor-cell' },
|
||||||
|
{ name: t('cursor.text'), icon: <TextCursorIcon size={14} />, value: 'cursor-text' },
|
||||||
|
{ name: t('cursor.hide'), icon: <EyeOffIcon size={14} />, value: 'cursor-none' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const mouseModes = [
|
||||||
|
{ name: t('cursor.absolute'), value: 'absolute' },
|
||||||
|
{ name: t('cursor.relative'), value: 'relative' }
|
||||||
|
];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const style = ls.getMouseStyle();
|
||||||
|
if (style && style !== mouseStyle) {
|
||||||
|
setMouseStyle(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mode = ls.getMouseMode();
|
||||||
|
if (mode && mode !== mouseMode) {
|
||||||
|
setMouseMode(mode);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function updateStyle(style: string) {
|
||||||
|
setMouseStyle(style);
|
||||||
|
ls.setMouseStyle(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateMode(mode: string) {
|
||||||
|
setMouseMode(mode);
|
||||||
|
ls.setMouseMode(mode);
|
||||||
|
|
||||||
|
if (mode === 'relative') {
|
||||||
|
client.close();
|
||||||
|
setTimeout(() => {
|
||||||
|
client.connect();
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetHid() {
|
||||||
|
setIsPopoverOpen(false);
|
||||||
|
|
||||||
|
client.close();
|
||||||
|
|
||||||
|
api.resetHid().finally(() => {
|
||||||
|
client.connect();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
{mouseStyles.map((style) => (
|
||||||
|
<div
|
||||||
|
key={style.value}
|
||||||
|
className={clsx(
|
||||||
|
'flex cursor-pointer select-none items-center space-x-1 rounded px-3 py-1.5 hover:bg-neutral-600',
|
||||||
|
style.value === mouseStyle ? 'text-green-500' : 'text-white'
|
||||||
|
)}
|
||||||
|
onClick={() => updateStyle(style.value)}
|
||||||
|
>
|
||||||
|
<div className="flex h-[14px] w-[20px] items-end">{style.icon}</div>
|
||||||
|
<span>{style.name}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Divider style={{ margin: '10px 0' }} />
|
||||||
|
|
||||||
|
<Popover
|
||||||
|
content={
|
||||||
|
<>
|
||||||
|
{mouseModes.map((mode) => (
|
||||||
|
<div
|
||||||
|
key={mode.value}
|
||||||
|
className="my-1 flex cursor-pointer items-center space-x-1 rounded py-1 pl-2 pr-5 hover:bg-neutral-700"
|
||||||
|
onClick={() => updateMode(mode.value)}
|
||||||
|
>
|
||||||
|
<div className="flex h-[16px] w-[16px] items-end text-blue-500">
|
||||||
|
{mode.value === mouseMode && <CheckIcon strokeWidth={3} size={16} />}
|
||||||
|
</div>
|
||||||
|
<span>{mode.name}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
placement="rightTop"
|
||||||
|
arrow={true}
|
||||||
|
trigger="hover"
|
||||||
|
>
|
||||||
|
<div className="flex h-[30px] cursor-pointer items-center space-x-1 rounded px-3 text-neutral-300 hover:bg-neutral-700">
|
||||||
|
<div className="flex h-[14px] w-[20px] items-end">
|
||||||
|
<SquareMousePointerIcon size={14} />
|
||||||
|
</div>
|
||||||
|
<span>{t('cursor.mode')}</span>
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex cursor-pointer select-none items-center space-x-1 rounded px-3 py-1.5 hover:bg-neutral-600"
|
||||||
|
onClick={resetHid}
|
||||||
|
>
|
||||||
|
<div className="flex h-[14px] w-[20px] items-end">
|
||||||
|
<RefreshCwIcon size={14} />
|
||||||
|
</div>
|
||||||
|
<span>{t('cursor.resetHid')}</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
content={content}
|
||||||
|
placement="rightBottom"
|
||||||
|
trigger="click"
|
||||||
|
open={isPopoverOpen}
|
||||||
|
onOpenChange={setIsPopoverOpen}
|
||||||
|
>
|
||||||
|
<div className="flex h-[30px] cursor-pointer items-center rounded px-2 text-neutral-300 hover:bg-neutral-700">
|
||||||
|
<MouseIcon size={18} />
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
133
web/src/pages/desktop/menu-phone/power.tsx
Normal file
133
web/src/pages/desktop/menu-phone/power.tsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Popover, Slider, Tooltip } from 'antd';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import {
|
||||||
|
CirclePowerIcon,
|
||||||
|
HardDriveIcon,
|
||||||
|
LoaderCircleIcon,
|
||||||
|
PowerIcon,
|
||||||
|
RotateCcwIcon
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import * as api from '@/api/vm';
|
||||||
|
|
||||||
|
export const Power = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||||
|
const [isPowerOn, setIsPowerOn] = useState(false);
|
||||||
|
const [isHddOn, setIsHddOn] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [powerDuration, setPowerDuration] = useState(8);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getLed();
|
||||||
|
const interval = setInterval(getLed, 5000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(interval);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function getLed() {
|
||||||
|
api.getLedGpio().then((rsp: any) => {
|
||||||
|
if (rsp.code !== 0) {
|
||||||
|
console.log(rsp.msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsPowerOn(rsp.data.pwr);
|
||||||
|
setIsHddOn(rsp.data.hdd);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function click(button: 'reset' | 'power', duration?: number) {
|
||||||
|
if (isLoading) return;
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
setIsPopoverOpen(false);
|
||||||
|
|
||||||
|
const millisecond = Math.floor((duration ? duration : powerDuration) * 1000);
|
||||||
|
|
||||||
|
api.setGpio(button, millisecond).finally(() => {
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onChange(value: number) {
|
||||||
|
setPowerDuration(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<div className="flex flex-col space-y-1">
|
||||||
|
<div
|
||||||
|
className="flex h-[28px] cursor-pointer select-none items-center space-x-2 rounded px-3 py-0.5 hover:bg-neutral-600"
|
||||||
|
onClick={() => click('reset', 0.8)}
|
||||||
|
>
|
||||||
|
<RotateCcwIcon size={16} />
|
||||||
|
<span>{t('reset')}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex h-[28px] cursor-pointer select-none items-center space-x-2 rounded px-3 py-0.5 hover:bg-neutral-600"
|
||||||
|
onClick={() => click('power', 0.8)}
|
||||||
|
>
|
||||||
|
<PowerIcon size={16} />
|
||||||
|
<span>{t('powerShort')}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex h-[28px] cursor-pointer select-none items-center space-x-2 rounded px-3 py-0.5 hover:bg-neutral-600"
|
||||||
|
onClick={() => click('power')}
|
||||||
|
>
|
||||||
|
<CirclePowerIcon size={16} />
|
||||||
|
<span>{t('powerLong')}</span>
|
||||||
|
<div className="flex h-full items-start text-xs text-neutral-500">{powerDuration}s</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-3">
|
||||||
|
<Slider
|
||||||
|
defaultValue={8}
|
||||||
|
min={1}
|
||||||
|
max={30}
|
||||||
|
tooltip={{ placement: 'bottom' }}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Tooltip placement="rightTop" title={t('hddLed')}>
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'flex h-[30px] w-[18px] items-center justify-center px-2',
|
||||||
|
isHddOn ? 'text-green-600' : 'text-neutral-500'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<HardDriveIcon size={18} />
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Popover
|
||||||
|
content={content}
|
||||||
|
placement="rightTop"
|
||||||
|
trigger="click"
|
||||||
|
open={isPopoverOpen}
|
||||||
|
onOpenChange={setIsPopoverOpen}
|
||||||
|
>
|
||||||
|
<div className="flex h-[30px] cursor-pointer items-center justify-center rounded px-2 hover:bg-neutral-700/80">
|
||||||
|
<div
|
||||||
|
className={clsx('h-[18px] w-[18px]', isPowerOn ? 'text-green-600' : 'text-neutral-500')}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<LoaderCircleIcon className="animate-spin" size={18} />
|
||||||
|
) : (
|
||||||
|
<PowerIcon size={18} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
115
web/src/pages/desktop/menu-phone/screen/fps.tsx
Normal file
115
web/src/pages/desktop/menu-phone/screen/fps.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { CheckOutlined, CloseOutlined } from '@ant-design/icons';
|
||||||
|
import { Button, InputNumber, Popover } from 'antd';
|
||||||
|
import { CheckIcon, ScanBarcodeIcon } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { updateScreen } from '@/api/vm';
|
||||||
|
import { setFps as setCookie } from '@/lib/localstorage';
|
||||||
|
|
||||||
|
const fpsList = [
|
||||||
|
{ key: 60, label: '60Hz' },
|
||||||
|
{ key: 30, label: '30Hz' },
|
||||||
|
{ key: 24, label: '24Hz' },
|
||||||
|
{ key: 15, label: '15Hz' },
|
||||||
|
{ key: 10, label: '10Hz' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const defaultFps = [60, 30, 24, 15, 10];
|
||||||
|
|
||||||
|
type FpsProps = {
|
||||||
|
fps: number;
|
||||||
|
setFps: (fps: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Fps = ({ fps, setFps }: FpsProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [isCustomize, setIsCustomize] = useState(false);
|
||||||
|
const customizeRef = useRef(0);
|
||||||
|
|
||||||
|
function showCustomize() {
|
||||||
|
customizeRef.current = fps;
|
||||||
|
setIsCustomize(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onChange(value: any) {
|
||||||
|
const num = Number(value);
|
||||||
|
if (num > 0 && num <= 60) {
|
||||||
|
customizeRef.current = num;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function update(value: number) {
|
||||||
|
if (isCustomize && customizeRef.current === fps) {
|
||||||
|
setIsCustomize(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rsp = await updateScreen('fps', value);
|
||||||
|
if (rsp.code !== 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setFps(value);
|
||||||
|
setCookie(value);
|
||||||
|
|
||||||
|
isCustomize && setIsCustomize(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
{fpsList.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.key}
|
||||||
|
className="flex w-[140px] cursor-pointer select-none items-center space-x-1 rounded py-1.5 pl-1 hover:bg-neutral-600"
|
||||||
|
onClick={() => update(item.key)}
|
||||||
|
>
|
||||||
|
<div className="flex h-[14px] w-[20px] items-end">
|
||||||
|
{item.key === fps && <CheckIcon size={14} />}
|
||||||
|
</div>
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex w-[140px] cursor-pointer select-none items-center space-x-1 rounded py-1.5 pl-1 hover:bg-neutral-600"
|
||||||
|
onClick={showCustomize}
|
||||||
|
>
|
||||||
|
{defaultFps.includes(fps) ? (
|
||||||
|
<>
|
||||||
|
<div className="flex h-[14px] w-[20px] items-end"></div>
|
||||||
|
<span>{t('screen.customizeFps')}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex h-[14px] w-[20px] items-end">
|
||||||
|
<CheckIcon size={14} />
|
||||||
|
</div>
|
||||||
|
<span>{`Customize(${fps}Hz)`}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isCustomize && (
|
||||||
|
<div className="flex items-center space-x-1 py-1">
|
||||||
|
<InputNumber<number> defaultValue={fps} min={1} max={60} onChange={onChange} />
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<CheckOutlined />}
|
||||||
|
onClick={() => update(customizeRef.current)}
|
||||||
|
/>
|
||||||
|
<Button size="small" icon={<CloseOutlined />} onClick={() => setIsCustomize(false)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover content={content} placement="rightTop">
|
||||||
|
<div className="flex h-[30px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700">
|
||||||
|
<ScanBarcodeIcon size={18} />
|
||||||
|
<span className="select-none text-sm">{t('screen.fps')}</span>
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
53
web/src/pages/desktop/menu-phone/screen/frame-detect.tsx
Normal file
53
web/src/pages/desktop/menu-phone/screen/frame-detect.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Tooltip } from 'antd';
|
||||||
|
import { LoaderCircleIcon, Tally4Icon, Tally5Icon } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { getFrameDetect, updateFrameDetect } from '@/api/stream.ts';
|
||||||
|
|
||||||
|
export const FrameDetect = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [isEnabled, setIsEnabled] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getFrameDetect().then((rsp) => {
|
||||||
|
if (rsp.code === 0) {
|
||||||
|
setIsEnabled(rsp.data.enabled);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function update() {
|
||||||
|
if (isLoading) return;
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
updateFrameDetect()
|
||||||
|
.then((rsp) => {
|
||||||
|
if (rsp.code === 0) {
|
||||||
|
setIsEnabled(rsp.data.enabled);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip placement="rightTop" title={t('screen.frameDetectTip')} color="#262626" arrow>
|
||||||
|
<div
|
||||||
|
className="flex h-[30px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700"
|
||||||
|
onClick={update}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<LoaderCircleIcon className="animate-spin" size={18} />
|
||||||
|
) : isEnabled ? (
|
||||||
|
<Tally4Icon color="#22c55e" size={18} />
|
||||||
|
) : (
|
||||||
|
<Tally5Icon size={18} />
|
||||||
|
)}
|
||||||
|
<span className="select-none text-sm">{t('screen.frameDetect')}</span>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
};
|
||||||
60
web/src/pages/desktop/menu-phone/screen/index.tsx
Normal file
60
web/src/pages/desktop/menu-phone/screen/index.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Popover } from 'antd';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
|
import { MonitorIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { updateScreen } from '@/api/vm';
|
||||||
|
import * as ls from '@/lib/localstorage';
|
||||||
|
import { resolutionAtom } from '@/jotai/resolution';
|
||||||
|
|
||||||
|
import { Fps } from './fps';
|
||||||
|
import { FrameDetect } from './frame-detect';
|
||||||
|
import { Quality } from './quality';
|
||||||
|
import { Resolution } from './resolution';
|
||||||
|
|
||||||
|
export const Screen = () => {
|
||||||
|
const resolution = useAtomValue(resolutionAtom);
|
||||||
|
const [fps, setFps] = useState(30);
|
||||||
|
const [quality, setQuality] = useState(80);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
updateScreen('resolution', resolution!.height);
|
||||||
|
|
||||||
|
const cookieFps = ls.getFps();
|
||||||
|
if (cookieFps) {
|
||||||
|
updateScreen('fps', cookieFps).then((rsp) => {
|
||||||
|
if (rsp.code === 0) {
|
||||||
|
setFps(cookieFps);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookieQuality = ls.getQuality();
|
||||||
|
if (cookieQuality) {
|
||||||
|
updateScreen('quality', cookieQuality).then((rsp) => {
|
||||||
|
if (rsp.code === 0) {
|
||||||
|
setQuality(cookieQuality);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
content={
|
||||||
|
<div className="flex flex-col space-y-1">
|
||||||
|
<Resolution />
|
||||||
|
<Fps fps={fps} setFps={setFps} />
|
||||||
|
<Quality quality={quality} setQuality={setQuality} />
|
||||||
|
<FrameDetect />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
placement="rightBottom"
|
||||||
|
trigger="click"
|
||||||
|
>
|
||||||
|
<div className="flex h-[32px] cursor-pointer items-center justify-center rounded px-2 text-neutral-300 hover:bg-neutral-700/80">
|
||||||
|
<MonitorIcon size={18} />
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
61
web/src/pages/desktop/menu-phone/screen/quality.tsx
Normal file
61
web/src/pages/desktop/menu-phone/screen/quality.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { Popover } from 'antd';
|
||||||
|
import { CheckIcon, SquareActivityIcon } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { updateScreen } from '@/api/vm';
|
||||||
|
import { setQuality as setCookie } from '@/lib/localstorage.ts';
|
||||||
|
|
||||||
|
const qualityList = [
|
||||||
|
{ key: 99, label: '100%' },
|
||||||
|
{ key: 90, label: '90%' },
|
||||||
|
{ key: 80, label: '80%' },
|
||||||
|
{ key: 70, label: '70%' },
|
||||||
|
{ key: 60, label: '60%' },
|
||||||
|
{ key: 51, label: '50%' }
|
||||||
|
];
|
||||||
|
|
||||||
|
type QualityProps = {
|
||||||
|
quality: number;
|
||||||
|
setQuality: (quality: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Quality = ({ quality, setQuality }: QualityProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
async function update(value: number) {
|
||||||
|
const rsp = await updateScreen('quality', value);
|
||||||
|
|
||||||
|
if (rsp.code !== 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setQuality(value);
|
||||||
|
setCookie(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
{qualityList.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.key}
|
||||||
|
className="flex h-[30px] cursor-pointer select-none items-center space-x-1 rounded pl-1 pr-5 hover:bg-neutral-600"
|
||||||
|
onClick={() => update(item.key)}
|
||||||
|
>
|
||||||
|
<div className="flex h-[14px] w-[20px] items-end">
|
||||||
|
{item.key === quality && <CheckIcon size={14} />}
|
||||||
|
</div>
|
||||||
|
<span className="flex w-[32px]">{item.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover content={content} placement="rightTop">
|
||||||
|
<div className="flex h-[30px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700">
|
||||||
|
<SquareActivityIcon size={18} />
|
||||||
|
<span className="select-none text-sm">{t('screen.quality')}</span>
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
59
web/src/pages/desktop/menu-phone/screen/resolution.tsx
Normal file
59
web/src/pages/desktop/menu-phone/screen/resolution.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { Popover } from 'antd';
|
||||||
|
import { useAtom } from 'jotai';
|
||||||
|
import { CheckIcon, RatioIcon } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { updateScreen } from '@/api/vm';
|
||||||
|
import { Resolution as TypeResolution } from '@/types';
|
||||||
|
import { setResolution as setCookie } from '@/lib/localstorage';
|
||||||
|
import { resolutionAtom } from '@/jotai/resolution.ts';
|
||||||
|
|
||||||
|
const resolutions: TypeResolution[] = [
|
||||||
|
{ width: 1920, height: 1080 },
|
||||||
|
{ width: 1280, height: 720 },
|
||||||
|
{ width: 800, height: 600 },
|
||||||
|
{ width: 640, height: 480 }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const Resolution = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [resolution, setResolution] = useAtom(resolutionAtom);
|
||||||
|
|
||||||
|
async function update(item: TypeResolution) {
|
||||||
|
const rsp = await updateScreen('resolution', item.height);
|
||||||
|
if (rsp.code !== 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setResolution(item);
|
||||||
|
setCookie(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
{resolutions.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.height}
|
||||||
|
className="flex cursor-pointer select-none items-center space-x-1 rounded py-1.5 pl-1 pr-5 hover:bg-neutral-600"
|
||||||
|
onClick={() => update(item)}
|
||||||
|
>
|
||||||
|
<div className="flex h-[14px] w-[20px] items-end">
|
||||||
|
{item.height === resolution?.height && <CheckIcon size={14} />}
|
||||||
|
</div>
|
||||||
|
<span className="flex w-[32px]">{item.width}</span>
|
||||||
|
<span>x</span>
|
||||||
|
<span className="w-[36px]">{item.height}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover content={content} placement="rightTop">
|
||||||
|
<div className="flex h-[30px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700">
|
||||||
|
<RatioIcon size={18} />
|
||||||
|
<span className="select-none text-sm">{t('screen.resolution')}</span>
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
205
web/src/pages/desktop/menu-phone/script/index.tsx
Normal file
205
web/src/pages/desktop/menu-phone/script/index.tsx
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
import { ChangeEvent, useRef, useState } from 'react';
|
||||||
|
import { UploadOutlined } from '@ant-design/icons';
|
||||||
|
import { Button, Divider, Popconfirm, Popover } from 'antd';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { ChevronRightIcon, FileCodeIcon } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import * as api from '@/api/script.ts';
|
||||||
|
|
||||||
|
import { Run } from './run';
|
||||||
|
|
||||||
|
export const Script = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||||
|
const [scripts, setScripts] = useState<string[]>(['test.sh']);
|
||||||
|
const [currentScript, setCurrentScript] = useState('');
|
||||||
|
const [isRunning, setIsRunning] = useState(false);
|
||||||
|
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const inputRef = useRef<any>(null);
|
||||||
|
|
||||||
|
function handleOpenChange(open: boolean) {
|
||||||
|
if (open) {
|
||||||
|
getScripts();
|
||||||
|
} else {
|
||||||
|
setCurrentScript('');
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsPopoverOpen(open);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectFile() {
|
||||||
|
if (inputRef.current) {
|
||||||
|
inputRef.current.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
inputRef.current?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
function uploadFile(e: ChangeEvent<HTMLInputElement>) {
|
||||||
|
if (!e.target?.files?.length) return;
|
||||||
|
const file = e.target.files[0];
|
||||||
|
|
||||||
|
if (isUploading) return;
|
||||||
|
setIsUploading(true);
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
api
|
||||||
|
.uploadScript(formData)
|
||||||
|
.then((rsp) => {
|
||||||
|
if (rsp.code !== 0) {
|
||||||
|
console.log(rsp.msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!scripts.includes(rsp.data.file)) {
|
||||||
|
setScripts([...scripts, rsp.data.file]);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsUploading(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function runScript(type: string) {
|
||||||
|
if (!currentScript) return;
|
||||||
|
|
||||||
|
if (type === 'foreground') {
|
||||||
|
setIsRunning(true);
|
||||||
|
} else {
|
||||||
|
api.runScript(currentScript, type).then((rsp) => {
|
||||||
|
if (rsp.code !== 0) {
|
||||||
|
console.log(rsp.msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsPopoverOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getScripts() {
|
||||||
|
api.getScripts().then((rsp) => {
|
||||||
|
if (rsp.code !== 0) {
|
||||||
|
console.log(rsp.msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rsp.data?.files?.length > 0) {
|
||||||
|
setScripts(rsp.data.files);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteScript() {
|
||||||
|
if (!currentScript) return;
|
||||||
|
|
||||||
|
api.deleteScript(currentScript).then((rsp) => {
|
||||||
|
if (rsp.code !== 0) {
|
||||||
|
console.log(rsp.msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setScripts(scripts.filter((script) => script !== currentScript));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function activate(script: string) {
|
||||||
|
setCurrentScript(script === currentScript ? '' : script);
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<div className="min-w-[250px]">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center justify-between px-1">
|
||||||
|
<span className="text-base font-bold text-neutral-300">{t('script.title')}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept="*.sh,*.py"
|
||||||
|
className="hidden"
|
||||||
|
onChange={uploadFile}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
ghost
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
icon={<UploadOutlined />}
|
||||||
|
loading={isUploading}
|
||||||
|
onClick={selectFile}
|
||||||
|
>
|
||||||
|
{t('script.upload')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Divider style={{ margin: '10px 0 15px 0' }} />
|
||||||
|
|
||||||
|
{scripts.map((script) => (
|
||||||
|
<div
|
||||||
|
key={script}
|
||||||
|
className={clsx(
|
||||||
|
'my-1 cursor-pointer rounded',
|
||||||
|
script === currentScript ? 'bg-neutral-700/50' : 'hover:bg-neutral-700/50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between space-x-5 px-2 py-1.5"
|
||||||
|
onClick={() => activate(script)}
|
||||||
|
>
|
||||||
|
<div className="max-w-[300px] select-none truncate">{script}</div>
|
||||||
|
<div className={clsx('h-[16px] w-[16px]', script === currentScript && 'rotate-90')}>
|
||||||
|
<ChevronRightIcon size={16} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{script === currentScript && (
|
||||||
|
<div className="flex items-center justify-end space-x-2 p-3">
|
||||||
|
<Button type="primary" size="small" onClick={() => runScript('foreground')}>
|
||||||
|
{t('script.run')}
|
||||||
|
</Button>
|
||||||
|
<Button type="primary" size="small" onClick={() => runScript('background')}>
|
||||||
|
{t('script.runBackground')}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Popconfirm
|
||||||
|
title={t('script.attention')}
|
||||||
|
description={t('script.delDesc')}
|
||||||
|
onConfirm={deleteScript}
|
||||||
|
onCancel={() => {}}
|
||||||
|
okText={t('script.confirm')}
|
||||||
|
cancelText={t('script.cancel')}
|
||||||
|
placement="bottom"
|
||||||
|
>
|
||||||
|
<Button danger type="primary" size="small">
|
||||||
|
{t('script.delete')}
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Popover
|
||||||
|
content={content}
|
||||||
|
placement="rightTop"
|
||||||
|
trigger="click"
|
||||||
|
open={isPopoverOpen}
|
||||||
|
onOpenChange={handleOpenChange}
|
||||||
|
>
|
||||||
|
<div className="flex h-[30px] cursor-pointer items-center justify-center rounded px-2 text-neutral-300 hover:bg-neutral-700">
|
||||||
|
<FileCodeIcon size={18} />
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
{isRunning && <Run script={currentScript} setIsRunning={setIsRunning} />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
63
web/src/pages/desktop/menu-phone/script/run.tsx
Normal file
63
web/src/pages/desktop/menu-phone/script/run.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { LoadingOutlined } from '@ant-design/icons';
|
||||||
|
import { Button, Card, Modal, Spin } from 'antd';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import * as api from '@/api/script';
|
||||||
|
|
||||||
|
type RunProps = {
|
||||||
|
script: string;
|
||||||
|
setIsRunning: (isRunning: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Run = ({ script, setIsRunning }: RunProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [state, setState] = useState('');
|
||||||
|
const [log, setLog] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setState('running');
|
||||||
|
|
||||||
|
api
|
||||||
|
.runScript(script, 'foreground')
|
||||||
|
.then((rsp) => {
|
||||||
|
if (rsp.code !== 0) {
|
||||||
|
setLog(rsp.msg);
|
||||||
|
setState('failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState('success');
|
||||||
|
setLog(rsp.data.log);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setLog(t('script.runFailed'));
|
||||||
|
setState('failed');
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={script}
|
||||||
|
width={350}
|
||||||
|
closable={false}
|
||||||
|
footer={null}
|
||||||
|
style={{ top: 60 }}
|
||||||
|
open={true}
|
||||||
|
>
|
||||||
|
{state === 'running' ? (
|
||||||
|
<div className="flex h-[300px] items-center justify-center">
|
||||||
|
<Spin indicator={<LoadingOutlined spin />} size="large" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Card className="h-[500px] overflow-auto whitespace-pre-line font-mono">{log}</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-5 flex justify-center">
|
||||||
|
<Button type="primary" onClick={() => setIsRunning(false)}>
|
||||||
|
{t('script.close')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
121
web/src/pages/desktop/menu-phone/settings/about.tsx
Normal file
121
web/src/pages/desktop/menu-phone/settings/about.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Modal } from 'antd';
|
||||||
|
import { useSetAtom } from 'jotai';
|
||||||
|
import { BookOpenIcon, GithubIcon, LoaderCircleIcon, MessageSquareIcon } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { getInfo } from '@/api/vm';
|
||||||
|
import { isSettingsOpenAtom } from '@/jotai/settings.ts';
|
||||||
|
|
||||||
|
type Info = {
|
||||||
|
ip: string;
|
||||||
|
mdns: string;
|
||||||
|
image: string;
|
||||||
|
firmware: string;
|
||||||
|
deviceKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const About = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const setIsSettingsOpen = useSetAtom(isSettingsOpenAtom);
|
||||||
|
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [info, setInfo] = useState<Info | undefined>();
|
||||||
|
|
||||||
|
const communities = [
|
||||||
|
{ name: 'wiki', icon: <BookOpenIcon size={24} />, url: 'https://wiki.sipeed.com/nanokvm' },
|
||||||
|
{ name: 'github', icon: <GithubIcon size={24} />, url: 'https://github.com/sipeed/NanoKVM' },
|
||||||
|
{
|
||||||
|
name: 'discussion',
|
||||||
|
icon: <MessageSquareIcon size={24} />,
|
||||||
|
url: 'https://maixhub.com/discussion/nanokvm'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
getInfo()
|
||||||
|
.then((rsp: any) => {
|
||||||
|
if (rsp.code !== 0) {
|
||||||
|
console.log(rsp.msg);
|
||||||
|
setInfo(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setInfo(rsp.data);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setInfo(undefined);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function showModal() {
|
||||||
|
setIsModalOpen(true);
|
||||||
|
setIsSettingsOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPage(url: string) {
|
||||||
|
window.open(url, '_blank');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="flex cursor-pointer select-none items-center rounded px-3 py-1.5 hover:bg-neutral-600"
|
||||||
|
onClick={showModal}
|
||||||
|
>
|
||||||
|
{t('about.title')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={t('about.title')}
|
||||||
|
open={isModalOpen}
|
||||||
|
onCancel={() => setIsModalOpen(false)}
|
||||||
|
width={380}
|
||||||
|
footer={null}
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<div className="my-5 h-[1px] bg-neutral-700/50" />
|
||||||
|
|
||||||
|
<div className="flex w-full flex-col space-y-2">
|
||||||
|
<span className="text-neutral-400">{t('about.information')}</span>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<LoaderCircleIcon className="animate-spin" size={18} />
|
||||||
|
) : info ? (
|
||||||
|
<div className="flex w-full flex-col space-y-1">
|
||||||
|
{Object.entries(info).map(([key, value]) => (
|
||||||
|
<div key={key} className="flex w-full items-center justify-between">
|
||||||
|
<span>{t(`about.${key}`)}</span>
|
||||||
|
<span>{value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span>{t('about.queryFailed')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="my-5 h-[1px] bg-neutral-700/50" />
|
||||||
|
<div className="text-neutral-400">{t('about.community')}</div>
|
||||||
|
<div className="my-3 flex space-x-5">
|
||||||
|
{communities.map((community) => (
|
||||||
|
<div
|
||||||
|
key={community.name}
|
||||||
|
className="flex h-20 w-20 cursor-pointer flex-col items-center justify-center space-y-1 rounded text-neutral-400 outline outline-1 outline-neutral-700 hover:bg-neutral-800"
|
||||||
|
onClick={() => openPage(community.url)}
|
||||||
|
>
|
||||||
|
{community.icon}
|
||||||
|
<span className="text-xs text-neutral-300">{community.name}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
export const ChangePassword = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
function changePassword() {
|
||||||
|
navigate('/auth/password');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex cursor-pointer select-none items-center rounded px-3 py-1.5 hover:bg-neutral-600"
|
||||||
|
onClick={changePassword}
|
||||||
|
>
|
||||||
|
{t('changePassword')}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
53
web/src/pages/desktop/menu-phone/settings/index.tsx
Normal file
53
web/src/pages/desktop/menu-phone/settings/index.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Badge, Divider, Popover } from 'antd';
|
||||||
|
import { useAtom } from 'jotai';
|
||||||
|
import { SettingsIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { isSettingsOpenAtom } from '@/jotai/settings.ts';
|
||||||
|
|
||||||
|
import { About } from './about.tsx';
|
||||||
|
import { ChangePassword } from './change-password.tsx';
|
||||||
|
import { Language } from './language.tsx';
|
||||||
|
import { Logout } from './logout.tsx';
|
||||||
|
import { Tailscale } from './tailscale.tsx';
|
||||||
|
import { Update } from './update.tsx';
|
||||||
|
import { VirtualDevice } from './virtual-device.tsx';
|
||||||
|
|
||||||
|
export const Settings = () => {
|
||||||
|
const [isSettingsOpen, setIsSettingsOpen] = useAtom(isSettingsOpenAtom);
|
||||||
|
const [isBadgeVisible, setIsBadgeVisible] = useState(false);
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
<Language />
|
||||||
|
<About />
|
||||||
|
<Update setIsBadgeVisible={setIsBadgeVisible} />
|
||||||
|
<Divider style={{ margin: '10px 0' }} />
|
||||||
|
|
||||||
|
<VirtualDevice />
|
||||||
|
<Tailscale />
|
||||||
|
<Divider style={{ margin: '10px 0' }} />
|
||||||
|
|
||||||
|
<ChangePassword />
|
||||||
|
<Logout />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Popover
|
||||||
|
content={content}
|
||||||
|
placement="rightBottom"
|
||||||
|
trigger="click"
|
||||||
|
open={isSettingsOpen}
|
||||||
|
onOpenChange={setIsSettingsOpen}
|
||||||
|
>
|
||||||
|
<div className="flex h-[30px] cursor-pointer items-center justify-center space-x-1 rounded px-2 text-white hover:bg-neutral-700">
|
||||||
|
<Badge dot={isBadgeVisible} color="blue" offset={[1, 0]}>
|
||||||
|
<SettingsIcon size={18} />
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
49
web/src/pages/desktop/menu-phone/settings/language.tsx
Normal file
49
web/src/pages/desktop/menu-phone/settings/language.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { Popover } from 'antd';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { LanguagesIcon } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { setLanguage } from '@/lib/localstorage.ts';
|
||||||
|
|
||||||
|
export const Language = () => {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
|
||||||
|
function changeLanguage(language: string) {
|
||||||
|
if (i18n.language === language) return;
|
||||||
|
i18n.changeLanguage(language).then();
|
||||||
|
setLanguage(language);
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'flex cursor-pointer select-none items-center space-x-1 rounded px-5 py-1',
|
||||||
|
i18n.language === 'en' ? 'text-blue-500' : 'text-white hover:bg-neutral-700'
|
||||||
|
)}
|
||||||
|
onClick={() => changeLanguage('en')}
|
||||||
|
>
|
||||||
|
English
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'flex cursor-pointer select-none items-center space-x-1 rounded px-5 py-1',
|
||||||
|
i18n.language === 'zh' ? 'text-blue-500' : 'text-white hover:bg-neutral-700'
|
||||||
|
)}
|
||||||
|
onClick={() => changeLanguage('zh')}
|
||||||
|
>
|
||||||
|
中文
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover content={content} placement="right" trigger="hover" arrow>
|
||||||
|
<div className="flex cursor-pointer select-none items-center space-x-2 rounded px-3 py-1.5 text-white hover:bg-neutral-600">
|
||||||
|
<LanguagesIcon size={16} />
|
||||||
|
<span>{t('language')}</span>
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
23
web/src/pages/desktop/menu-phone/settings/logout.tsx
Normal file
23
web/src/pages/desktop/menu-phone/settings/logout.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { removeToken } from '@/lib/cookie.ts';
|
||||||
|
|
||||||
|
export const Logout = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
removeToken();
|
||||||
|
navigate('/auth/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex cursor-pointer select-none items-center rounded px-3 py-1.5 hover:bg-neutral-600"
|
||||||
|
onClick={logout}
|
||||||
|
>
|
||||||
|
{t('logout')}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
202
web/src/pages/desktop/menu-phone/settings/tailscale.tsx
Normal file
202
web/src/pages/desktop/menu-phone/settings/tailscale.tsx
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Button, Modal } from 'antd';
|
||||||
|
import { useSetAtom } from 'jotai';
|
||||||
|
import { LoaderCircleIcon } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import * as api from '@/api/network';
|
||||||
|
import { isSettingsOpenAtom } from '@/jotai/settings.ts';
|
||||||
|
|
||||||
|
type State = '' | 'installing' | 'installed' | 'failed';
|
||||||
|
|
||||||
|
export const Tailscale = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const setIsSettingsOpen = useSetAtom(isSettingsOpenAtom);
|
||||||
|
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isStarting, setIsStarting] = useState(false);
|
||||||
|
const [state, setState] = useState<State>('');
|
||||||
|
const [loginUrl, setLoginUrl] = useState('');
|
||||||
|
|
||||||
|
function openModal() {
|
||||||
|
check();
|
||||||
|
|
||||||
|
setIsModalOpen(true);
|
||||||
|
setIsSettingsOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function check() {
|
||||||
|
if (['installing', 'installed'].includes(state)) return;
|
||||||
|
|
||||||
|
if (isLoading) return;
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
api
|
||||||
|
.getTailscale()
|
||||||
|
.then((rsp) => {
|
||||||
|
if (rsp.code !== 0) {
|
||||||
|
console.log(rsp.msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rsp.data.exist) {
|
||||||
|
setState('installed');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function install() {
|
||||||
|
if (state === 'installing') return;
|
||||||
|
setState('installing');
|
||||||
|
|
||||||
|
api
|
||||||
|
.installTailscale()
|
||||||
|
.then((rsp) => {
|
||||||
|
if (rsp.code !== 0) {
|
||||||
|
console.log(rsp.msg);
|
||||||
|
setState('failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState('installed');
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setState('failed');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function run() {
|
||||||
|
if (state !== 'installed') return;
|
||||||
|
|
||||||
|
if (isStarting) return;
|
||||||
|
setIsStarting(true);
|
||||||
|
|
||||||
|
setLoginUrl('');
|
||||||
|
|
||||||
|
api
|
||||||
|
.runTailscale()
|
||||||
|
.then((rsp) => {
|
||||||
|
if (rsp.code !== 0) {
|
||||||
|
console.log(rsp.msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rsp.data.url) {
|
||||||
|
updateLoginUrl(rsp.data.url);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsStarting(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLoginUrl(url: string) {
|
||||||
|
setLoginUrl(url);
|
||||||
|
|
||||||
|
window.open(url, '_blank');
|
||||||
|
|
||||||
|
setTimeout(() => setLoginUrl(''), 10 * 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="flex cursor-pointer select-none items-center rounded px-3 py-1.5 hover:bg-neutral-600"
|
||||||
|
onClick={openModal}
|
||||||
|
>
|
||||||
|
Tailscale
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="Tailscale"
|
||||||
|
open={isModalOpen}
|
||||||
|
width={350}
|
||||||
|
footer={null}
|
||||||
|
centered={true}
|
||||||
|
maskClosable={false}
|
||||||
|
onCancel={() => setIsModalOpen(false)}
|
||||||
|
>
|
||||||
|
<div className="flex h-[180px] items-center justify-center">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center space-x-2 text-neutral-500">
|
||||||
|
<LoaderCircleIcon className="animate-spin" size={18} />
|
||||||
|
<span>{t('tailscale.loading')}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{state === '' && (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center space-y-5">
|
||||||
|
<span>{t('tailscale.notInstalled')}</span>
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Button type="primary" onClick={install}>
|
||||||
|
{t('tailscale.install')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state === 'installing' && (
|
||||||
|
<div className="flex items-center justify-center space-x-2 text-neutral-500">
|
||||||
|
<LoaderCircleIcon className="animate-spin" size={18} />
|
||||||
|
<span>{t('tailscale.installing')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state === 'installed' && (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center space-y-5">
|
||||||
|
{loginUrl ? (
|
||||||
|
<>
|
||||||
|
<a href={loginUrl} target="_blank">
|
||||||
|
{loginUrl}
|
||||||
|
</a>
|
||||||
|
<span className="text-sm text-neutral-400">{t('tailscale.urlPeriod')}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span>{t('tailscale.installed')}</span>
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Button type="primary" loading={isStarting} onClick={run}>
|
||||||
|
{t('tailscale.login')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state === 'failed' && (
|
||||||
|
<div className="flex flex-col space-y-10">
|
||||||
|
<div className="flex flex-col items-center justify-center font-bold text-neutral-400">
|
||||||
|
<span>{t('tailscale.failed')}</span>
|
||||||
|
<span>{t('tailscale.retry')}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="list-decimal">
|
||||||
|
<li>
|
||||||
|
{t('tailscale.download')}
|
||||||
|
<a
|
||||||
|
className="px-1"
|
||||||
|
href="https://pkgs.tailscale.com/stable/tailscale_latest_riscv64.tgz"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
{t('tailscale.package')}
|
||||||
|
</a>
|
||||||
|
{t('tailscale.unzip')}
|
||||||
|
</li>
|
||||||
|
<li>{t('tailscale.upTailscale')}</li>
|
||||||
|
<li>{t('tailscale.upTailscaled')}</li>
|
||||||
|
<li>{t('tailscale.refresh')}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
176
web/src/pages/desktop/menu-phone/settings/update.tsx
Normal file
176
web/src/pages/desktop/menu-phone/settings/update.tsx
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Badge, Button, Modal, Spin } from 'antd';
|
||||||
|
import { useSetAtom } from 'jotai';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import semver from 'semver';
|
||||||
|
|
||||||
|
import * as api from '@/api/firmware.ts';
|
||||||
|
import { getSkipUpdate, setSkipUpdate } from '@/lib/localstorage.ts';
|
||||||
|
import { isSettingsOpenAtom } from '@/jotai/settings.ts';
|
||||||
|
|
||||||
|
type UpdateProps = {
|
||||||
|
setIsBadgeVisible: (isBadgeVisible: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Update = ({ setIsBadgeVisible }: UpdateProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const setIsSettingsOpen = useSetAtom(isSettingsOpenAtom);
|
||||||
|
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [current, setCurrent] = useState('');
|
||||||
|
const [latest, setLatest] = useState('');
|
||||||
|
const [isLatest, setIsLatest] = useState(true);
|
||||||
|
const [isUpdating, setIsUpdating] = useState(false);
|
||||||
|
const [errMsg, setErrMsg] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const skip = getSkipUpdate();
|
||||||
|
if (!skip) {
|
||||||
|
getVersion();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function showModal() {
|
||||||
|
getVersion();
|
||||||
|
|
||||||
|
setErrMsg('');
|
||||||
|
setIsModalOpen(true);
|
||||||
|
|
||||||
|
setIsSettingsOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVersion() {
|
||||||
|
if (isLoading) return;
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
api
|
||||||
|
.getVersion()
|
||||||
|
.then((rsp: any) => {
|
||||||
|
if (rsp.code !== 0) {
|
||||||
|
setErrMsg(t('update.queryFailed'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrent(rsp.data.current);
|
||||||
|
setLatest(rsp.data.latest);
|
||||||
|
|
||||||
|
if (semver.gt(rsp.data.latest, rsp.data.current)) {
|
||||||
|
setIsLatest(false);
|
||||||
|
setIsBadgeVisible(true);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setErrMsg(t('update.queryFailed'));
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirm() {
|
||||||
|
if (isLatest || !latest) {
|
||||||
|
setIsModalOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isUpdating) return;
|
||||||
|
setIsUpdating(true);
|
||||||
|
|
||||||
|
api
|
||||||
|
.update()
|
||||||
|
.then((rsp: any) => {
|
||||||
|
if (rsp.code !== 0) {
|
||||||
|
setErrMsg(t('update.updateFailed'));
|
||||||
|
setIsUpdating(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, 6000);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setErrMsg(t('update.updateFailed'));
|
||||||
|
setIsUpdating(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancel() {
|
||||||
|
setIsBadgeVisible(false);
|
||||||
|
|
||||||
|
setIsLatest(true);
|
||||||
|
setIsModalOpen(false);
|
||||||
|
setSkipUpdate(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="flex cursor-pointer select-none items-center rounded px-3 py-1.5 hover:bg-neutral-600"
|
||||||
|
onClick={showModal}
|
||||||
|
>
|
||||||
|
<Badge dot={!isLatest} color="blue" offset={[5, 5]}>
|
||||||
|
{t('update.title')}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={t('update.title')}
|
||||||
|
open={isModalOpen}
|
||||||
|
width={400}
|
||||||
|
footer={null}
|
||||||
|
closable={false}
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
{isUpdating ? (
|
||||||
|
<div className="flex h-[300px] flex-col items-center justify-center space-y-5">
|
||||||
|
<Spin spinning={isUpdating} size="large" />
|
||||||
|
<span className="text-neutral-400">{t('update.updating')}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center pb-5 pt-10">
|
||||||
|
{isLoading ? (
|
||||||
|
<Spin tip={t('update.loading')} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{errMsg !== '' ? (
|
||||||
|
<span>{errMsg}</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{isLatest ? (
|
||||||
|
<div className="flex flex-col items-center space-y-1">
|
||||||
|
<span className="text-neutral-400">{current}</span>
|
||||||
|
<span>{t('update.isLatest')}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center space-y-1">
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<span>{current}</span>
|
||||||
|
<span className="text-neutral-500">{'->'}</span>
|
||||||
|
<span>{latest}</span>
|
||||||
|
</div>
|
||||||
|
<span>{t('update.available')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-10 flex items-center space-x-3">
|
||||||
|
<Button type="primary" onClick={confirm}>
|
||||||
|
{t('update.confirm')}
|
||||||
|
</Button>
|
||||||
|
{!isLatest && (
|
||||||
|
<Button type="default" onClick={cancel}>
|
||||||
|
{t('update.cancel')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
66
web/src/pages/desktop/menu-phone/settings/virtual-device.tsx
Normal file
66
web/src/pages/desktop/menu-phone/settings/virtual-device.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { CheckIcon } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import * as api from '@/api/virtual-device';
|
||||||
|
|
||||||
|
export const VirtualDevice = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [isNetworkOn, setIsNetworkOn] = useState(false);
|
||||||
|
const [isUSBOn, setIsUSBOn] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.getVirtualDevice().then((rsp) => {
|
||||||
|
if (rsp.code !== 0) {
|
||||||
|
console.log(rsp.msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsNetworkOn(rsp.data.network);
|
||||||
|
setIsUSBOn(rsp.data.usb);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function update(device: string) {
|
||||||
|
if (isLoading) return;
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
api
|
||||||
|
.updateVirtualDevice(device)
|
||||||
|
.then((rsp) => {
|
||||||
|
if (rsp.code !== 0) {
|
||||||
|
console.log(rsp.msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (device === 'network') {
|
||||||
|
setIsNetworkOn(rsp.data.on);
|
||||||
|
} else {
|
||||||
|
setIsUSBOn(rsp.data.on);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="flex cursor-pointer select-none items-center space-x-1 rounded px-3 py-1.5 text-white hover:bg-neutral-600"
|
||||||
|
onClick={() => update('network')}
|
||||||
|
>
|
||||||
|
{isNetworkOn && <CheckIcon size={18} color="#3b82f6" />}
|
||||||
|
<span>{t('virtualDevice.network')}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="flex cursor-pointer select-none items-center space-x-1 rounded px-3 py-1.5 text-white hover:bg-neutral-600"
|
||||||
|
onClick={() => update('usb')}
|
||||||
|
>
|
||||||
|
{isUSBOn && <CheckIcon size={18} color="#3b82f6" />}
|
||||||
|
<span>{t('virtualDevice.usb')}</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
164
web/src/pages/desktop/menu-phone/storage.tsx
Normal file
164
web/src/pages/desktop/menu-phone/storage.tsx
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Divider, Popover } from 'antd';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import {
|
||||||
|
DiscIcon,
|
||||||
|
FileBoxIcon,
|
||||||
|
LoaderCircleIcon,
|
||||||
|
PackageIcon,
|
||||||
|
PackageSearchIcon,
|
||||||
|
XIcon
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import * as api from '@/api/storage';
|
||||||
|
import { client } from '@/lib/websocket.ts';
|
||||||
|
|
||||||
|
export const Storage = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [files, setFiles] = useState<string[]>([]);
|
||||||
|
const [mountingFile, setMountingFile] = useState('');
|
||||||
|
const [mountedFile, setMountedFile] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getFiles();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function handleOpenChange(open: boolean) {
|
||||||
|
if (open) {
|
||||||
|
getFiles();
|
||||||
|
}
|
||||||
|
setIsPopoverOpen(open);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取镜像列表
|
||||||
|
function getFiles() {
|
||||||
|
if (isLoading) return;
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
api
|
||||||
|
.getImages()
|
||||||
|
.then((rsp) => {
|
||||||
|
if (rsp.code !== 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const images = rsp.data.files;
|
||||||
|
|
||||||
|
if (!images?.length) {
|
||||||
|
setFiles([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setFiles(images);
|
||||||
|
|
||||||
|
// 获取已挂载的镜像
|
||||||
|
api.getMountedImage().then((rsp) => {
|
||||||
|
if (rsp.code === 0 && rsp.data?.file && images.includes(rsp.data.file)) {
|
||||||
|
setMountedFile(rsp.data.file);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 挂载/取消挂载
|
||||||
|
function mountFile(file: string) {
|
||||||
|
if (mountingFile) return;
|
||||||
|
setMountingFile(file);
|
||||||
|
|
||||||
|
client.close();
|
||||||
|
|
||||||
|
const filename = mountedFile === file ? '' : file;
|
||||||
|
|
||||||
|
api
|
||||||
|
.mountImage(filename)
|
||||||
|
.then((rsp) => {
|
||||||
|
if (rsp.code !== 0) {
|
||||||
|
console.log(rsp.msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setMountedFile(filename);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setMountingFile('');
|
||||||
|
client.connect();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<div className="min-w-[250px]">
|
||||||
|
<div className="flex items-center justify-between px-1">
|
||||||
|
<span className="text-base font-bold text-neutral-300">{t('images')}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider style={{ margin: '10px 0 15px 0' }} />
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center space-x-2 py-2 pl-2 pr-4 text-neutral-400">
|
||||||
|
<LoaderCircleIcon className="animate-spin" size={18} />
|
||||||
|
<span className="text-sm">{t('loading')}</span>
|
||||||
|
</div>
|
||||||
|
) : files.length === 0 ? (
|
||||||
|
<div className="flex items-center space-x-2 pl-2 pr-4 text-neutral-500">
|
||||||
|
<PackageSearchIcon size={18} />
|
||||||
|
<span className="text-sm">{t('empty')}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
files.map((file) => (
|
||||||
|
<div
|
||||||
|
key={file}
|
||||||
|
className={clsx(
|
||||||
|
'group my-1 flex max-w-[300px] cursor-pointer select-none items-center space-x-2 rounded py-2 pl-2 pr-4 hover:bg-neutral-700/80',
|
||||||
|
{ 'text-blue-500': mountedFile === file }
|
||||||
|
)}
|
||||||
|
onClick={() => mountFile(file)}
|
||||||
|
>
|
||||||
|
{mountedFile === file ? (
|
||||||
|
<>
|
||||||
|
<div className="h-[18px] w-[18px] group-hover:text-red-500">
|
||||||
|
<PackageIcon size={18} className="block group-hover:hidden" />
|
||||||
|
<XIcon size={18} className="hidden group-hover:block" />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : mountingFile === file ? (
|
||||||
|
<div className="h-[18px] w-[18px]">
|
||||||
|
<LoaderCircleIcon className="animate-spin" size={18} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="h-[18px] w-[18px]">
|
||||||
|
<FileBoxIcon size={18} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span className="break-all">{file.replace(/^.*[\\/]/, '')}</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
content={content}
|
||||||
|
placement="rightTop"
|
||||||
|
trigger="click"
|
||||||
|
open={isPopoverOpen}
|
||||||
|
onOpenChange={handleOpenChange}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'flex h-[30px] cursor-pointer items-center justify-center rounded px-2 hover:bg-neutral-700',
|
||||||
|
mountedFile ? 'text-blue-500' : 'text-neutral-300'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<DiscIcon size={18} />
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
39
web/src/pages/desktop/menu-phone/terminal/index.tsx
Normal file
39
web/src/pages/desktop/menu-phone/terminal/index.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Divider, Popover } from 'antd';
|
||||||
|
import { SquareTerminalIcon } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { Nanokvm } from './nanokvm';
|
||||||
|
import { SerialPort } from './serial-port';
|
||||||
|
|
||||||
|
export const Terminal = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<div className="min-w-[200px]">
|
||||||
|
<div className="flex items-center justify-between px-1">
|
||||||
|
<span className="text-base font-bold text-neutral-300">{t('terminal.title')}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider style={{ margin: '10px 0 15px 0' }} />
|
||||||
|
|
||||||
|
<Nanokvm setIsPopoverOpen={setIsPopoverOpen} />
|
||||||
|
<SerialPort setIsPopoverOpen={setIsPopoverOpen} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
content={content}
|
||||||
|
placement="rightTop"
|
||||||
|
trigger="click"
|
||||||
|
open={isPopoverOpen}
|
||||||
|
onOpenChange={setIsPopoverOpen}
|
||||||
|
>
|
||||||
|
<div className="flex h-[30px] cursor-pointer items-center justify-center rounded px-2 text-neutral-300 hover:bg-neutral-700">
|
||||||
|
<SquareTerminalIcon size={18} />
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
25
web/src/pages/desktop/menu-phone/terminal/nanokvm.tsx
Normal file
25
web/src/pages/desktop/menu-phone/terminal/nanokvm.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { TerminalIcon } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
type NanokvmProps = {
|
||||||
|
setIsPopoverOpen: (open: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Nanokvm = ({ setIsPopoverOpen }: NanokvmProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
function openTerminal() {
|
||||||
|
setIsPopoverOpen(false);
|
||||||
|
window.open('/#terminal', '_blank');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex h-[28px] cursor-pointer select-none items-center space-x-2 rounded px-2 py-1 hover:bg-neutral-700/80"
|
||||||
|
onClick={openTerminal}
|
||||||
|
>
|
||||||
|
<TerminalIcon size={16} />
|
||||||
|
<span>{t('terminal.nanokvm')}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
114
web/src/pages/desktop/menu-phone/terminal/serial-port.tsx
Normal file
114
web/src/pages/desktop/menu-phone/terminal/serial-port.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { ChangeEvent, useState } from 'react';
|
||||||
|
import { Button, Input, InputNumber, Modal, Radio, RadioChangeEvent } from 'antd';
|
||||||
|
import { useSetAtom } from 'jotai';
|
||||||
|
import { TerminalIcon } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
|
||||||
|
|
||||||
|
type SerialPortProps = {
|
||||||
|
setIsPopoverOpen: (open: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SerialPort = ({ setIsPopoverOpen }: SerialPortProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const setIsKeyboardEnable = useSetAtom(isKeyboardEnableAtom);
|
||||||
|
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [port, setPort] = useState('');
|
||||||
|
const [baudrate, setBaudrate] = useState(115200);
|
||||||
|
|
||||||
|
function openModal() {
|
||||||
|
setIsKeyboardEnable(false);
|
||||||
|
|
||||||
|
setIsModalOpen(true);
|
||||||
|
setIsPopoverOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
setIsKeyboardEnable(true);
|
||||||
|
setIsModalOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInputChange(e: ChangeEvent<HTMLInputElement>) {
|
||||||
|
setPort(e.target.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRadioChange(e: RadioChangeEvent) {
|
||||||
|
setPort(e.target.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onInputNumberChange(value: number | null) {
|
||||||
|
if (value === null) return;
|
||||||
|
setBaudrate(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
if (!port || !baudrate) {
|
||||||
|
closeModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsModalOpen(false);
|
||||||
|
window.open(`/#terminal?port=${port}&baud=${baudrate}`, '_blank');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="flex h-[28px] cursor-pointer select-none items-center space-x-2 rounded px-2 py-1 hover:bg-neutral-700/80"
|
||||||
|
onClick={openModal}
|
||||||
|
>
|
||||||
|
<TerminalIcon size={16} />
|
||||||
|
<span>{t('terminal.serial')}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={isModalOpen}
|
||||||
|
title={t('terminal.serial')}
|
||||||
|
width={350}
|
||||||
|
footer={null}
|
||||||
|
onCancel={closeModal}
|
||||||
|
>
|
||||||
|
<div className="mt-10 flex items-center space-x-[20px]">
|
||||||
|
<div className="flex w-[80px] justify-end text-neutral-400">
|
||||||
|
{t('terminal.serialPort')}
|
||||||
|
</div>
|
||||||
|
<div className="w-1/2">
|
||||||
|
<Input
|
||||||
|
value={port}
|
||||||
|
placeholder={t('terminal.serialPortPlaceholder')}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 pl-[100px]">
|
||||||
|
<Radio.Group size="large" onChange={handleRadioChange}>
|
||||||
|
<Radio value="/dev/ttyS1">
|
||||||
|
<code>/dev/ttyS1</code>
|
||||||
|
</Radio>
|
||||||
|
<Radio value="/dev/ttyS2">
|
||||||
|
<code>/dev/ttyS2</code>
|
||||||
|
</Radio>
|
||||||
|
</Radio.Group>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-7 flex items-center space-x-[20px]">
|
||||||
|
<div className="flex w-[80px] justify-end text-neutral-400">{t('terminal.baudrate')}</div>
|
||||||
|
<InputNumber
|
||||||
|
controls={false}
|
||||||
|
min={1}
|
||||||
|
defaultValue={115200}
|
||||||
|
onChange={onInputNumberChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3 mt-12 flex justify-center">
|
||||||
|
<Button type="primary" onClick={submit}>
|
||||||
|
{t('terminal.confirm')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
159
web/src/pages/desktop/menu-phone/wol.tsx
Normal file
159
web/src/pages/desktop/menu-phone/wol.tsx
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import { ChangeEvent, useRef, useState } from 'react';
|
||||||
|
import { Button, Divider, Input, List, Popover } from 'antd';
|
||||||
|
import type { InputRef } from 'antd';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { useSetAtom } from 'jotai';
|
||||||
|
import { NetworkIcon, SendIcon, Trash2Icon } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { deleteWolMac, getWolMacs, wol } from '@/api/network.ts';
|
||||||
|
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
|
||||||
|
|
||||||
|
export const Wol = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const setIsKeyboardEnable = useSetAtom(isKeyboardEnableAtom);
|
||||||
|
|
||||||
|
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||||
|
|
||||||
|
const [input, setInput] = useState('');
|
||||||
|
const [status, setStatus] = useState('');
|
||||||
|
const [log, setLog] = useState('');
|
||||||
|
|
||||||
|
const [macList, setMacList] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const inputRef = useRef<InputRef>(null);
|
||||||
|
|
||||||
|
function handleOpenChange(open: boolean) {
|
||||||
|
if (open) {
|
||||||
|
getMacs();
|
||||||
|
|
||||||
|
setIsKeyboardEnable(false);
|
||||||
|
} else {
|
||||||
|
setInput('');
|
||||||
|
setStatus('');
|
||||||
|
setLog('');
|
||||||
|
|
||||||
|
setIsKeyboardEnable(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsPopoverOpen(open);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleChange(e: ChangeEvent<HTMLInputElement>) {
|
||||||
|
setInput(e.target.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMacs() {
|
||||||
|
getWolMacs().then((rsp) => {
|
||||||
|
if (rsp.code !== 0) {
|
||||||
|
console.log(rsp.msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setMacList(rsp.data.macs.filter((mac: string) => mac !== ''));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteMac(mac: string) {
|
||||||
|
deleteWolMac(mac).then((rsp) => {
|
||||||
|
if (rsp.code === 0) {
|
||||||
|
setMacList(macList.filter((item) => item !== mac));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function wake(mac?: string) {
|
||||||
|
if (status === 'loading') return;
|
||||||
|
|
||||||
|
const value = mac ? mac : input;
|
||||||
|
if (!value) return;
|
||||||
|
|
||||||
|
setStatus('loading');
|
||||||
|
setLog(t('wol.sending'));
|
||||||
|
|
||||||
|
wol(value)
|
||||||
|
.then((rsp) => {
|
||||||
|
if (rsp.code !== 0) {
|
||||||
|
setStatus('failed');
|
||||||
|
setLog(rsp.msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus('success');
|
||||||
|
setLog(t('wol.sent'));
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setStatus('failed');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<div className="min-w-[300px]">
|
||||||
|
<div className="flex items-center justify-between px-1">
|
||||||
|
<span className="text-base font-bold text-neutral-300">Wake-on-LAN</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider style={{ margin: '10px 0 10px 0' }} />
|
||||||
|
|
||||||
|
<div className="pb-1 text-neutral-500">{t('wol.input')}</div>
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<Input ref={inputRef} value={input} onChange={handleChange} />
|
||||||
|
<Button type="primary" onClick={() => wake()}>
|
||||||
|
{t('wol.ok')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={clsx('py-2')}>
|
||||||
|
{status && (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'max-w-[300px] break-words text-sm',
|
||||||
|
status === 'failed' ? 'text-red-500' : 'text-green-500'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{log}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<List
|
||||||
|
itemLayout="horizontal"
|
||||||
|
locale={{ emptyText: ' ' }}
|
||||||
|
dataSource={macList}
|
||||||
|
renderItem={(mac) => (
|
||||||
|
<List.Item className="flex w-full items-center justify-between">
|
||||||
|
<div className="h-[24px] max-w-[200px]">{mac}</div>
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div
|
||||||
|
className="flex h-[24px] w-[24px] cursor-pointer items-center justify-center rounded text-green-500 hover:bg-neutral-700/80"
|
||||||
|
onClick={() => wake(mac)}
|
||||||
|
>
|
||||||
|
<SendIcon size={16} />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="flex h-[24px] w-[24px] cursor-pointer items-center justify-center rounded text-red-500 hover:bg-neutral-700"
|
||||||
|
onClick={() => deleteMac(mac)}
|
||||||
|
>
|
||||||
|
<Trash2Icon size={16} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
content={content}
|
||||||
|
placement="rightTop"
|
||||||
|
trigger="click"
|
||||||
|
open={isPopoverOpen}
|
||||||
|
onOpenChange={handleOpenChange}
|
||||||
|
>
|
||||||
|
<div className="flex h-[30px] cursor-pointer items-center justify-center rounded px-2 text-neutral-300 hover:bg-neutral-700">
|
||||||
|
<NetworkIcon size={18} />
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
37
web/src/pages/desktop/menu/fullscreen.tsx
Normal file
37
web/src/pages/desktop/menu/fullscreen.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { MaximizeIcon, MinimizeIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
export const Fullscreen = () => {
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onFullscreenChange() {
|
||||||
|
setIsFullscreen(!!document.fullscreenElement);
|
||||||
|
}
|
||||||
|
onFullscreenChange();
|
||||||
|
|
||||||
|
document.addEventListener('fullscreenchange', onFullscreenChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('fullscreenchange', onFullscreenChange);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function handleFullscreen() {
|
||||||
|
if (!document.fullscreenElement) {
|
||||||
|
const element = document.documentElement;
|
||||||
|
element.requestFullscreen().then();
|
||||||
|
} else {
|
||||||
|
document.exitFullscreen().then();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex h-[30px] cursor-pointer items-center justify-center space-x-1 rounded px-2 text-white hover:bg-neutral-700"
|
||||||
|
onClick={handleFullscreen}
|
||||||
|
>
|
||||||
|
{isFullscreen ? <MinimizeIcon size={18} /> : <MaximizeIcon size={18} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
68
web/src/pages/desktop/menu/index.tsx
Normal file
68
web/src/pages/desktop/menu/index.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Divider } from 'antd';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { MenuIcon, XIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Fullscreen } from './fullscreen';
|
||||||
|
import { Keyboard } from './keyboard';
|
||||||
|
import { Mouse } from './mouse';
|
||||||
|
import { Power } from './power';
|
||||||
|
import { Screen } from './screen';
|
||||||
|
import { Script } from './script';
|
||||||
|
import { Settings } from './settings';
|
||||||
|
import { Storage } from './storage';
|
||||||
|
import { Terminal } from './terminal';
|
||||||
|
import { Wol } from './wol';
|
||||||
|
|
||||||
|
export const Menu = () => {
|
||||||
|
const [isMenuOpen, setIsMenuOpen] = useState(true);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed left-1/2 top-[10px] z-[1000] -translate-x-1/2">
|
||||||
|
<div className="sticky top-[10px]">
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'h-[40px] items-center justify-between rounded bg-neutral-800/90',
|
||||||
|
isMenuOpen ? 'flex' : 'hidden'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex h-[30px] select-none items-center px-3">
|
||||||
|
<img src="/sipeed.ico" width={18} height={18} alt="sipeed" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Screen />
|
||||||
|
<Keyboard />
|
||||||
|
<Mouse />
|
||||||
|
<Divider type="vertical" />
|
||||||
|
|
||||||
|
<Storage />
|
||||||
|
<Script />
|
||||||
|
<Terminal />
|
||||||
|
<Wol />
|
||||||
|
<Divider type="vertical" />
|
||||||
|
|
||||||
|
<Power />
|
||||||
|
<Divider type="vertical" />
|
||||||
|
|
||||||
|
<Settings />
|
||||||
|
<Fullscreen />
|
||||||
|
<div
|
||||||
|
className="mr-2 flex h-[30px] cursor-pointer items-center justify-center space-x-1 rounded px-2 text-white hover:bg-neutral-700"
|
||||||
|
onClick={() => setIsMenuOpen((o) => !o)}
|
||||||
|
>
|
||||||
|
<XIcon size={20} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isMenuOpen && (
|
||||||
|
<div
|
||||||
|
className="flex h-[30px] w-[50px] items-center justify-center rounded bg-neutral-800/80 text-neutral-300 hover:bg-neutral-800 hover:text-white"
|
||||||
|
onClick={() => setIsMenuOpen((o) => !o)}
|
||||||
|
>
|
||||||
|
<MenuIcon />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
19
web/src/pages/desktop/menu/keyboard.tsx
Normal file
19
web/src/pages/desktop/menu/keyboard.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { useSetAtom } from 'jotai';
|
||||||
|
import { KeyboardIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { isKeyboardOpenAtom } from '@/jotai/keyboard.ts';
|
||||||
|
|
||||||
|
export const Keyboard = () => {
|
||||||
|
const setIsKeyboardOpen = useSetAtom(isKeyboardOpenAtom);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="flex h-[30px] cursor-pointer items-center justify-center rounded px-2 text-neutral-300 hover:bg-neutral-700"
|
||||||
|
onClick={() => setIsKeyboardOpen((o) => !o)}
|
||||||
|
>
|
||||||
|
<KeyboardIcon size={18} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
153
web/src/pages/desktop/menu/mouse.tsx
Normal file
153
web/src/pages/desktop/menu/mouse.tsx
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Divider, Popover } from 'antd';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { useAtom } from 'jotai';
|
||||||
|
import {
|
||||||
|
CheckIcon,
|
||||||
|
EyeOffIcon,
|
||||||
|
HandIcon,
|
||||||
|
MouseIcon,
|
||||||
|
MousePointerIcon,
|
||||||
|
PlusIcon,
|
||||||
|
RefreshCwIcon,
|
||||||
|
SquareMousePointerIcon,
|
||||||
|
TextCursorIcon
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import * as api from '@/api/storage';
|
||||||
|
import * as ls from '@/lib/localstorage';
|
||||||
|
import { client } from '@/lib/websocket';
|
||||||
|
import { mouseModeAtom, mouseStyleAtom } from '@/jotai/mouse';
|
||||||
|
|
||||||
|
export const Mouse = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [mouseStyle, setMouseStyle] = useAtom(mouseStyleAtom);
|
||||||
|
const [mouseMode, setMouseMode] = useAtom(mouseModeAtom);
|
||||||
|
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||||
|
|
||||||
|
const mouseStyles = [
|
||||||
|
{ name: t('cursor.default'), icon: <MousePointerIcon size={14} />, value: 'cursor-default' },
|
||||||
|
{ name: t('cursor.grab'), icon: <HandIcon size={14} />, value: 'cursor-grab' },
|
||||||
|
{ name: t('cursor.cell'), icon: <PlusIcon size={14} />, value: 'cursor-cell' },
|
||||||
|
{ name: t('cursor.text'), icon: <TextCursorIcon size={14} />, value: 'cursor-text' },
|
||||||
|
{ name: t('cursor.hide'), icon: <EyeOffIcon size={14} />, value: 'cursor-none' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const mouseModes = [
|
||||||
|
{ name: t('cursor.absolute'), value: 'absolute' },
|
||||||
|
{ name: t('cursor.relative'), value: 'relative' }
|
||||||
|
];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const style = ls.getMouseStyle();
|
||||||
|
if (style && style !== mouseStyle) {
|
||||||
|
setMouseStyle(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mode = ls.getMouseMode();
|
||||||
|
if (mode && mode !== mouseMode) {
|
||||||
|
setMouseMode(mode);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function updateStyle(style: string) {
|
||||||
|
setMouseStyle(style);
|
||||||
|
ls.setMouseStyle(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateMode(mode: string) {
|
||||||
|
setMouseMode(mode);
|
||||||
|
ls.setMouseMode(mode);
|
||||||
|
|
||||||
|
if (mode === 'relative') {
|
||||||
|
client.close();
|
||||||
|
setTimeout(() => {
|
||||||
|
client.connect();
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetHid() {
|
||||||
|
setIsPopoverOpen(false);
|
||||||
|
|
||||||
|
client.close();
|
||||||
|
|
||||||
|
api.resetHid().finally(() => {
|
||||||
|
client.connect();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
{mouseStyles.map((style) => (
|
||||||
|
<div
|
||||||
|
key={style.value}
|
||||||
|
className={clsx(
|
||||||
|
'flex cursor-pointer select-none items-center space-x-1 rounded px-3 py-1.5 hover:bg-neutral-600',
|
||||||
|
style.value === mouseStyle ? 'text-green-500' : 'text-white'
|
||||||
|
)}
|
||||||
|
onClick={() => updateStyle(style.value)}
|
||||||
|
>
|
||||||
|
<div className="flex h-[14px] w-[20px] items-end">{style.icon}</div>
|
||||||
|
<span>{style.name}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Divider style={{ margin: '10px 0' }} />
|
||||||
|
|
||||||
|
<Popover
|
||||||
|
content={
|
||||||
|
<>
|
||||||
|
{mouseModes.map((mode) => (
|
||||||
|
<div
|
||||||
|
key={mode.value}
|
||||||
|
className="my-1 flex cursor-pointer items-center space-x-1 rounded py-1 pl-2 pr-5 hover:bg-neutral-700"
|
||||||
|
onClick={() => updateMode(mode.value)}
|
||||||
|
>
|
||||||
|
<div className="flex h-[16px] w-[16px] items-end text-blue-500">
|
||||||
|
{mode.value === mouseMode && <CheckIcon strokeWidth={3} size={16} />}
|
||||||
|
</div>
|
||||||
|
<span>{mode.name}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
placement="rightTop"
|
||||||
|
arrow={true}
|
||||||
|
trigger="hover"
|
||||||
|
>
|
||||||
|
<div className="flex h-[30px] cursor-pointer items-center space-x-1 rounded px-3 text-neutral-300 hover:bg-neutral-700">
|
||||||
|
<div className="flex h-[14px] w-[20px] items-end">
|
||||||
|
<SquareMousePointerIcon size={14} />
|
||||||
|
</div>
|
||||||
|
<span>{t('cursor.mode')}</span>
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex cursor-pointer select-none items-center space-x-1 rounded px-3 py-1.5 hover:bg-neutral-600"
|
||||||
|
onClick={resetHid}
|
||||||
|
>
|
||||||
|
<div className="flex h-[14px] w-[20px] items-end">
|
||||||
|
<RefreshCwIcon size={14} />
|
||||||
|
</div>
|
||||||
|
<span>{t('cursor.resetHid')}</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
content={content}
|
||||||
|
placement="bottomLeft"
|
||||||
|
trigger="click"
|
||||||
|
open={isPopoverOpen}
|
||||||
|
onOpenChange={setIsPopoverOpen}
|
||||||
|
>
|
||||||
|
<div className="flex h-[30px] cursor-pointer items-center rounded px-2 text-neutral-300 hover:bg-neutral-700">
|
||||||
|
<MouseIcon size={18} />
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
133
web/src/pages/desktop/menu/power.tsx
Normal file
133
web/src/pages/desktop/menu/power.tsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Popover, Slider, Tooltip } from 'antd';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import {
|
||||||
|
CirclePowerIcon,
|
||||||
|
HardDriveIcon,
|
||||||
|
LoaderCircleIcon,
|
||||||
|
PowerIcon,
|
||||||
|
RotateCcwIcon
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import * as api from '@/api/vm';
|
||||||
|
|
||||||
|
export const Power = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||||
|
const [isPowerOn, setIsPowerOn] = useState(false);
|
||||||
|
const [isHddOn, setIsHddOn] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [powerDuration, setPowerDuration] = useState(8);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getLed();
|
||||||
|
const interval = setInterval(getLed, 5000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(interval);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function getLed() {
|
||||||
|
api.getLedGpio().then((rsp: any) => {
|
||||||
|
if (rsp.code !== 0) {
|
||||||
|
console.log(rsp.msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsPowerOn(rsp.data.pwr);
|
||||||
|
setIsHddOn(rsp.data.hdd);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function click(button: 'reset' | 'power', duration?: number) {
|
||||||
|
if (isLoading) return;
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
setIsPopoverOpen(false);
|
||||||
|
|
||||||
|
const millisecond = Math.floor((duration ? duration : powerDuration) * 1000);
|
||||||
|
|
||||||
|
api.setGpio(button, millisecond).finally(() => {
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onChange(value: number) {
|
||||||
|
setPowerDuration(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<div className="flex flex-col space-y-1">
|
||||||
|
<div
|
||||||
|
className="flex h-[28px] cursor-pointer select-none items-center space-x-2 rounded px-3 py-0.5 hover:bg-neutral-600"
|
||||||
|
onClick={() => click('reset', 0.8)}
|
||||||
|
>
|
||||||
|
<RotateCcwIcon size={16} />
|
||||||
|
<span>{t('reset')}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex h-[28px] cursor-pointer select-none items-center space-x-2 rounded px-3 py-0.5 hover:bg-neutral-600"
|
||||||
|
onClick={() => click('power', 0.8)}
|
||||||
|
>
|
||||||
|
<PowerIcon size={16} />
|
||||||
|
<span>{t('powerShort')}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex h-[28px] cursor-pointer select-none items-center space-x-2 rounded px-3 py-0.5 hover:bg-neutral-600"
|
||||||
|
onClick={() => click('power')}
|
||||||
|
>
|
||||||
|
<CirclePowerIcon size={16} />
|
||||||
|
<span>{t('powerLong')}</span>
|
||||||
|
<div className="flex h-full items-start text-xs text-neutral-500">{powerDuration}s</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-3">
|
||||||
|
<Slider
|
||||||
|
defaultValue={8}
|
||||||
|
min={1}
|
||||||
|
max={30}
|
||||||
|
tooltip={{ placement: 'bottom' }}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Popover
|
||||||
|
content={content}
|
||||||
|
placement="bottomLeft"
|
||||||
|
trigger="click"
|
||||||
|
open={isPopoverOpen}
|
||||||
|
onOpenChange={setIsPopoverOpen}
|
||||||
|
>
|
||||||
|
<div className="flex h-[30px] cursor-pointer items-center justify-center rounded px-2 hover:bg-neutral-700/80">
|
||||||
|
<div
|
||||||
|
className={clsx('h-[18px] w-[18px]', isPowerOn ? 'text-green-600' : 'text-neutral-500')}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<LoaderCircleIcon className="animate-spin" size={18} />
|
||||||
|
) : (
|
||||||
|
<PowerIcon size={18} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
<Tooltip placement="bottom" title={t('hddLed')}>
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'flex h-[30px] w-[18px] items-center justify-center px-2',
|
||||||
|
isHddOn ? 'text-green-600' : 'text-neutral-500'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<HardDriveIcon size={18} />
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
117
web/src/pages/desktop/menu/screen/fps.tsx
Normal file
117
web/src/pages/desktop/menu/screen/fps.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { CheckOutlined, CloseOutlined } from '@ant-design/icons';
|
||||||
|
import { Button, InputNumber, Popover } from 'antd';
|
||||||
|
import { CheckIcon, ScanBarcodeIcon } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { updateScreen } from '@/api/vm';
|
||||||
|
import { setFps as setCookie } from '@/lib/localstorage';
|
||||||
|
|
||||||
|
const fpsList = [
|
||||||
|
{ key: 60, label: '60Hz' },
|
||||||
|
{ key: 30, label: '30Hz' },
|
||||||
|
{ key: 24, label: '24Hz' },
|
||||||
|
{ key: 15, label: '15Hz' },
|
||||||
|
{ key: 10, label: '10Hz' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const defaultFps = [60, 30, 24, 15, 10];
|
||||||
|
|
||||||
|
type FpsProps = {
|
||||||
|
fps: number;
|
||||||
|
setFps: (fps: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Fps = ({ fps, setFps }: FpsProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [isCustomize, setIsCustomize] = useState(false);
|
||||||
|
const customizeRef = useRef(0);
|
||||||
|
|
||||||
|
function showCustomize() {
|
||||||
|
customizeRef.current = fps;
|
||||||
|
setIsCustomize(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onChange(value: any) {
|
||||||
|
const num = Number(value);
|
||||||
|
if (num > 0 && num <= 60) {
|
||||||
|
customizeRef.current = num;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function update(value: number) {
|
||||||
|
if (isCustomize && customizeRef.current === fps) {
|
||||||
|
setIsCustomize(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rsp = await updateScreen('fps', value);
|
||||||
|
if (rsp.code !== 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setFps(value);
|
||||||
|
setCookie(value);
|
||||||
|
|
||||||
|
isCustomize && setIsCustomize(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
{/* default fps list */}
|
||||||
|
{fpsList.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.key}
|
||||||
|
className="flex w-[140px] cursor-pointer select-none items-center space-x-1 rounded py-1.5 pl-1 hover:bg-neutral-600"
|
||||||
|
onClick={() => update(item.key)}
|
||||||
|
>
|
||||||
|
<div className="flex h-[14px] w-[20px] items-end">
|
||||||
|
{item.key === fps && <CheckIcon size={14} />}
|
||||||
|
</div>
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* customize fps */}
|
||||||
|
<div
|
||||||
|
className="flex w-[140px] cursor-pointer select-none items-center space-x-1 rounded py-1.5 pl-1 hover:bg-neutral-600"
|
||||||
|
onClick={showCustomize}
|
||||||
|
>
|
||||||
|
{defaultFps.includes(fps) ? (
|
||||||
|
<>
|
||||||
|
<div className="flex h-[14px] w-[20px] items-end"></div>
|
||||||
|
<span>{t('screen.customizeFps')}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex h-[14px] w-[20px] items-end">
|
||||||
|
<CheckIcon size={14} />
|
||||||
|
</div>
|
||||||
|
<span>{`Customize(${fps}Hz)`}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isCustomize && (
|
||||||
|
<div className="flex items-center space-x-1 py-1">
|
||||||
|
<InputNumber<number> defaultValue={fps} min={1} max={60} onChange={onChange} />
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<CheckOutlined />}
|
||||||
|
onClick={() => update(customizeRef.current)}
|
||||||
|
/>
|
||||||
|
<Button size="small" icon={<CloseOutlined />} onClick={() => setIsCustomize(false)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover content={content} placement="rightTop">
|
||||||
|
<div className="flex h-[30px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700">
|
||||||
|
<ScanBarcodeIcon size={18} />
|
||||||
|
<span className="select-none text-sm">{t('screen.fps')}</span>
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
53
web/src/pages/desktop/menu/screen/frame-detect.tsx
Normal file
53
web/src/pages/desktop/menu/screen/frame-detect.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Tooltip } from 'antd';
|
||||||
|
import { LoaderCircleIcon, Tally4Icon, Tally5Icon } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { getFrameDetect, updateFrameDetect } from '@/api/stream.ts';
|
||||||
|
|
||||||
|
export const FrameDetect = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [isEnabled, setIsEnabled] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getFrameDetect().then((rsp) => {
|
||||||
|
if (rsp.code === 0) {
|
||||||
|
setIsEnabled(rsp.data.enabled);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function update() {
|
||||||
|
if (isLoading) return;
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
updateFrameDetect()
|
||||||
|
.then((rsp) => {
|
||||||
|
if (rsp.code === 0) {
|
||||||
|
setIsEnabled(rsp.data.enabled);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip placement="rightTop" title={t('screen.frameDetectTip')} color="#262626" arrow>
|
||||||
|
<div
|
||||||
|
className="flex h-[30px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700"
|
||||||
|
onClick={update}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<LoaderCircleIcon className="animate-spin" size={18} />
|
||||||
|
) : isEnabled ? (
|
||||||
|
<Tally4Icon color="#22c55e" size={18} />
|
||||||
|
) : (
|
||||||
|
<Tally5Icon size={18} />
|
||||||
|
)}
|
||||||
|
<span className="select-none text-sm">{t('screen.frameDetect')}</span>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
};
|
||||||
60
web/src/pages/desktop/menu/screen/index.tsx
Normal file
60
web/src/pages/desktop/menu/screen/index.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Popover } from 'antd';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
|
import { MonitorIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { updateScreen } from '@/api/vm';
|
||||||
|
import * as ls from '@/lib/localstorage';
|
||||||
|
import { resolutionAtom } from '@/jotai/resolution';
|
||||||
|
|
||||||
|
import { Fps } from './fps';
|
||||||
|
import { FrameDetect } from './frame-detect';
|
||||||
|
import { Quality } from './quality';
|
||||||
|
import { Resolution } from './resolution';
|
||||||
|
|
||||||
|
export const Screen = () => {
|
||||||
|
const resolution = useAtomValue(resolutionAtom);
|
||||||
|
const [fps, setFps] = useState(30);
|
||||||
|
const [quality, setQuality] = useState(80);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
updateScreen('resolution', resolution!.height);
|
||||||
|
|
||||||
|
const cookieFps = ls.getFps();
|
||||||
|
if (cookieFps) {
|
||||||
|
updateScreen('fps', cookieFps).then((rsp) => {
|
||||||
|
if (rsp.code === 0) {
|
||||||
|
setFps(cookieFps);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookieQuality = ls.getQuality();
|
||||||
|
if (cookieQuality) {
|
||||||
|
updateScreen('quality', cookieQuality).then((rsp) => {
|
||||||
|
if (rsp.code === 0) {
|
||||||
|
setQuality(cookieQuality);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
content={
|
||||||
|
<div className="flex flex-col space-y-1">
|
||||||
|
<Resolution />
|
||||||
|
<Fps fps={fps} setFps={setFps} />
|
||||||
|
<Quality quality={quality} setQuality={setQuality} />
|
||||||
|
<FrameDetect />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
placement="bottomLeft"
|
||||||
|
trigger="click"
|
||||||
|
>
|
||||||
|
<div className="flex h-[32px] cursor-pointer items-center justify-center rounded px-2 text-neutral-300 hover:bg-neutral-700/80">
|
||||||
|
<MonitorIcon size={18} />
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
61
web/src/pages/desktop/menu/screen/quality.tsx
Normal file
61
web/src/pages/desktop/menu/screen/quality.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { Popover } from 'antd';
|
||||||
|
import { CheckIcon, SquareActivityIcon } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { updateScreen } from '@/api/vm';
|
||||||
|
import { setQuality as setCookie } from '@/lib/localstorage.ts';
|
||||||
|
|
||||||
|
const qualityList = [
|
||||||
|
{ key: 99, label: '100%' },
|
||||||
|
{ key: 90, label: '90%' },
|
||||||
|
{ key: 80, label: '80%' },
|
||||||
|
{ key: 70, label: '70%' },
|
||||||
|
{ key: 60, label: '60%' },
|
||||||
|
{ key: 51, label: '50%' }
|
||||||
|
];
|
||||||
|
|
||||||
|
type QualityProps = {
|
||||||
|
quality: number;
|
||||||
|
setQuality: (quality: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Quality = ({ quality, setQuality }: QualityProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
async function update(value: number) {
|
||||||
|
const rsp = await updateScreen('quality', value);
|
||||||
|
|
||||||
|
if (rsp.code !== 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setQuality(value);
|
||||||
|
setCookie(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
{qualityList.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.key}
|
||||||
|
className="flex h-[30px] cursor-pointer select-none items-center space-x-1 rounded pl-1 pr-5 hover:bg-neutral-600"
|
||||||
|
onClick={() => update(item.key)}
|
||||||
|
>
|
||||||
|
<div className="flex h-[14px] w-[20px] items-end">
|
||||||
|
{item.key === quality && <CheckIcon size={14} />}
|
||||||
|
</div>
|
||||||
|
<span className="flex w-[32px]">{item.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover content={content} placement="rightTop">
|
||||||
|
<div className="flex h-[30px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700">
|
||||||
|
<SquareActivityIcon size={18} />
|
||||||
|
<span className="select-none text-sm">{t('screen.quality')}</span>
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
59
web/src/pages/desktop/menu/screen/resolution.tsx
Normal file
59
web/src/pages/desktop/menu/screen/resolution.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { Popover } from 'antd';
|
||||||
|
import { useAtom } from 'jotai';
|
||||||
|
import { CheckIcon, RatioIcon } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { updateScreen } from '@/api/vm';
|
||||||
|
import { Resolution as TypeResolution } from '@/types';
|
||||||
|
import { setResolution as setCookie } from '@/lib/localstorage';
|
||||||
|
import { resolutionAtom } from '@/jotai/resolution.ts';
|
||||||
|
|
||||||
|
const resolutions: TypeResolution[] = [
|
||||||
|
{ width: 1920, height: 1080 },
|
||||||
|
{ width: 1280, height: 720 },
|
||||||
|
{ width: 800, height: 600 },
|
||||||
|
{ width: 640, height: 480 }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const Resolution = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [resolution, setResolution] = useAtom(resolutionAtom);
|
||||||
|
|
||||||
|
async function update(item: TypeResolution) {
|
||||||
|
const rsp = await updateScreen('resolution', item.height);
|
||||||
|
if (rsp.code !== 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setResolution(item);
|
||||||
|
setCookie(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
{resolutions.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.height}
|
||||||
|
className="flex cursor-pointer select-none items-center space-x-1 rounded py-1.5 pl-1 pr-5 hover:bg-neutral-600"
|
||||||
|
onClick={() => update(item)}
|
||||||
|
>
|
||||||
|
<div className="flex h-[14px] w-[20px] items-end">
|
||||||
|
{item.height === resolution?.height && <CheckIcon size={14} />}
|
||||||
|
</div>
|
||||||
|
<span className="flex w-[32px]">{item.width}</span>
|
||||||
|
<span>x</span>
|
||||||
|
<span className="w-[36px]">{item.height}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover content={content} placement="rightTop">
|
||||||
|
<div className="flex h-[30px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700">
|
||||||
|
<RatioIcon size={18} />
|
||||||
|
<span className="select-none text-sm">{t('screen.resolution')}</span>
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
205
web/src/pages/desktop/menu/script/index.tsx
Normal file
205
web/src/pages/desktop/menu/script/index.tsx
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
import { ChangeEvent, useRef, useState } from 'react';
|
||||||
|
import { UploadOutlined } from '@ant-design/icons';
|
||||||
|
import { Button, Divider, Popconfirm, Popover } from 'antd';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { ChevronRightIcon, FileCodeIcon } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import * as api from '@/api/script.ts';
|
||||||
|
|
||||||
|
import { Run } from './run';
|
||||||
|
|
||||||
|
export const Script = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||||
|
const [scripts, setScripts] = useState<string[]>([]);
|
||||||
|
const [currentScript, setCurrentScript] = useState('');
|
||||||
|
const [isRunning, setIsRunning] = useState(false);
|
||||||
|
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const inputRef = useRef<any>(null);
|
||||||
|
|
||||||
|
function handleOpenChange(open: boolean) {
|
||||||
|
if (open) {
|
||||||
|
getScripts();
|
||||||
|
} else {
|
||||||
|
setCurrentScript('');
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsPopoverOpen(open);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectFile() {
|
||||||
|
if (inputRef.current) {
|
||||||
|
inputRef.current.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
inputRef.current?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
function uploadFile(e: ChangeEvent<HTMLInputElement>) {
|
||||||
|
if (!e.target?.files?.length) return;
|
||||||
|
const file = e.target.files[0];
|
||||||
|
|
||||||
|
if (isUploading) return;
|
||||||
|
setIsUploading(true);
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
api
|
||||||
|
.uploadScript(formData)
|
||||||
|
.then((rsp) => {
|
||||||
|
if (rsp.code !== 0) {
|
||||||
|
console.log(rsp.msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!scripts.includes(rsp.data.file)) {
|
||||||
|
setScripts([...scripts, rsp.data.file]);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsUploading(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function runScript(type: string) {
|
||||||
|
if (!currentScript) return;
|
||||||
|
|
||||||
|
if (type === 'foreground') {
|
||||||
|
setIsRunning(true);
|
||||||
|
} else {
|
||||||
|
api.runScript(currentScript, type).then((rsp) => {
|
||||||
|
if (rsp.code !== 0) {
|
||||||
|
console.log(rsp.msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsPopoverOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getScripts() {
|
||||||
|
api.getScripts().then((rsp) => {
|
||||||
|
if (rsp.code !== 0) {
|
||||||
|
console.log(rsp.msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rsp.data?.files?.length > 0) {
|
||||||
|
setScripts(rsp.data.files);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteScript() {
|
||||||
|
if (!currentScript) return;
|
||||||
|
|
||||||
|
api.deleteScript(currentScript).then((rsp) => {
|
||||||
|
if (rsp.code !== 0) {
|
||||||
|
console.log(rsp.msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setScripts(scripts.filter((script) => script !== currentScript));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function activate(script: string) {
|
||||||
|
setCurrentScript(script === currentScript ? '' : script);
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<div className="min-w-[250px]">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center justify-between px-1">
|
||||||
|
<span className="text-base font-bold text-neutral-300">{t('script.title')}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".sh,.py"
|
||||||
|
className="hidden"
|
||||||
|
onChange={uploadFile}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
ghost
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
icon={<UploadOutlined />}
|
||||||
|
loading={isUploading}
|
||||||
|
onClick={selectFile}
|
||||||
|
>
|
||||||
|
{t('script.upload')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Divider style={{ margin: '10px 0 15px 0' }} />
|
||||||
|
|
||||||
|
{scripts.map((script) => (
|
||||||
|
<div
|
||||||
|
key={script}
|
||||||
|
className={clsx(
|
||||||
|
'my-1 cursor-pointer rounded',
|
||||||
|
script === currentScript ? 'bg-neutral-700/50' : 'hover:bg-neutral-700/50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between space-x-5 px-2 py-1.5"
|
||||||
|
onClick={() => activate(script)}
|
||||||
|
>
|
||||||
|
<div className="max-w-[300px] select-none truncate">{script}</div>
|
||||||
|
<div className={clsx('h-[16px] w-[16px]', script === currentScript && 'rotate-90')}>
|
||||||
|
<ChevronRightIcon size={16} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{script === currentScript && (
|
||||||
|
<div className="flex items-center justify-end space-x-2 p-3">
|
||||||
|
<Button type="primary" size="small" onClick={() => runScript('foreground')}>
|
||||||
|
{t('script.run')}
|
||||||
|
</Button>
|
||||||
|
<Button type="primary" size="small" onClick={() => runScript('background')}>
|
||||||
|
{t('script.runBackground')}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Popconfirm
|
||||||
|
title={t('script.attention')}
|
||||||
|
description={t('script.delDesc')}
|
||||||
|
onConfirm={deleteScript}
|
||||||
|
onCancel={() => {}}
|
||||||
|
okText={t('script.confirm')}
|
||||||
|
cancelText={t('script.cancel')}
|
||||||
|
placement="bottom"
|
||||||
|
>
|
||||||
|
<Button danger type="primary" size="small">
|
||||||
|
{t('script.delete')}
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Popover
|
||||||
|
content={content}
|
||||||
|
placement="bottomLeft"
|
||||||
|
trigger="click"
|
||||||
|
open={isPopoverOpen}
|
||||||
|
onOpenChange={handleOpenChange}
|
||||||
|
>
|
||||||
|
<div className="flex h-[30px] cursor-pointer items-center justify-center rounded px-2 text-neutral-300 hover:bg-neutral-700">
|
||||||
|
<FileCodeIcon size={18} />
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
{isRunning && <Run script={currentScript} setIsRunning={setIsRunning} />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
63
web/src/pages/desktop/menu/script/run.tsx
Normal file
63
web/src/pages/desktop/menu/script/run.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { LoadingOutlined } from '@ant-design/icons';
|
||||||
|
import { Button, Card, Modal, Spin } from 'antd';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import * as api from '@/api/script';
|
||||||
|
|
||||||
|
type RunProps = {
|
||||||
|
script: string;
|
||||||
|
setIsRunning: (isRunning: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Run = ({ script, setIsRunning }: RunProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [state, setState] = useState('');
|
||||||
|
const [log, setLog] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setState('running');
|
||||||
|
|
||||||
|
api
|
||||||
|
.runScript(script, 'foreground')
|
||||||
|
.then((rsp) => {
|
||||||
|
if (rsp.code !== 0) {
|
||||||
|
setLog(rsp.msg);
|
||||||
|
setState('failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState('success');
|
||||||
|
setLog(rsp.data.log);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setLog(t('script.runFailed'));
|
||||||
|
setState('failed');
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={script}
|
||||||
|
width={600}
|
||||||
|
closable={false}
|
||||||
|
footer={null}
|
||||||
|
style={{ top: 60 }}
|
||||||
|
open={true}
|
||||||
|
>
|
||||||
|
{state === 'running' ? (
|
||||||
|
<div className="flex h-[300px] items-center justify-center">
|
||||||
|
<Spin indicator={<LoadingOutlined spin />} size="large" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Card className="h-[600px] overflow-auto whitespace-pre-line font-mono">{log}</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-5 flex justify-center">
|
||||||
|
<Button type="primary" onClick={() => setIsRunning(false)}>
|
||||||
|
{t('script.close')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
121
web/src/pages/desktop/menu/settings/about.tsx
Normal file
121
web/src/pages/desktop/menu/settings/about.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Modal } from 'antd';
|
||||||
|
import { useSetAtom } from 'jotai';
|
||||||
|
import { BookOpenIcon, GithubIcon, LoaderCircleIcon, MessageSquareIcon } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { getInfo } from '@/api/vm';
|
||||||
|
import { isSettingsOpenAtom } from '@/jotai/settings';
|
||||||
|
|
||||||
|
type Info = {
|
||||||
|
ip: string;
|
||||||
|
mdns: string;
|
||||||
|
image: string;
|
||||||
|
firmware: string;
|
||||||
|
deviceKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const About = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const setIsSettingsOpen = useSetAtom(isSettingsOpenAtom);
|
||||||
|
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [info, setInfo] = useState<Info | undefined>();
|
||||||
|
|
||||||
|
const communities = [
|
||||||
|
{ name: 'wiki', icon: <BookOpenIcon size={24} />, url: 'https://wiki.sipeed.com/nanokvm' },
|
||||||
|
{ name: 'github', icon: <GithubIcon size={24} />, url: 'https://github.com/sipeed/NanoKVM' },
|
||||||
|
{
|
||||||
|
name: 'discussion',
|
||||||
|
icon: <MessageSquareIcon size={24} />,
|
||||||
|
url: 'https://maixhub.com/discussion/nanokvm'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
getInfo()
|
||||||
|
.then((rsp: any) => {
|
||||||
|
if (rsp.code !== 0) {
|
||||||
|
console.log(rsp.msg);
|
||||||
|
setInfo(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setInfo(rsp.data);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setInfo(undefined);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function showModal() {
|
||||||
|
setIsModalOpen(true);
|
||||||
|
setIsSettingsOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPage(url: string) {
|
||||||
|
window.open(url, '_blank');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="flex cursor-pointer select-none items-center rounded px-3 py-1.5 hover:bg-neutral-600"
|
||||||
|
onClick={showModal}
|
||||||
|
>
|
||||||
|
{t('about.title')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={t('about.title')}
|
||||||
|
open={isModalOpen}
|
||||||
|
onCancel={() => setIsModalOpen(false)}
|
||||||
|
width={450}
|
||||||
|
footer={null}
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<div className="my-5 h-[1px] bg-neutral-700/50" />
|
||||||
|
|
||||||
|
<div className="flex w-full flex-col space-y-2">
|
||||||
|
<span className="text-neutral-400">{t('about.information')}</span>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<LoaderCircleIcon className="animate-spin" size={18} />
|
||||||
|
) : info ? (
|
||||||
|
<div className="flex w-full flex-col space-y-1">
|
||||||
|
{Object.entries(info).map(([key, value]) => (
|
||||||
|
<div key={key} className="flex w-full items-center justify-between">
|
||||||
|
<span>{t(`about.${key}`)}</span>
|
||||||
|
<span>{value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span>{t('about.queryFailed')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="my-5 h-[1px] bg-neutral-700/50" />
|
||||||
|
<div className="text-neutral-400">{t('about.community')}</div>
|
||||||
|
<div className="my-3 flex space-x-5">
|
||||||
|
{communities.map((community) => (
|
||||||
|
<div
|
||||||
|
key={community.name}
|
||||||
|
className="flex h-20 w-20 cursor-pointer flex-col items-center justify-center space-y-1 rounded text-neutral-400 outline outline-1 outline-neutral-700 hover:bg-neutral-800"
|
||||||
|
onClick={() => openPage(community.url)}
|
||||||
|
>
|
||||||
|
{community.icon}
|
||||||
|
<span className="text-xs text-neutral-300">{community.name}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
20
web/src/pages/desktop/menu/settings/change-password.tsx
Normal file
20
web/src/pages/desktop/menu/settings/change-password.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
export const ChangePassword = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
function changePassword() {
|
||||||
|
navigate('/auth/password');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex cursor-pointer select-none items-center rounded px-3 py-1.5 hover:bg-neutral-600"
|
||||||
|
onClick={changePassword}
|
||||||
|
>
|
||||||
|
{t('changePassword')}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
53
web/src/pages/desktop/menu/settings/index.tsx
Normal file
53
web/src/pages/desktop/menu/settings/index.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Badge, Divider, Popover } from 'antd';
|
||||||
|
import { useAtom } from 'jotai';
|
||||||
|
import { SettingsIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { isSettingsOpenAtom } from '@/jotai/settings.ts';
|
||||||
|
|
||||||
|
import { About } from './about.tsx';
|
||||||
|
import { ChangePassword } from './change-password.tsx';
|
||||||
|
import { Language } from './language.tsx';
|
||||||
|
import { Logout } from './logout.tsx';
|
||||||
|
import { Tailscale } from './tailscale.tsx';
|
||||||
|
import { Update } from './update.tsx';
|
||||||
|
import { VirtualDevice } from './virtual-device.tsx';
|
||||||
|
|
||||||
|
export const Settings = () => {
|
||||||
|
const [isSettingsOpen, setIsSettingsOpen] = useAtom(isSettingsOpenAtom);
|
||||||
|
const [isBadgeVisible, setIsBadgeVisible] = useState(false);
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
<Language />
|
||||||
|
<About />
|
||||||
|
<Update setIsBadgeVisible={setIsBadgeVisible} />
|
||||||
|
<Divider style={{ margin: '10px 0' }} />
|
||||||
|
|
||||||
|
<VirtualDevice />
|
||||||
|
<Tailscale />
|
||||||
|
<Divider style={{ margin: '10px 0' }} />
|
||||||
|
|
||||||
|
<ChangePassword />
|
||||||
|
<Logout />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Popover
|
||||||
|
content={content}
|
||||||
|
placement="bottomLeft"
|
||||||
|
trigger="click"
|
||||||
|
open={isSettingsOpen}
|
||||||
|
onOpenChange={setIsSettingsOpen}
|
||||||
|
>
|
||||||
|
<div className="flex h-[30px] cursor-pointer items-center justify-center space-x-1 rounded px-2 text-white hover:bg-neutral-700">
|
||||||
|
<Badge dot={isBadgeVisible} color="blue" offset={[1, 0]}>
|
||||||
|
<SettingsIcon size={18} />
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
49
web/src/pages/desktop/menu/settings/language.tsx
Normal file
49
web/src/pages/desktop/menu/settings/language.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { Popover } from 'antd';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { LanguagesIcon } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { setLanguage } from '@/lib/localstorage.ts';
|
||||||
|
|
||||||
|
export const Language = () => {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
|
||||||
|
function changeLanguage(language: string) {
|
||||||
|
if (i18n.language === language) return;
|
||||||
|
i18n.changeLanguage(language).then();
|
||||||
|
setLanguage(language);
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'flex cursor-pointer select-none items-center space-x-1 rounded px-5 py-1',
|
||||||
|
i18n.language === 'en' ? 'text-blue-500' : 'text-white hover:bg-neutral-700'
|
||||||
|
)}
|
||||||
|
onClick={() => changeLanguage('en')}
|
||||||
|
>
|
||||||
|
English
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'flex cursor-pointer select-none items-center space-x-1 rounded px-5 py-1',
|
||||||
|
i18n.language === 'zh' ? 'text-blue-500' : 'text-white hover:bg-neutral-700'
|
||||||
|
)}
|
||||||
|
onClick={() => changeLanguage('zh')}
|
||||||
|
>
|
||||||
|
中文
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover content={content} placement="right" trigger="hover" arrow>
|
||||||
|
<div className="flex cursor-pointer select-none items-center space-x-2 rounded px-3 py-1.5 text-white hover:bg-neutral-600">
|
||||||
|
<LanguagesIcon size={16} />
|
||||||
|
<span>{t('language')}</span>
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
23
web/src/pages/desktop/menu/settings/logout.tsx
Normal file
23
web/src/pages/desktop/menu/settings/logout.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { removeToken } from '@/lib/cookie.ts';
|
||||||
|
|
||||||
|
export const Logout = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
removeToken();
|
||||||
|
navigate('/auth/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex cursor-pointer select-none items-center rounded px-3 py-1.5 hover:bg-neutral-600"
|
||||||
|
onClick={logout}
|
||||||
|
>
|
||||||
|
{t('logout')}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
202
web/src/pages/desktop/menu/settings/tailscale.tsx
Normal file
202
web/src/pages/desktop/menu/settings/tailscale.tsx
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Button, Modal } from 'antd';
|
||||||
|
import { useSetAtom } from 'jotai';
|
||||||
|
import { LoaderCircleIcon } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import * as api from '@/api/network';
|
||||||
|
import { isSettingsOpenAtom } from '@/jotai/settings.ts';
|
||||||
|
|
||||||
|
type State = '' | 'installing' | 'installed' | 'failed';
|
||||||
|
|
||||||
|
export const Tailscale = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const setIsSettingsOpen = useSetAtom(isSettingsOpenAtom);
|
||||||
|
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isStarting, setIsStarting] = useState(false);
|
||||||
|
const [state, setState] = useState<State>('');
|
||||||
|
const [loginUrl, setLoginUrl] = useState('');
|
||||||
|
|
||||||
|
function openModal() {
|
||||||
|
check();
|
||||||
|
|
||||||
|
setIsModalOpen(true);
|
||||||
|
setIsSettingsOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function check() {
|
||||||
|
if (['installing', 'installed'].includes(state)) return;
|
||||||
|
|
||||||
|
if (isLoading) return;
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
api
|
||||||
|
.getTailscale()
|
||||||
|
.then((rsp) => {
|
||||||
|
if (rsp.code !== 0) {
|
||||||
|
console.log(rsp.msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rsp.data.exist) {
|
||||||
|
setState('installed');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function install() {
|
||||||
|
if (state === 'installing') return;
|
||||||
|
setState('installing');
|
||||||
|
|
||||||
|
api
|
||||||
|
.installTailscale()
|
||||||
|
.then((rsp) => {
|
||||||
|
if (rsp.code !== 0) {
|
||||||
|
console.log(rsp.msg);
|
||||||
|
setState('failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState('installed');
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setState('failed');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function run() {
|
||||||
|
if (state !== 'installed') return;
|
||||||
|
|
||||||
|
if (isStarting) return;
|
||||||
|
setIsStarting(true);
|
||||||
|
|
||||||
|
setLoginUrl('');
|
||||||
|
|
||||||
|
api
|
||||||
|
.runTailscale()
|
||||||
|
.then((rsp) => {
|
||||||
|
if (rsp.code !== 0) {
|
||||||
|
console.log(rsp.msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rsp.data.url) {
|
||||||
|
updateLoginUrl(rsp.data.url);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsStarting(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLoginUrl(url: string) {
|
||||||
|
setLoginUrl(url);
|
||||||
|
|
||||||
|
window.open(url, '_blank');
|
||||||
|
|
||||||
|
setTimeout(() => setLoginUrl(''), 10 * 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="flex cursor-pointer select-none items-center rounded px-3 py-1.5 hover:bg-neutral-600"
|
||||||
|
onClick={openModal}
|
||||||
|
>
|
||||||
|
Tailscale
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="Tailscale"
|
||||||
|
open={isModalOpen}
|
||||||
|
width={450}
|
||||||
|
footer={null}
|
||||||
|
centered={true}
|
||||||
|
maskClosable={false}
|
||||||
|
onCancel={() => setIsModalOpen(false)}
|
||||||
|
>
|
||||||
|
<div className="flex h-[200px] items-center justify-center">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center space-x-2 text-neutral-500">
|
||||||
|
<LoaderCircleIcon className="animate-spin" size={18} />
|
||||||
|
<span>{t('tailscale.loading')}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{state === '' && (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center space-y-5">
|
||||||
|
<span>{t('tailscale.notInstalled')}</span>
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Button type="primary" onClick={install}>
|
||||||
|
{t('tailscale.install')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state === 'installing' && (
|
||||||
|
<div className="flex items-center justify-center space-x-2 text-neutral-500">
|
||||||
|
<LoaderCircleIcon className="animate-spin" size={18} />
|
||||||
|
<span>{t('tailscale.installing')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state === 'installed' && (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center space-y-5">
|
||||||
|
{loginUrl ? (
|
||||||
|
<>
|
||||||
|
<a href={loginUrl} target="_blank">
|
||||||
|
{loginUrl}
|
||||||
|
</a>
|
||||||
|
<span className="text-sm text-neutral-400">{t('tailscale.urlPeriod')}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span>{t('tailscale.installed')}</span>
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Button type="primary" loading={isStarting} onClick={run}>
|
||||||
|
{t('tailscale.login')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state === 'failed' && (
|
||||||
|
<div className="flex flex-col space-y-10">
|
||||||
|
<div className="flex flex-col items-center justify-center font-bold text-neutral-400">
|
||||||
|
<span>{t('tailscale.failed')}</span>
|
||||||
|
<span>{t('tailscale.retry')}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="list-decimal">
|
||||||
|
<li>
|
||||||
|
{t('tailscale.download')}
|
||||||
|
<a
|
||||||
|
className="px-1"
|
||||||
|
href="https://pkgs.tailscale.com/stable/tailscale_latest_riscv64.tgz"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
{t('tailscale.package')}
|
||||||
|
</a>
|
||||||
|
{t('tailscale.unzip')}
|
||||||
|
</li>
|
||||||
|
<li>{t('tailscale.upTailscale')}</li>
|
||||||
|
<li>{t('tailscale.upTailscaled')}</li>
|
||||||
|
<li>{t('tailscale.refresh')}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
176
web/src/pages/desktop/menu/settings/update.tsx
Normal file
176
web/src/pages/desktop/menu/settings/update.tsx
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Badge, Button, Modal, Spin } from 'antd';
|
||||||
|
import { useSetAtom } from 'jotai';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import semver from 'semver';
|
||||||
|
|
||||||
|
import * as api from '@/api/firmware.ts';
|
||||||
|
import { getSkipUpdate, setSkipUpdate } from '@/lib/localstorage.ts';
|
||||||
|
import { isSettingsOpenAtom } from '@/jotai/settings.ts';
|
||||||
|
|
||||||
|
type UpdateProps = {
|
||||||
|
setIsBadgeVisible: (isBadgeVisible: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Update = ({ setIsBadgeVisible }: UpdateProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const setIsSettingsOpen = useSetAtom(isSettingsOpenAtom);
|
||||||
|
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [current, setCurrent] = useState('');
|
||||||
|
const [latest, setLatest] = useState('');
|
||||||
|
const [isLatest, setIsLatest] = useState(true);
|
||||||
|
const [isUpdating, setIsUpdating] = useState(false);
|
||||||
|
const [errMsg, setErrMsg] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const skip = getSkipUpdate();
|
||||||
|
if (!skip) {
|
||||||
|
getVersion();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function showModal() {
|
||||||
|
getVersion();
|
||||||
|
|
||||||
|
setErrMsg('');
|
||||||
|
setIsModalOpen(true);
|
||||||
|
|
||||||
|
setIsSettingsOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVersion() {
|
||||||
|
if (isLoading) return;
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
api
|
||||||
|
.getVersion()
|
||||||
|
.then((rsp: any) => {
|
||||||
|
if (rsp.code !== 0) {
|
||||||
|
setErrMsg(t('update.queryFailed'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrent(rsp.data.current);
|
||||||
|
setLatest(rsp.data.latest);
|
||||||
|
|
||||||
|
if (semver.gt(rsp.data.latest, rsp.data.current)) {
|
||||||
|
setIsLatest(false);
|
||||||
|
setIsBadgeVisible(true);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setErrMsg(t('update.queryFailed'));
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirm() {
|
||||||
|
if (isLatest || !latest) {
|
||||||
|
setIsModalOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isUpdating) return;
|
||||||
|
setIsUpdating(true);
|
||||||
|
|
||||||
|
api
|
||||||
|
.update()
|
||||||
|
.then((rsp: any) => {
|
||||||
|
if (rsp.code !== 0) {
|
||||||
|
setErrMsg(t('update.updateFailed'));
|
||||||
|
setIsUpdating(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, 6000);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setErrMsg(t('update.updateFailed'));
|
||||||
|
setIsUpdating(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancel() {
|
||||||
|
setIsBadgeVisible(false);
|
||||||
|
|
||||||
|
setIsLatest(true);
|
||||||
|
setIsModalOpen(false);
|
||||||
|
setSkipUpdate(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="flex cursor-pointer select-none items-center rounded px-3 py-1.5 hover:bg-neutral-600"
|
||||||
|
onClick={showModal}
|
||||||
|
>
|
||||||
|
<Badge dot={!isLatest} color="blue" offset={[5, 5]}>
|
||||||
|
{t('update.title')}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={t('update.title')}
|
||||||
|
open={isModalOpen}
|
||||||
|
width={400}
|
||||||
|
footer={null}
|
||||||
|
closable={false}
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
{isUpdating ? (
|
||||||
|
<div className="flex h-[300px] flex-col items-center justify-center space-y-5">
|
||||||
|
<Spin spinning={isUpdating} size="large" />
|
||||||
|
<span className="text-neutral-400">{t('update.updating')}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center pb-5 pt-10">
|
||||||
|
{isLoading ? (
|
||||||
|
<Spin tip={t('update.loading')} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{errMsg !== '' ? (
|
||||||
|
<span>{errMsg}</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{isLatest ? (
|
||||||
|
<div className="flex flex-col items-center space-y-1">
|
||||||
|
<span className="text-neutral-400">{current}</span>
|
||||||
|
<span>{t('update.isLatest')}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center space-y-1">
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<span>{current}</span>
|
||||||
|
<span className="text-neutral-500">{'->'}</span>
|
||||||
|
<span>{latest}</span>
|
||||||
|
</div>
|
||||||
|
<span>{t('update.available')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-10 flex items-center space-x-3">
|
||||||
|
<Button type="primary" onClick={confirm}>
|
||||||
|
{t('update.confirm')}
|
||||||
|
</Button>
|
||||||
|
{!isLatest && (
|
||||||
|
<Button type="default" onClick={cancel}>
|
||||||
|
{t('update.cancel')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
66
web/src/pages/desktop/menu/settings/virtual-device.tsx
Normal file
66
web/src/pages/desktop/menu/settings/virtual-device.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { CheckIcon } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import * as api from '@/api/virtual-device';
|
||||||
|
|
||||||
|
export const VirtualDevice = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [isNetworkOn, setIsNetworkOn] = useState(false);
|
||||||
|
const [isUSBOn, setIsUSBOn] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.getVirtualDevice().then((rsp) => {
|
||||||
|
if (rsp.code !== 0) {
|
||||||
|
console.log(rsp.msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsNetworkOn(rsp.data.network);
|
||||||
|
setIsUSBOn(rsp.data.usb);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function update(device: string) {
|
||||||
|
if (isLoading) return;
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
api
|
||||||
|
.updateVirtualDevice(device)
|
||||||
|
.then((rsp) => {
|
||||||
|
if (rsp.code !== 0) {
|
||||||
|
console.log(rsp.msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (device === 'network') {
|
||||||
|
setIsNetworkOn(rsp.data.on);
|
||||||
|
} else {
|
||||||
|
setIsUSBOn(rsp.data.on);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="flex cursor-pointer select-none items-center space-x-1 rounded px-3 py-1.5 text-white hover:bg-neutral-600"
|
||||||
|
onClick={() => update('network')}
|
||||||
|
>
|
||||||
|
{isNetworkOn && <CheckIcon size={18} color="#3b82f6" />}
|
||||||
|
<span>{t('virtualDevice.network')}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="flex cursor-pointer select-none items-center space-x-1 rounded px-3 py-1.5 text-white hover:bg-neutral-600"
|
||||||
|
onClick={() => update('usb')}
|
||||||
|
>
|
||||||
|
{isUSBOn && <CheckIcon size={18} color="#3b82f6" />}
|
||||||
|
<span>{t('virtualDevice.usb')}</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
164
web/src/pages/desktop/menu/storage.tsx
Normal file
164
web/src/pages/desktop/menu/storage.tsx
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Divider, Popover } from 'antd';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import {
|
||||||
|
DiscIcon,
|
||||||
|
FileBoxIcon,
|
||||||
|
LoaderCircleIcon,
|
||||||
|
PackageIcon,
|
||||||
|
PackageSearchIcon,
|
||||||
|
XIcon
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import * as api from '@/api/storage';
|
||||||
|
import { client } from '@/lib/websocket.ts';
|
||||||
|
|
||||||
|
export const Storage = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [files, setFiles] = useState<string[]>([]);
|
||||||
|
const [mountingFile, setMountingFile] = useState('');
|
||||||
|
const [mountedFile, setMountedFile] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getFiles();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function handleOpenChange(open: boolean) {
|
||||||
|
if (open) {
|
||||||
|
getFiles();
|
||||||
|
}
|
||||||
|
setIsPopoverOpen(open);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取镜像列表
|
||||||
|
function getFiles() {
|
||||||
|
if (isLoading) return;
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
api
|
||||||
|
.getImages()
|
||||||
|
.then((rsp) => {
|
||||||
|
if (rsp.code !== 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const images = rsp.data.files;
|
||||||
|
|
||||||
|
if (!images?.length) {
|
||||||
|
setFiles([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setFiles(images);
|
||||||
|
|
||||||
|
// 获取已挂载的镜像
|
||||||
|
api.getMountedImage().then((rsp) => {
|
||||||
|
if (rsp.code === 0 && rsp.data?.file && images.includes(rsp.data.file)) {
|
||||||
|
setMountedFile(rsp.data.file);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 挂载/取消挂载
|
||||||
|
function mountFile(file: string) {
|
||||||
|
if (mountingFile) return;
|
||||||
|
setMountingFile(file);
|
||||||
|
|
||||||
|
client.close();
|
||||||
|
|
||||||
|
const filename = mountedFile === file ? '' : file;
|
||||||
|
|
||||||
|
api
|
||||||
|
.mountImage(filename)
|
||||||
|
.then((rsp) => {
|
||||||
|
if (rsp.code !== 0) {
|
||||||
|
console.log(rsp.msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setMountedFile(filename);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setMountingFile('');
|
||||||
|
client.connect();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<div className="min-w-[250px]">
|
||||||
|
<div className="flex items-center justify-between px-1">
|
||||||
|
<span className="text-base font-bold text-neutral-300">{t('images')}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider style={{ margin: '10px 0 15px 0' }} />
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center space-x-2 py-2 pl-2 pr-4 text-neutral-400">
|
||||||
|
<LoaderCircleIcon className="animate-spin" size={18} />
|
||||||
|
<span className="text-sm">{t('loading')}</span>
|
||||||
|
</div>
|
||||||
|
) : files.length === 0 ? (
|
||||||
|
<div className="flex items-center space-x-2 pl-2 pr-4 text-neutral-500">
|
||||||
|
<PackageSearchIcon size={18} />
|
||||||
|
<span className="text-sm">{t('empty')}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
files.map((file) => (
|
||||||
|
<div
|
||||||
|
key={file}
|
||||||
|
className={clsx(
|
||||||
|
'group my-1 flex max-w-[300px] cursor-pointer select-none items-center space-x-2 rounded py-2 pl-2 pr-4 hover:bg-neutral-700/80',
|
||||||
|
{ 'text-blue-500': mountedFile === file }
|
||||||
|
)}
|
||||||
|
onClick={() => mountFile(file)}
|
||||||
|
>
|
||||||
|
{mountedFile === file ? (
|
||||||
|
<>
|
||||||
|
<div className="h-[18px] w-[18px] group-hover:text-red-500">
|
||||||
|
<PackageIcon size={18} className="block group-hover:hidden" />
|
||||||
|
<XIcon size={18} className="hidden group-hover:block" />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : mountingFile === file ? (
|
||||||
|
<div className="h-[18px] w-[18px]">
|
||||||
|
<LoaderCircleIcon className="animate-spin" size={18} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="h-[18px] w-[18px]">
|
||||||
|
<FileBoxIcon size={18} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span className="break-all">{file.replace(/^.*[\\/]/, '')}</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
content={content}
|
||||||
|
placement="bottomLeft"
|
||||||
|
trigger="click"
|
||||||
|
open={isPopoverOpen}
|
||||||
|
onOpenChange={handleOpenChange}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'flex h-[30px] cursor-pointer items-center justify-center rounded px-2 hover:bg-neutral-700',
|
||||||
|
mountedFile ? 'text-blue-500' : 'text-neutral-300'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<DiscIcon size={18} />
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
39
web/src/pages/desktop/menu/terminal/index.tsx
Normal file
39
web/src/pages/desktop/menu/terminal/index.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Divider, Popover } from 'antd';
|
||||||
|
import { SquareTerminalIcon } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { Nanokvm } from './nanokvm';
|
||||||
|
import { SerialPort } from './serial-port';
|
||||||
|
|
||||||
|
export const Terminal = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<div className="min-w-[200px]">
|
||||||
|
<div className="flex items-center justify-between px-1">
|
||||||
|
<span className="text-base font-bold text-neutral-300">{t('terminal.title')}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider style={{ margin: '10px 0 15px 0' }} />
|
||||||
|
|
||||||
|
<Nanokvm setIsPopoverOpen={setIsPopoverOpen} />
|
||||||
|
<SerialPort setIsPopoverOpen={setIsPopoverOpen} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
content={content}
|
||||||
|
placement="bottomLeft"
|
||||||
|
trigger="click"
|
||||||
|
open={isPopoverOpen}
|
||||||
|
onOpenChange={setIsPopoverOpen}
|
||||||
|
>
|
||||||
|
<div className="flex h-[30px] cursor-pointer items-center justify-center rounded px-2 text-neutral-300 hover:bg-neutral-700">
|
||||||
|
<SquareTerminalIcon size={18} />
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
25
web/src/pages/desktop/menu/terminal/nanokvm.tsx
Normal file
25
web/src/pages/desktop/menu/terminal/nanokvm.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { TerminalIcon } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
type NanokvmProps = {
|
||||||
|
setIsPopoverOpen: (open: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Nanokvm = ({ setIsPopoverOpen }: NanokvmProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
function openTerminal() {
|
||||||
|
setIsPopoverOpen(false);
|
||||||
|
window.open('/#terminal', '_blank');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex h-[28px] cursor-pointer select-none items-center space-x-2 rounded px-2 py-1 hover:bg-neutral-700/80"
|
||||||
|
onClick={openTerminal}
|
||||||
|
>
|
||||||
|
<TerminalIcon size={16} />
|
||||||
|
<span>{t('terminal.nanokvm')}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
108
web/src/pages/desktop/menu/terminal/serial-port.tsx
Normal file
108
web/src/pages/desktop/menu/terminal/serial-port.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { ChangeEvent, useState } from 'react';
|
||||||
|
import { Button, Input, InputNumber, Modal, Radio, RadioChangeEvent } from 'antd';
|
||||||
|
import { useSetAtom } from 'jotai';
|
||||||
|
import { TerminalIcon } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
|
||||||
|
|
||||||
|
type SerialPortProps = {
|
||||||
|
setIsPopoverOpen: (open: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SerialPort = ({ setIsPopoverOpen }: SerialPortProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const setIsKeyboardEnable = useSetAtom(isKeyboardEnableAtom);
|
||||||
|
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [port, setPort] = useState('');
|
||||||
|
const [baudrate, setBaudrate] = useState(115200);
|
||||||
|
|
||||||
|
function openModal() {
|
||||||
|
setIsKeyboardEnable(false);
|
||||||
|
|
||||||
|
setIsModalOpen(true);
|
||||||
|
setIsPopoverOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
setIsKeyboardEnable(true);
|
||||||
|
setIsModalOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInputChange(e: ChangeEvent<HTMLInputElement>) {
|
||||||
|
setPort(e.target.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRadioChange(e: RadioChangeEvent) {
|
||||||
|
setPort(e.target.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onInputNumberChange(value: number | null) {
|
||||||
|
if (value === null) return;
|
||||||
|
setBaudrate(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
if (!port || !baudrate) {
|
||||||
|
closeModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsModalOpen(false);
|
||||||
|
window.open(`/#terminal?port=${port}&baud=${baudrate}`, '_blank');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="flex h-[28px] cursor-pointer select-none items-center space-x-2 rounded px-2 py-1 hover:bg-neutral-700/80"
|
||||||
|
onClick={openModal}
|
||||||
|
>
|
||||||
|
<TerminalIcon size={16} />
|
||||||
|
<span>{t('terminal.serial')}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal open={isModalOpen} title={t('terminal.serial')} footer={null} onCancel={closeModal}>
|
||||||
|
<div className="mt-10 flex items-center space-x-[20px]">
|
||||||
|
<div className="flex w-[80px] justify-end text-neutral-400">
|
||||||
|
{t('terminal.serialPort')}
|
||||||
|
</div>
|
||||||
|
<div className="w-1/2">
|
||||||
|
<Input
|
||||||
|
value={port}
|
||||||
|
placeholder={t('terminal.serialPortPlaceholder')}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 pl-[100px]">
|
||||||
|
<Radio.Group size="large" onChange={handleRadioChange}>
|
||||||
|
<Radio value="/dev/ttyS1">
|
||||||
|
<code>/dev/ttyS1</code>
|
||||||
|
</Radio>
|
||||||
|
<Radio value="/dev/ttyS2">
|
||||||
|
<code>/dev/ttyS2</code>
|
||||||
|
</Radio>
|
||||||
|
</Radio.Group>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-7 flex items-center space-x-[20px]">
|
||||||
|
<div className="flex w-[80px] justify-end text-neutral-400">{t('terminal.baudrate')}</div>
|
||||||
|
<InputNumber
|
||||||
|
controls={false}
|
||||||
|
min={1}
|
||||||
|
defaultValue={115200}
|
||||||
|
onChange={onInputNumberChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3 mt-12 flex justify-center">
|
||||||
|
<Button type="primary" onClick={submit}>
|
||||||
|
{t('terminal.confirm')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
159
web/src/pages/desktop/menu/wol.tsx
Normal file
159
web/src/pages/desktop/menu/wol.tsx
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import { ChangeEvent, useRef, useState } from 'react';
|
||||||
|
import { Button, Divider, Input, List, Popover } from 'antd';
|
||||||
|
import type { InputRef } from 'antd';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { useSetAtom } from 'jotai';
|
||||||
|
import { NetworkIcon, SendIcon, Trash2Icon } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { deleteWolMac, getWolMacs, wol } from '@/api/network.ts';
|
||||||
|
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
|
||||||
|
|
||||||
|
export const Wol = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const setIsKeyboardEnable = useSetAtom(isKeyboardEnableAtom);
|
||||||
|
|
||||||
|
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||||
|
|
||||||
|
const [input, setInput] = useState('');
|
||||||
|
const [status, setStatus] = useState('');
|
||||||
|
const [log, setLog] = useState('');
|
||||||
|
|
||||||
|
const [macList, setMacList] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const inputRef = useRef<InputRef>(null);
|
||||||
|
|
||||||
|
function handleOpenChange(open: boolean) {
|
||||||
|
if (open) {
|
||||||
|
getMacs();
|
||||||
|
|
||||||
|
setIsKeyboardEnable(false);
|
||||||
|
} else {
|
||||||
|
setInput('');
|
||||||
|
setStatus('');
|
||||||
|
setLog('');
|
||||||
|
|
||||||
|
setIsKeyboardEnable(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsPopoverOpen(open);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleChange(e: ChangeEvent<HTMLInputElement>) {
|
||||||
|
setInput(e.target.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMacs() {
|
||||||
|
getWolMacs().then((rsp) => {
|
||||||
|
if (rsp.code !== 0) {
|
||||||
|
console.log(rsp.msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setMacList(rsp.data.macs.filter((mac: string) => mac !== ''));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteMac(mac: string) {
|
||||||
|
deleteWolMac(mac).then((rsp) => {
|
||||||
|
if (rsp.code === 0) {
|
||||||
|
setMacList(macList.filter((item) => item !== mac));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function wake(mac?: string) {
|
||||||
|
if (status === 'loading') return;
|
||||||
|
|
||||||
|
const value = mac ? mac : input;
|
||||||
|
if (!value) return;
|
||||||
|
|
||||||
|
setStatus('loading');
|
||||||
|
setLog(t('wol.sending'));
|
||||||
|
|
||||||
|
wol(value)
|
||||||
|
.then((rsp) => {
|
||||||
|
if (rsp.code !== 0) {
|
||||||
|
setStatus('failed');
|
||||||
|
setLog(rsp.msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus('success');
|
||||||
|
setLog(t('wol.sent'));
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setStatus('failed');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<div className="min-w-[300px]">
|
||||||
|
<div className="flex items-center justify-between px-1">
|
||||||
|
<span className="text-base font-bold text-neutral-300">Wake-on-LAN</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider style={{ margin: '10px 0 10px 0' }} />
|
||||||
|
|
||||||
|
<div className="pb-1 text-neutral-500">{t('wol.input')}</div>
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<Input ref={inputRef} value={input} onChange={handleChange} />
|
||||||
|
<Button type="primary" onClick={() => wake()}>
|
||||||
|
{t('wol.ok')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={clsx('py-2')}>
|
||||||
|
{status && (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'max-w-[300px] break-words text-sm',
|
||||||
|
status === 'failed' ? 'text-red-500' : 'text-green-500'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{log}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<List
|
||||||
|
itemLayout="horizontal"
|
||||||
|
locale={{ emptyText: ' ' }}
|
||||||
|
dataSource={macList}
|
||||||
|
renderItem={(mac) => (
|
||||||
|
<List.Item className="flex w-full items-center justify-between">
|
||||||
|
<div className="h-[24px] max-w-[200px]">{mac}</div>
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div
|
||||||
|
className="flex h-[24px] w-[24px] cursor-pointer items-center justify-center rounded text-green-500 hover:bg-neutral-700/80"
|
||||||
|
onClick={() => wake(mac)}
|
||||||
|
>
|
||||||
|
<SendIcon size={16} />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="flex h-[24px] w-[24px] cursor-pointer items-center justify-center rounded text-red-500 hover:bg-neutral-700"
|
||||||
|
onClick={() => deleteMac(mac)}
|
||||||
|
>
|
||||||
|
<Trash2Icon size={16} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
content={content}
|
||||||
|
placement="bottomLeft"
|
||||||
|
trigger="click"
|
||||||
|
open={isPopoverOpen}
|
||||||
|
onOpenChange={handleOpenChange}
|
||||||
|
>
|
||||||
|
<div className="flex h-[30px] cursor-pointer items-center justify-center rounded px-2 text-neutral-300 hover:bg-neutral-700">
|
||||||
|
<NetworkIcon size={18} />
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
97
web/src/pages/desktop/mouse/absolute.tsx
Normal file
97
web/src/pages/desktop/mouse/absolute.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
|
|
||||||
|
import { client } from '@/lib/websocket.ts';
|
||||||
|
import { resolutionAtom } from '@/jotai/resolution.ts';
|
||||||
|
|
||||||
|
import { MouseButton, MouseEvent } from './constants';
|
||||||
|
|
||||||
|
export const Absolute = () => {
|
||||||
|
const resolution = useAtomValue(resolutionAtom);
|
||||||
|
|
||||||
|
// listen mouse events
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = document.getElementById('screen');
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
canvas.addEventListener('mousedown', handleMouseDown);
|
||||||
|
canvas.addEventListener('mouseup', handleMouseUp);
|
||||||
|
canvas.addEventListener('mousemove', handleMouseMove);
|
||||||
|
canvas.addEventListener('wheel', handleWheel);
|
||||||
|
canvas.addEventListener('click', disableEvent);
|
||||||
|
canvas.addEventListener('contextmenu', disableEvent);
|
||||||
|
|
||||||
|
// press button
|
||||||
|
function handleMouseDown(event: any) {
|
||||||
|
disableEvent(event);
|
||||||
|
|
||||||
|
let button: MouseButton;
|
||||||
|
switch (event.button) {
|
||||||
|
case 0:
|
||||||
|
button = MouseButton.Left;
|
||||||
|
break;
|
||||||
|
case 1:
|
||||||
|
button = MouseButton.Wheel;
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
button = MouseButton.Right;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.log(`unknown button ${event.button}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = [2, MouseEvent.Down, button, 0, 0];
|
||||||
|
client.send(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// release button
|
||||||
|
function handleMouseUp(event: any) {
|
||||||
|
disableEvent(event);
|
||||||
|
|
||||||
|
const data = [2, MouseEvent.Up, MouseButton.None, 0, 0];
|
||||||
|
client.send(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// mouse move
|
||||||
|
function handleMouseMove(event: any) {
|
||||||
|
disableEvent(event);
|
||||||
|
|
||||||
|
const rect = canvas!.getBoundingClientRect();
|
||||||
|
const x = (event.clientX - rect.left) / resolution!.width;
|
||||||
|
const y = (event.clientY - rect.top) / resolution!.height;
|
||||||
|
const hexX = x < 0 ? 0x0001 : Math.floor(0x7fff * x) + 0x0001;
|
||||||
|
const hexY = y < 0 ? 0x0001 : Math.floor(0x7fff * y) + 0x0001;
|
||||||
|
|
||||||
|
const data = [2, MouseEvent.MoveAbsolute, MouseButton.None, hexX, hexY];
|
||||||
|
client.send(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// mouse scroll
|
||||||
|
function handleWheel(event: any) {
|
||||||
|
disableEvent(event);
|
||||||
|
|
||||||
|
const delta = Math.floor(event.deltaY);
|
||||||
|
if (delta === 0) return;
|
||||||
|
|
||||||
|
const data = [2, MouseEvent.Scroll, 0, 0, delta];
|
||||||
|
client.send(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
canvas.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
canvas.removeEventListener('mousedown', handleMouseDown);
|
||||||
|
canvas.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
canvas.removeEventListener('click', disableEvent);
|
||||||
|
canvas.removeEventListener('contextmenu', disableEvent);
|
||||||
|
};
|
||||||
|
}, [resolution]);
|
||||||
|
|
||||||
|
// disable default events
|
||||||
|
function disableEvent(event: any) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
|
||||||
|
return <></>;
|
||||||
|
};
|
||||||
14
web/src/pages/desktop/mouse/constants.ts
Normal file
14
web/src/pages/desktop/mouse/constants.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
export enum MouseEvent {
|
||||||
|
Up = 0,
|
||||||
|
Down = 1,
|
||||||
|
MoveAbsolute = 2,
|
||||||
|
MoveRelative = 3,
|
||||||
|
Scroll = 4
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum MouseButton {
|
||||||
|
None = 0,
|
||||||
|
Left = 1,
|
||||||
|
Right = 2,
|
||||||
|
Wheel = 3
|
||||||
|
}
|
||||||
12
web/src/pages/desktop/mouse/index.tsx
Normal file
12
web/src/pages/desktop/mouse/index.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { useAtomValue } from 'jotai';
|
||||||
|
|
||||||
|
import { mouseModeAtom } from '@/jotai/mouse.ts';
|
||||||
|
|
||||||
|
import { Absolute } from './absolute.tsx';
|
||||||
|
import { Relative } from './relative.tsx';
|
||||||
|
|
||||||
|
export const Mouse = () => {
|
||||||
|
const mouseMode = useAtomValue(mouseModeAtom);
|
||||||
|
|
||||||
|
return <>{mouseMode === 'relative' ? <Relative /> : <Absolute />}</>;
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user