mirror of
https://github.com/sipeed/NanoKVM.git
synced 2026-11-09 17:44:43 -06:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aae370fc2b | ||
|
|
74a8c9ee42 | ||
|
|
dddf3de8db | ||
|
|
4c4aa4270e | ||
|
|
a21159aaff | ||
|
|
1e3c131e0d | ||
|
|
a037767f77 |
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
|
||||
13
README.md
13
README.md
@@ -36,8 +36,8 @@ The NanoKVM Full is a full version with a sophisticated case and complete access
|
||||
| Video encoding | MJPEG, H264(developing) | MJPEG, H264(developing) | MJPEG, H264 |
|
||||
| Video delay | 90~230ms | 90~230ms | 100~230ms |
|
||||
| UEFI/BIOS | ✓ | ✓ | ✓ |
|
||||
| Keyboard and Mouse<br>Virtual HID | ✓ | ✓ | ✓ |
|
||||
| USB storage <br> Virtual CD-ROM | ✓ | ✓ | ✓ |
|
||||
| Virtual HID | ✓ | ✓ | ✓ |
|
||||
| Virtual CD-ROM | ✓ | ✓ | ✓ |
|
||||
| IPMI | ✓ | ✓ | ✓ |
|
||||
| Wake-on-LAN | ✓ | ✓ | ✓ |
|
||||
| ATX power control | None,Users can connect by their own | USB interface IO control board | RJ-45 interface IO control board |
|
||||
@@ -60,9 +60,16 @@ The NanoKVM Lite is constructed by the LicheeRV Nano and HDMItoCSI board, and th
|
||||
|
||||
The NanoKVM image is built on LicheeRV Nano SDK and MaixCDK, and is compatible with materials that use the LicheeRV Nano, opposite the KVM software cannot be used with the LicheeRV Nano or other SG2002 products. If you would like to build an HDMI input application on LicheeRV Nano or MaixCam, please contact us for technical support.
|
||||
|
||||
+ [HDMItoCSI Schematic](https://cn.dl.sipeed.com/fileList/KVM/nanoKVM/HDK/02_Schematic/SCH_RV_Nano_KVM_B_30131.pdf)
|
||||
+ [NanoKVM-A Schematic](https://cn.dl.sipeed.com/fileList/KVM/nanoKVM/HDK/02_Schematic/SCH_RV_Nano_KVM_A_30111.pdf)
|
||||
+ [NanoKVM-B Schematic](https://cn.dl.sipeed.com/fileList/KVM/nanoKVM/HDK/02_Schematic/SCH_HDMI_MIPI_31011.pdf)
|
||||
+ [NanoKVM img](https://github.com/sipeed/NanoKVM/releases/tag/NanoKVM)
|
||||
|
||||
## Where to buy
|
||||
|
||||
* [Aliexpress(global except USA&Russia)](https://www.aliexpress.com/item/1005007369816019.html)
|
||||
* [淘宝](https://item.taobao.com/item.htm?id=811206560480)
|
||||
* [Preorder (anyother country that not support in Aliexpress or Taobao)](https://sipeed.com/nanokvm)
|
||||
|
||||
|
||||
## Community
|
||||
|
||||
|
||||
13
README_ZH.md
13
README_ZH.md
@@ -36,8 +36,8 @@ NanoKVM Full 为完整版配置,带精致外壳和完整配件,内置开机
|
||||
| 视频编码 | MJPEG, H264(developing) | MJPEG, H264(developing) | MJPEG, H264 |
|
||||
| 视频延迟 | 90~230ms | 90~230ms | 100~230ms |
|
||||
| UEFI/BIOS | ✓ | ✓ | ✓ |
|
||||
| 模拟USB键鼠 <br>Virtual HID | ✓ | ✓ | ✓ |
|
||||
| 模拟USB存储 <br> Virtual CD-ROM | ✓ | ✓ | ✓ |
|
||||
| 模拟USB键鼠 | ✓ | ✓ | ✓ |
|
||||
| 模拟USB存储 | ✓ | ✓ | ✓ |
|
||||
| IPMI | ✓ | ✓ | ✓ |
|
||||
| Wake-on-LAN | ✓ | ✓ | ✓ |
|
||||
| ATX电源控制 | 无,用户可自行连接 | USB接口IO控制板 | RJ45接口IO控制板 |
|
||||
@@ -60,9 +60,16 @@ NanoKVM Lite 由 LicheeRV Nano E 和 HDMItoCSI 小板构成,NanoKVM FULL 在 N
|
||||
|
||||
NanoKVM 镜像在LicheeRV Nano SDK 和 MaixCDK 基础上构建,可以兼容使用 LicheeRV Nano 的资料,反之LicheeRV Nano 或其他 SG2002 产品无法使用KVM软件。如果您想在 LicheeRV Nano 或 MaixCam 上构建 HDMI输入相关应用,请与我们联系,以获得技术支持。
|
||||
|
||||
+ [HDMItoCSI 板原理图](https://cn.dl.sipeed.com/fileList/KVM/nanoKVM/HDK/02_Schematic/SCH_RV_Nano_KVM_B_30131.pdf)
|
||||
+ [NanoKVM-A 原理图](https://cn.dl.sipeed.com/fileList/KVM/nanoKVM/HDK/02_Schematic/SCH_RV_Nano_KVM_A_30111.pdf)
|
||||
+ [NanoKVM-B 原理图](https://cn.dl.sipeed.com/fileList/KVM/nanoKVM/HDK/02_Schematic/SCH_HDMI_MIPI_31011.pdf)
|
||||
+ [NanoKVM 镜像下载](https://github.com/sipeed/NanoKVM/releases/tag/NanoKVM)
|
||||
|
||||
|
||||
## 购买渠道
|
||||
|
||||
* [Aliexpress(全球,除了美国和俄罗斯)](https://www.aliexpress.com/item/1005007369816019.html)
|
||||
* [淘宝](https://item.taobao.com/item.htm?id=811206560480)
|
||||
* [Preorder (其它不支持淘宝和速卖通的国家)](https://sipeed.com/nanokvm)
|
||||
|
||||
## 社区
|
||||
|
||||
|
||||
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 resources
|
||||
├── 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'
|
||||
}
|
||||
}
|
||||
};
|
||||
144
web/src/i18n/fr.ts
Normal file
144
web/src/i18n/fr.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
export const fr = {
|
||||
translation: {
|
||||
language: 'Langue',
|
||||
changePassword: 'Changer le mot de passe',
|
||||
logout: 'Déconnexion',
|
||||
keyboard: 'Clavier',
|
||||
images: 'Images',
|
||||
loading: 'Chargement',
|
||||
empty: 'Vide',
|
||||
settings: 'Réglages',
|
||||
showMouse: 'Afficher la souris',
|
||||
hideMouse: 'Masquer la souris',
|
||||
power: 'Power',
|
||||
reset: 'Reset',
|
||||
powerShort: 'Power (appui court)',
|
||||
powerLong: 'Power (appui long)',
|
||||
hddLed: 'HDD LED',
|
||||
checkLibFailed: 'Impossible de vérifier la bibliothèque d\'exécution, veuillez réessayer',
|
||||
updateLibFailed: 'Impossible de mettre à jour la bibliothèque d\'exécution, veuillez réessayer',
|
||||
updatingLib: 'Mise à jour de la bibliothèque d\'exécution. Veuillez rafraîchir la page après la mise à jour.',
|
||||
checkForUpdate: 'Chercher des mises à jour',
|
||||
head: {
|
||||
desktop: 'Bureau à distance',
|
||||
login: 'Connexion',
|
||||
changePassword: 'Changer le mot de passe',
|
||||
terminal: 'Terminal'
|
||||
},
|
||||
auth: {
|
||||
login: 'Connexion',
|
||||
placeholderUsername: 'Veuillez entrer votre nom d\'utilisateur',
|
||||
placeholderPassword: 'Veuillez entrer votre mot de passe',
|
||||
placeholderPassword2: 'Veuillez entrer votre mot de passe à nouveau',
|
||||
noEmptyUsername: 'Le nom d\'utilisateur ne peut pas être vide',
|
||||
noEmptyPassword: 'Le mot de passe ne peut pas être vide',
|
||||
noAccount: 'Impossible de récupérer les informations de l\'utilisateur, veuillez rafraîchir la page ou réinitialiser le mot de passe',
|
||||
invalidUser: 'Nom d\'utilisateur ou mot de passe invalide',
|
||||
error: 'Erreur inattendue',
|
||||
changePassword: 'Changer le mot de passe',
|
||||
differentPassword: 'Les mots de passe ne correspondent pas',
|
||||
illegalUsername: 'Le nom d\'utilisateur contient des caractères illégaux',
|
||||
illegalPassword: 'Le mot de passe contient des caractères illégaux',
|
||||
forgetPassword: 'Mot de passe oublié',
|
||||
resetPassword: 'Réinitialiser le mot de passe',
|
||||
reset1: 'Si vous avez oublié le mot de passe, veuillez suivre les étapes pour le réinitialiser:',
|
||||
reset2: '1. Connectez-vous à l\'appareil NanoKVM via SSH;',
|
||||
reset3: '2. Supprimez le fichier de l\'appareil: ',
|
||||
reset4: '3. Utilisez le compte par défaut pour vous connecter: ',
|
||||
ok: 'Se connecter',
|
||||
cancel: 'Annuler'
|
||||
},
|
||||
screen: {
|
||||
resolution: 'Résolution',
|
||||
fps: 'FPS',
|
||||
customizeFps: 'Personnaliser',
|
||||
quality: 'Qualité',
|
||||
frameDetect: 'Frame Detect',
|
||||
frameDetectTip:
|
||||
"Calcule la différence entre les images. Arrête la transmission du flux vidéo lorsqu'aucun changement n'est détecté sur l'écran de l'hôte distant"
|
||||
},
|
||||
cursor: {
|
||||
default: 'Curseur par défaut',
|
||||
pointer: 'Curseur de la souris',
|
||||
cell: 'Curseur de cellule',
|
||||
text: 'Curseur de texte',
|
||||
grab: 'Curseur de poignée',
|
||||
hide: 'Cacher le curseur',
|
||||
mode: 'Mode de la souris',
|
||||
absolute: 'Mode absolu',
|
||||
relative: 'Mode relatif',
|
||||
requestPointer: 'Utilisation du mode relatif. Veuillez cliquer sur le bureau pour obtenir le pointeur de la souris.',
|
||||
resetHid: 'Réinitialiser le périphérique HID',
|
||||
},
|
||||
script: {
|
||||
title: 'Script',
|
||||
upload: 'Téléverser',
|
||||
run: 'Executer',
|
||||
runBackground: 'Executer en arrière-plan',
|
||||
runFailed: 'Échec de l\'exécution',
|
||||
attention: 'Attention',
|
||||
delDesc: 'Etes-vous sûr de vouloir supprimer ce fichier?',
|
||||
confirm: 'Oui',
|
||||
cancel: 'Non',
|
||||
delete: 'Supprimer',
|
||||
close: 'Fermer'
|
||||
},
|
||||
terminal: {
|
||||
title: 'Terminal',
|
||||
nanokvm: 'Terminal NanoKVM',
|
||||
serial: 'Terminal Port Série',
|
||||
serialPort: 'Port série',
|
||||
serialPortPlaceholder: 'Veuillez entrer le port série',
|
||||
baudrate: 'Débit en bauds',
|
||||
confirm: 'Ok'
|
||||
},
|
||||
wol: {
|
||||
sending: 'Envoi de la commande...',
|
||||
sent: 'Commande envoyée',
|
||||
input: 'Veuillez entrer l\'adresse MAC',
|
||||
ok: 'Ok'
|
||||
},
|
||||
about: {
|
||||
title: 'A propos de NanoKVM',
|
||||
information: 'Informations',
|
||||
ip: 'IP',
|
||||
mdns: 'mDNS',
|
||||
firmware: 'Version de l\'application',
|
||||
image: 'Version de l\'image',
|
||||
deviceKey: 'Clé de l\'appareil',
|
||||
queryFailed: 'Echec de la requête',
|
||||
community: 'Communauté',
|
||||
},
|
||||
update: {
|
||||
title: 'Vérifier les mises à jour',
|
||||
queryFailed: 'Impossible de vérifier les mises à jour. Veuillez réessayer.',
|
||||
updateFailed: 'Mis à jour échouée. Veuillez réessayer.',
|
||||
isLatest: 'Vous avez déjà la dernière version.',
|
||||
available: 'Une mise à jour est disponible. Voulez-vous vraiment mettre à jour?',
|
||||
updating: 'Mise à jour en cours. Veuillez patienter...',
|
||||
confirm: 'Confirmer',
|
||||
cancel: 'Annuler'
|
||||
},
|
||||
virtualDevice: {
|
||||
network: 'Réseau virtuel',
|
||||
usb: 'USB virtuel'
|
||||
},
|
||||
tailscale: {
|
||||
loading: 'Chargement...',
|
||||
notInstalled: 'Tailscale non trouvé! Veuillez l\'installer.',
|
||||
install: 'Installer',
|
||||
installing: 'Installation...',
|
||||
urlPeriod: 'L\'URL est valide pendant 10 minutes',
|
||||
installed: 'Tailscale est déjà installé. Veuillez vous connecter.',
|
||||
login: 'Connexion',
|
||||
failed: 'Installation échouée',
|
||||
retry: 'Veuillez rafraîchir et réessayer. Ou essayez d\'installer manuellement',
|
||||
download: 'Télécharger le',
|
||||
package: 'installation package',
|
||||
unzip: 'et décompressez-le',
|
||||
upTailscale: 'Téléverser tailscale dans le répertoire NanoKVM /usr/sbin/',
|
||||
upTailscaled: 'Téléverser tailscaled dans le répertoire NanoKVM /usr/sbin/',
|
||||
refresh: 'Rafraîchir la page courante'
|
||||
}
|
||||
}
|
||||
};
|
||||
24
web/src/i18n/index.ts
Normal file
24
web/src/i18n/index.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
|
||||
import { getLanguage } from '@/lib/localstorage.ts';
|
||||
|
||||
import { en } from './en';
|
||||
import { zh } from './zh';
|
||||
import { fr } from './fr';
|
||||
|
||||
const lng = getLanguage();
|
||||
|
||||
i18n
|
||||
.use(initReactI18next) // passes i18n down to react-i18next
|
||||
.init({
|
||||
resources: { zh, en, fr },
|
||||
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();
|
||||
123
web/src/lib/localstorage.ts
Normal file
123
web/src/lib/localstorage.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
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;
|
||||
|
||||
const language = navigator.language;
|
||||
|
||||
switch (true) {
|
||||
case language.indexOf('zh') > -1:
|
||||
return 'zh';
|
||||
case language.indexOf('fr') > -1:
|
||||
return 'fr';
|
||||
default:
|
||||
return 'en'; // Default language
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
59
web/src/pages/desktop/menu-phone/settings/language.tsx
Normal file
59
web/src/pages/desktop/menu-phone/settings/language.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
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 === 'fr' ? 'text-blue-500' : 'text-white hover:bg-neutral-700'
|
||||
)}
|
||||
onClick={() => changeLanguage('fr')}
|
||||
>
|
||||
Français
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
59
web/src/pages/desktop/menu/settings/language.tsx
Normal file
59
web/src/pages/desktop/menu/settings/language.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
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 === 'fr' ? 'text-blue-500' : 'text-white hover:bg-neutral-700'
|
||||
)}
|
||||
onClick={() => changeLanguage('fr')}
|
||||
>
|
||||
Français
|
||||
</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>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user