Compare commits

..

68 Commits
v0.1.2 ... main

Author SHA1 Message Date
kerms 9c6db50de2 fix(build): make command compatible on windows 2025-06-09 18:15:24 +02:00
kerms fc9648ea8b feat(widget): add Widget Panel with i18n, layout enhancements, and draggable UI
- Replaces AtCommand with WidgetPannel
- Adds export/import, local storage support
- Implements translation (i18n)
- Improves UI spacing and grid logic
- Adds reset-to-default and command loop composables
2025-06-09 17:48:20 +02:00
kerms 12b56e80d6 feat(AT-CMD): init 2025-05-31 19:50:44 +02:00
kerms dccf5feaa8 Merge branch 'wireless-proxy'
# Conflicts:
#	package-lock.json
#	package.json
#	set_env.sh
#	src/App.vue
#	src/api/index.ts
#	src/views/About.vue
#	src/views/Wifi.vue
#	src/views/navigation/NavBar.vue
2025-05-30 14:57:28 +02:00
kerms 94d8061436 fix(wifi) wifi info never update 2025-04-28 14:44:02 +02:00
kerms aaea2c50a7 fix(i18n) wifi notice not translated 2025-04-28 14:43:39 +02:00
kerms d0325e5b44 feat(env) add trial msg 2025-04-28 14:41:44 +02:00
kerms 28eb1cedbb minor(UI): uart button possition, french Translation 2025-04-25 10:02:28 +02:00
kerms b06b040e5c feat(websocket): IOS14 compatibility due to unsupported BroadcastChannel 2025-04-25 10:01:26 +02:00
kerms e96037a073 minor(lint) add ignore for production dirs 2025-01-18 16:43:03 +01:00
kerms 2f4790a57c feat(i18n): page tab's title 2025-01-18 16:42:08 +01:00
kerms b7590d8997 feat(i18n): page wifi, feedback amd about 2025-01-18 16:41:38 +01:00
kerms 2082f5b60c minor fix(navBar): title unrecognized 2024-09-23 16:10:27 +02:00
kerms d7d7c94f53 feat(i18n) multi-lang on uart page: Chinese, French and English. 2024-09-23 16:01:46 +02:00
kerms dd1b9adc0d feat(uart) make settings independent between pages, and add enable checkbox
settings were synced between pages, now each pages can have different settings.
2024-08-02 21:41:34 +08:00
kerms 8e0d8234e5 feat(app) support multi-tabs 2024-08-02 21:28:03 +08:00
kerms 6b2d195faf feat(uart layout) use localStorage to store settings and import/export 2024-07-31 20:38:32 +08:00
kerms aa4e83937a feat(uart) import and export settings 2024-07-31 20:36:43 +08:00
kerms 85864823af feat(uart) make settings use localStorage for make settings persistent 2024-07-31 20:35:57 +08:00
kerms 6a7ec54ce8 update(wifi) change wifi mode description 2024-07-24 15:53:02 +08:00
kerms ff3b8bc9c7 fix(uart) should not send uart set command when got config response 2024-07-24 15:52:25 +08:00
kerms 545d02e57b fix(uart) loopSend can't be cleared when change pages and back to uart page 2024-07-20 11:33:54 +08:00
kerms dc861ab4ef fix(uart) sending utf-8 text, some part of text was ignored 2024-07-17 09:45:32 +08:00
kerms 5f002865f1 add gitattributes to force set_env.sh be LF ending 2024-07-15 17:11:01 +08:00
kerms f785745c79 add help.svg for tooltip 2024-07-15 17:10:30 +08:00
kerms d3fd661ecf fix(wifi) text wrapped -> add width 2024-07-15 17:00:47 +08:00
kerms 5c70211154 fix(svg logo) update package.json and lock 2024-07-15 17:00:47 +08:00
kerms 6ff82d7fe4 feat(wifi) static IP/DNS 2024-07-15 17:00:47 +08:00
kerms d45dde9261 feat(wifi) configurable AP credential 2024-07-15 16:58:05 +08:00
kerms 794c02b94c feat(wifi) configurable AP/STA mode 2024-07-15 16:22:49 +08:00
kerms 70cf85aba5 fix(wifi) text wrapped -> add width 2024-07-15 16:16:06 +08:00
kerms af73147bc5 fix(svg logo) update package.json and lock 2024-07-15 14:51:47 +08:00
kerms e53b0ed883 feat(sys) add serial number display 2024-07-15 13:05:24 +08:00
kerms 72079d0cf9 feat(wifi) static IP/DNS 2024-07-15 11:33:45 +08:00
kerms d5a9bee947 feat(navbar) add badge on bar menu 2024-07-12 20:51:43 +08:00
kerms 1d5adbf14e feat(wifi) configurable AP credential 2024-07-12 20:45:05 +08:00
kerms 42624fb58c feat(wifi) configurable AP/STA mode 2024-07-12 14:33:21 +08:00
kerms 80bbaf8f54 fix(set_env) error:bad variable name, make compatible to sh 2024-07-07 10:39:44 +08:00
kerms 4e809cde3f minor(set_env,readme) change #!/bin/bash to #!/usr/bin/env bash for better portability 2024-07-07 10:37:04 +08:00
kerms 4900caeda4 minor(set_env,readme) change #!/bin/bash to #!/usr/bin/env bash for better portability adn set line to LF 2024-07-07 09:58:37 +08:00
kerms c4bf06f043 update(package-lock) 2024-07-02 17:17:45 +08:00
kerms a444813a1e update(README): add build steps
(cherry picked from commit f72d117d90)
2024-06-28 11:21:31 +08:00
kerms d534fd4ec8 feat(uart) clear RX/TX byte count on click 2024-06-28 11:18:14 +08:00
kerms 8a84b215a7 fix(send) msg not sent if msg empty but frame suffix/prefix not empty 2024-06-28 11:13:28 +08:00
kerms 5b9d6f878f fix(unescape) str.replace is not suitable to store single byte above \x90 -> use Uint8Array 2024-06-25 16:51:05 +08:00
kerms 3b6c48f1d0 feat(build): move build to .env.local 2024-06-25 16:48:46 +08:00
kerms bc92656e20 reformat(update) and add badge to sidebar/update 2024-06-18 16:25:48 +08:00
kerms 981b0fbfed minor: cleanup code 2024-06-15 16:56:52 +08:00
kerms 949ef16e7f improve(UX) remove useless scroll bar for Chrome 2024-06-15 16:41:20 +08:00
kerms 418a31ce6d fix(UX) auto scroll not working when refresh filteredData 2024-06-15 16:40:38 +08:00
kerms 99b84b416d fix(UX) when refresh filteredBuf, html nodes not virtualized -> freeze 2024-06-15 16:39:11 +08:00
kerms 94ae6e44ef improve(UX) make \r\n default suffix default suffix and ignore \r instead of new line 2024-06-15 16:35:52 +08:00
kerms 8d52ff6690 fix(UI) main view h-screen has no effect on windows10 2024-06-15 16:31:55 +08:00
kerms 4f50883e8c feat(tcp-server) display connected tcp client list 2024-06-15 16:30:37 +08:00
kerms 6a64f861ba minor(logs) make logs to show in dev mode only 2024-06-08 20:17:42 +08:00
kerms 021627caf8 fix(uart auto scroll): auto scroll was unchecked on quick data, and fix height of RxFrameRemain window that oscillate on quick data rate 2024-06-08 20:14:12 +08:00
kerms 99fada4c85 feat(uart quick send): add draggable and editable send buttons list 2024-06-08 14:38:19 +08:00
kerms be07db9414 UX(uart frame break): break strategy not draggable from priority index 2024-06-08 14:10:42 +08:00
kerms 11b53e7531 feat(ota): add ota page and api, add direct link update 2024-06-06 11:54:03 +08:00
kerms fb833e6af7 feat(uart): change hardcoded uart num to use the first instances num as uart num 2024-05-25 14:42:57 +08:00
kerms f0f11c0646 fix(data-viewer): correct text line break in presence of <span> 2024-05-25 09:38:54 +08:00
kerms a2b7026f54 feat(uart): data frame break 2024-05-24 16:17:41 +08:00
kerms f72d117d90 update(README): add build steps 2024-05-22 20:14:23 +08:00
kerms 3fbb21aa1d add MIT licence 2024-05-22 19:55:00 +08:00
kerms a7758ac69a feat(stop uart communication) 2024-05-22 14:59:40 +08:00
kerms 51783612bc feat(smart scroll, ANSI clear, ANSI refresh) 2024-05-21 15:21:37 +08:00
kerms 15c1143b25 feat(uart proxy)
- use vuetify virtual scroll to handle large message

known issues:
- very slow addItem when frequent add 1 item, become short time normal after a batch item add
 -> batch add every 20ms
2024-05-20 10:40:22 +08:00
kerms c2b8f6ba09 feat(fullscreen, UI), reformat code
- add ws binary message communication (not stable)
- reformat ws: move ws text/bin packing code to msgRouter
- reformat api: centralize module ID
2024-05-20 10:03:25 +08:00
71 changed files with 7425 additions and 1007 deletions

2
.eslintignore Normal file
View File

@ -0,0 +1,2 @@
node_modules
dist

1
.gitattributes vendored Normal file
View File

@ -0,0 +1 @@
*.sh eol=lf

21
LICENCE.txt Normal file
View File

@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2024 kerms
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

85
README.cn.md Normal file
View File

@ -0,0 +1,85 @@
# wireless-esp32-tools 网页用户界面
本项目是 [wireless-esp32-tools](https://github.com/kerms/wireless-esp32-tools) 项目的网页用户界面。
`wireless-esp32-tools` 项目为多种ESP芯片提供了兼容CMSIS-DAP的无线调试工具可将ESP芯片变为一个功能强大的无线调试器。
## 关于此网页界面
此Web应用程序提供了一个用户友好的仪表板用于管理您的 `wireless-esp32-tools` 设备并与之交互。它采用 Vue.js 3、Vite 和 Element Plus 构建,并设计为直接托管在 ESP32 上。
通过此界面,您可以构建一个自定义的仪表板来监控目标设备并与之交互。
### 主要功能
* **Wi-Fi配置**:包括终端模式(STA)和热点模式(AP),支持静态/动态IP地址分配和DNS设置。
* **动态组件仪表板**:一个完全灵活的基于网格的面板。
* **实时数据可视化**包括一个用于监控目标设备串行通信的UART数据查看器。
* **嵌入式优先设计**:部署为单个.html文件以减少http连接数。
* **适用于小屏幕设计**响应式布局适配移动设备和平板电脑具有触摸友好的控件和针对小屏幕优化的UI元素。
## 界面截图
**组件面板**
![组件面板](assets/widgetPannel.png)
**UART数据显示**
![UART数据显示](assets/uart.png)
## 使用说明
### 最终用户
1. 将您的计算机或移动设备连接到运行 `wireless-esp32-tools` 的ESP32所承载的Wi-Fi网络。
2. 打开Web浏览器并导航到ESP32设备的IP地址例如 `http://dap.local``http://192.168.1.1`(连接AP的情况下))。
### 开发人员 (针对此Web UI)
请按照以下步骤设置项目以进行本地开发或构建以进行部署。
#### 环境准备
* [Node.js](https://nodejs.org/) (v16 或更高版本)
* [npm](https://www.npmjs.com/)
#### 本地开发
1. **安装依赖:**
```sh
npm install
```
2. **运行开发服务器:**
```sh
npm run dev
```
这将在本地启动一个服务器,通常地址为 `http://localhost:5173`
3. **为移动/远程调试运行:**
要从同一网络上的其他设备(如手机)访问开发服务器,请使用:
```sh
npm run devh
```
这将把服务器暴露给您的本地网络(例如 `http://192.168.X.X:5173`)。
#### 构建生产版本 (部署到ESP32)
1. **构建项目:**
```sh
npm run build
```
此命令将编译和打包应用程序到 `dist/` 目录,生成 `index.html``ws.sharedworker.js`
2. **压缩输出文件:**
进入 `dist/` 目录并使用gzip压缩构建产物。
```sh
cd dist
gzip *
```
这将生成 `index.html.gz``ws.sharedworker.js.gz`
3. **部署到ESP32**
将压缩后的 `.gz` 文件复制到您的 `wireless-esp32-tools` ESP-IDF项目中的相应目录例如 `project_components/html/`),然后将固件刷入您的设备。
## 许可证
根据MIT许可证分发。更多信息请参见 `LICENCE.txt` 文件。

View File

@ -1,3 +1,87 @@
# 允斯无线透传器的内嵌网页版上位机
# Web UI for wireless-esp32-tools
This is the web user interface for the [wireless-esp32-tools](https://github.com/kerms/wireless-esp32-tools) project.
The `wireless-esp32-tools` project provides CMSIS-DAP compatible wireless debugging tools for various ESP chips, turning them into powerful wireless debug probes.
## About This Web Interface
This web application provides a user-friendly dashboard to manage and interact with your `wireless-esp32-tools` device. It is built with Vue.js 3, Vite, and Element Plus, and is designed to be hosted directly on the ESP32.
From this interface, you can build a customized dashboard to monitor and interact with your target device.
### Key Features
* **Wi-Fi Configuration**: Easily configure Wi-Fi settings including station mode (STA) and access point mode (AP), with support for static/dynamic IP addressing and DNS settings.
* **Dynamic Widget Dashboard**: A fully flexible grid-based panel.
* **Real-time Data Visualization**: Includes a UART data viewer for monitoring serial communication from your target device.
* **Embedded-First Design**: Deploy in single .html file to reduce the number of http connection.
* **Designed to be usable on small screen**: Responsive layout that adapts to mobile devices and tablets, with touch-friendly controls and optimized UI elements for small displays.
## Screenshots
**Widget Panel**
![Widget Panel](assets/widgetPannel.png)
**UART Data Viewer**
![UART Data Viewer](assets/uart.png)
## Getting Started
### For End-Users
1. Connect your computer or mobile device to the Wi-Fi network hosted by the ESP32 running `wireless-esp32-tools`.
2. Open a web browser and navigate to the IP address of the ESP32 device (e.g., `http://dap.local` or `http://192.168.1.1`(when connected to the AP)).
### For Developers (of this Web UI)
Follow these steps to set up the project for local development or to build it for deployment.
#### Prerequisites
* [Node.js](https://nodejs.org/) (v16 or later)
* [npm](https://www.npmjs.com/)
#### Local Development
1. **Install dependencies:**
```sh
npm install
```
2. **Run the development server:**
```sh
npm run dev
```
This will start a local server, typically at `http://localhost:5173`.
3. **Run for mobile/remote debugging:**
To access the development server from other devices on the same network (like a mobile phone), use:
```sh
npm run devh
```
This will expose the server to your local network (e.g., `http://192.168.X.X:5173`).
#### Building for Production (Deploying to ESP32)
1. **Build the project:**
```sh
npm run build
```
This command compiles and bundles the application into the `dist/` directory, creating `index.html` and `ws.sharedworker.js`.
There is some bugs on Firefox or Vue3 when embbeding sharedWorker in single file mode. So at the time, the sharedWorker are build separately.
2. **Compress the output files:**
Navigate into the `dist/` directory and compress the build artifacts using gzip.
```sh
cd dist
gzip *
```
This will generate `index.html.gz` and `ws.sharedworker.js.gz`.
3. **Deploy to ESP32:**
Copy the compressed `.gz` files to the appropriate directory in your `wireless-esp32-tools` ESP-IDF project (e.g., `project_components/html/`) and flash the firmware to your device.
## License
Distributed under the MIT License. See `LICENCE.txt` for more information.

BIN
assets/uart.png Normal file

Binary file not shown.

After

(image error) Size: 103 KiB

BIN
assets/widgetPannel.png Normal file

Binary file not shown.

After

(image error) Size: 38 KiB

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8">
<link id="favicon" rel="icon" href="data:,">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title>
<title>yunsi.studio project</title>
</head>
<body>
<div id="app"></div>

1460
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -4,24 +4,29 @@
"private": true,
"type": "module",
"scripts": {
"dev": ". ./set_env.sh && vite",
"devh": ". ./set_env.sh && vite --host",
"build": "run-p type-check \"build-only {@}\" --",
"preview": ". ./set_env.sh && vite preview",
"build-only": ". ./set_env.sh && vite build",
"build:dev": ". ./set_env.sh && vite build --mode development",
"dev": "node scripts/gen-env.mjs",
"devh": "node scripts/gen-env.mjs --host",
"build": "run-p type-check build-only --",
"preview": "node scripts/gen-env.mjs preview",
"previewh": "node scripts/gen-env.mjs preview --host",
"build-only": "node scripts/gen-env.mjs build",
"build:dev": "NODE_ENV=development node scripts/gen-env.mjs build",
"type-check": "vue-tsc --build --force",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
"lint": "eslint . --ext .vue,.js,.jsx,.ts,.tsx --fix --ignore-path .gitignore",
"format": "prettier --write src/"
},
"dependencies": {
"@vueuse/core": "^10.9.0",
"element-plus": "^2.7.3",
"ansi_up": "^6.0.2",
"element-plus": "^2.8.1",
"mitt": "^3.0.1",
"pinia": "^2.1.7",
"vue": "^3.4.21",
"vue": "^3.5.16",
"vue-draggable-plus": "^0.4.1",
"vue-grid-layout-v3": "^3.1.2",
"vue-i18n": "^9.10.2",
"vue-router": "^4.3.0"
"vue-router": "^4.3.0",
"vuetify": "^3.6.5"
},
"devDependencies": {
"@rushstack/eslint-patch": "^1.3.3",
@ -42,10 +47,11 @@
"typescript": "~5.4.0",
"unplugin-auto-import": "^0.17.5",
"unplugin-vue-components": "^0.26.0",
"vite": "^5.1.6",
"vite": "^5.3.3",
"vite-plugin-css-injected-by-js": "^3.5.0",
"vite-plugin-html": "^3.2.2",
"vite-plugin-singlefile": "^2.0.1",
"vite-plugin-singlefile": "^2.0.2",
"vite-plugin-vuetify": "^2.0.3",
"vite-svg-loader": "^5.1.0",
"vue-tsc": "^2.0.6"
}

25
scripts/gen-env.mjs Normal file
View File

@ -0,0 +1,25 @@
import { execSync, spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
const gitTag = execSync('git describe --tags')
.toString().trim().split('-').slice(0, 2).join('-');
const lastCommit = execSync('git log -1 --format=%cd')
.toString().trim();
// run whatever came after “npm run dev …”
const argsFromNpm = process.argv.slice(2); // e.g. ["--host"]
spawn(
process.platform === 'win32' ? 'npx.cmd' : 'npx',
['vite', ...argsFromNpm],
{
stdio: 'inherit',
env: {
...process.env,
VITE_APP_GIT_TAG: gitTag,
VITE_APP_LAST_COMMIT: lastCommit
}
}
).on('exit', code => process.exit(code));

View File

@ -1,3 +1,7 @@
#!/bin/bash
export VITE_APP_GIT_TAG=$(git describe --tags | cut -d'-' -f1,2)
export VITE_APP_LAST_COMMIT=$(git log -1 --format=%cd)
#!/usr/bin/env bash
VITE_APP_GIT_TAG=$(git describe --tags | cut -d'-' -f1,2)
VITE_APP_LAST_COMMIT=$(git log -1 --format=%cd)
export VITE_APP_GIT_TAG
export VITE_APP_LAST_COMMIT

View File

@ -1,64 +1,94 @@
<script setup lang="ts">
import {useWsStore} from "@/stores/websocket";
import type {IWebsocketService} from "@/composables/websocket/websocketService";
import {getWebsocketService} from "@/composables/websocket/websocketService";
import {onMounted, onUnmounted} from "vue";
import {changeFavicon} from "@/composables/importFavicon";
import {logHelloMessage} from "@/composables/logConsoleMsg";
import NavBar from "@/views/navigation/NavBar.vue";
import type {ControlMsg, ServerMsg} from "@/api";
import {ControlEvent, ControlMsgType} from "@/api";
import {routeCtrlMsg, routeModuleServerMsg} from "@/router/msgRouter";
import {globalNotify} from "@/composables/notification";
import {isDevMode} from "@/composables/buildMode";
import { useWsStore } from '@/stores/websocket'
import type { IWebsocketService } from '@/composables/websocket/websocketService'
import { getWebsocketService } from '@/composables/websocket/websocketService'
import { onMounted, onUnmounted } from 'vue'
import { changeFavicon } from '@/composables/importFavicon'
import { logHelloMessage } from '@/composables/logConsoleMsg'
import NavBar from '@/views/navigation/NavBar.vue'
import type { ControlMsg, ServerMsg } from '@/api'
import { ControlEvent, ControlMsgType } from '@/api'
import { routeCtrlMsg, routeModuleServerMsg } from '@/router/msgRouter'
import { globalNotify } from '@/composables/notification'
import {
getTrialDate,
getTrialMsg,
isDevMode,
isOTAEnabled,
isTrialMode
} from '@/composables/buildMode'
import { useSystemModule } from '@/composables/useSystemModule'
import { useDataFlowModule } from '@/composables/useDataFlowModule'
import { useUpdateModule } from '@/composables/useUpdateModule'
import { ElMessageBox } from 'element-plus'
import { translate } from '@/locales'
const wsState = useWsStore();
const wsState = useWsStore()
const onClientCtrl = (msg: ControlMsg) => {
if (isDevMode()) {
console.log("App.vue:", msg);
console.log('App.vue:', msg)
}
if (msg.type === ControlMsgType.WS_EVENT) {
wsState.$patch({state: msg.data as ControlEvent})
routeCtrlMsg(msg);
wsState.$patch({ state: msg.data as ControlEvent })
routeCtrlMsg(msg)
if (msg.data === ControlEvent.CONNECTED) {
globalNotify("调试器已连接", "success");
globalNotify(translate('common.debuggerConnected'), 'success')
}
}
};
}
const onServerMsg = (msg: ServerMsg) => {
if (isDevMode()) {
console.log("App.vue:", msg);
console.log('App.vue:', msg)
}
routeModuleServerMsg(msg);
};
routeModuleServerMsg(msg)
}
let websocketService: IWebsocketService;
let websocketService: IWebsocketService
onMounted(() => {
logHelloMessage();
let host = "";
logHelloMessage()
let host: string
if (isDevMode()) {
host = import.meta.env.VITE_DEVICE_HOST_NAME || "dap.local";
host = import.meta.env.VITE_DEVICE_HOST_NAME || 'dap.local'
} else {
host = window.location.host
}
websocketService = getWebsocketService();
websocketService.init(host, onServerMsg, onClientCtrl);
changeFavicon();
});
if (import.meta.env.VITE_DISABLE_CONNECTION !== 'true') {
websocketService = getWebsocketService()
websocketService.init(host, onServerMsg, onClientCtrl)
websocketService.getSocketStatus()
}
changeFavicon()
onUnmounted(() => {
useSystemModule()
useDataFlowModule()
});
if (isOTAEnabled()) {
useUpdateModule()
}
if (isTrialMode()) {
ElMessageBox.alert(getTrialMsg(), getTrialDate(), {
confirmButtonText: translate('common.ok')
})
}
})
onUnmounted(() => {})
</script>
<template>
<div class="flex flex-col h-screen">
<div class="flex flex-col wt-h-100">
<header>
<nav-bar/>
<nav-bar />
</header>
<RouterView/>
<RouterView />
</div>
</template>
<style>
.wt-h-100 {
height: 100vh;
}
</style>

104
src/api/apiDataFlow.ts Normal file
View File

@ -0,0 +1,104 @@
import {type ApiJsonMsg} from '@/api'
import * as api from "@/api/index";
export enum WtDataFlowType {
NONE = 0,
SOCKET = 0x10,
WS_SERVER = 0x11,
WS_CLIENT,
WSS_SERVER,
WSS_CLIENT,
TCP_SERVER,
TCP_CLIENT,
TCP_TLS_SERVER,
TCP_TLS_CLIENT,
UDP_SERVER,
UDP_CLIENT,
PERIPHERAL = 0x80,
GPIO = 0x81,
UART = 0x82,
I2C,
I3C,
SPI,
I2S,
CAN,
RMT,
USB,
}
export enum WtDataFlowCmd {
UNKNOWN = 0,
GET_INS_LIST = 1,
GET_CUR_INS = 2,
GET_CUR_ATTACH_LIST = 3,
GET_ATTACH_LIST = 4,
ATTACH = 5,
ATTACH_CUR_TO_RECVER = 6,
ATTACH_CUR_TO_SENDER = 7,
DETACH_SINGLE = 8,
DETACH_CUR_FROM = 9,
SET_DATA_TYPE = 10,
}
export interface IWtDataFlowJsonMsg extends ApiJsonMsg {
data_type?: 3 | 4,
ins_idx?: number,
}
export interface IPeriphInfo {
periph_num: number;
}
export interface ISocketInfo {
foreign_port: number;
foreign_ip: string;
local_port: number;
}
export interface InstanceInfo {
ins_idx: number,
mod_idx: number,
mod_type: number,
port_info: ISocketInfo | IPeriphInfo;
}
export interface IInstanceList extends ApiJsonMsg {
instances: InstanceInfo[],
}
export interface AttachInfo {
attach_idx: number,
s_ins_idx: number,
r_ins_idx: number,
data_type: 3 | 4,
}
export interface IAttachList extends ApiJsonMsg {
attaches: AttachInfo[],
}
export function wt_data_flow_get_instance_list() {
const jsonMsg: IWtDataFlowJsonMsg = {
cmd: WtDataFlowCmd.GET_INS_LIST,
module: api.WtModuleID.DATA_FLOW,
}
api.sendJsonMsg(jsonMsg);
}
export function wt_data_flow_attach_cur_to_sender(instance_index: number) {
const jsonMsg: IWtDataFlowJsonMsg = {
cmd: WtDataFlowCmd.ATTACH_CUR_TO_SENDER,
module: api.WtModuleID.DATA_FLOW,
data_type: 3,
ins_idx: instance_index,
}
api.sendJsonMsg(jsonMsg);
}
export function wt_data_flow_get_attach_list() {
const jsonMsg: IWtDataFlowJsonMsg = {
cmd: WtDataFlowCmd.GET_ATTACH_LIST,
module: api.WtModuleID.DATA_FLOW,
}
api.sendJsonMsg(jsonMsg);
}

61
src/api/apiOTA.ts Normal file
View File

@ -0,0 +1,61 @@
import {type ApiJsonMsg, sendJsonMsg, WtModuleID} from '@/api'
export enum WtOTACmd {
WT_OTA_GET_UPDATE_INFO = 1, /* total_size, ver */
WT_OTA_DO_UPDATE = 2, /* returns OK, chunk of remaining bytes and total length -> wt_event_manager */
WT_OTA_GET_PROGRESS = 3, /* returns chunk of remaining bytes and total length */
WT_OTA_DO_URL_UPDATE = 4, /* force update { url: "https://" } */
}
export enum WtOTAProgressStatus {
OK = "OK",
IDLE = "IDLE",
IN_PROGRESS = "IN_PROGRESS",
FAILED = "FAILED",
}
export interface IOTAProgress extends ApiJsonMsg {
progress: number;
total_size: number;
status: string;
}
export interface IOTAFmInfo extends ApiJsonMsg {
fm_size: number;
fm_ver: string;
upd_date: string;
upd_note: string;
}
export function wt_ota_get_update_info() {
const msg: ApiJsonMsg = {
module: WtModuleID.OTA,
cmd: WtOTACmd.WT_OTA_GET_UPDATE_INFO,
};
sendJsonMsg(msg);
}
export function wt_ota_do_update() {
const msg: ApiJsonMsg = {
module: WtModuleID.OTA,
cmd: WtOTACmd.WT_OTA_DO_UPDATE,
};
sendJsonMsg(msg);
}
export function wt_ota_get_progress() {
const msg: ApiJsonMsg = {
module: WtModuleID.OTA,
cmd: WtOTACmd.WT_OTA_GET_PROGRESS,
};
sendJsonMsg(msg);
}
export function wt_ota_do_url_update(url: string) {
const msg: ApiJsonMsg & {url: string} = {
module: WtModuleID.OTA,
cmd: WtOTACmd.WT_OTA_DO_URL_UPDATE,
url: url,
};
sendJsonMsg(msg);
}

45
src/api/apiSystem.ts Normal file
View File

@ -0,0 +1,45 @@
import {type ApiJsonMsg, sendJsonMsg, WtModuleID} from '@/api'
export enum WtSytemCmd {
WT_SYS_GET_FM_INFO = 1,
WT_SYS_REBOOT = 2,
WT_SYS_GET_SYS_INFO = 3,
}
export interface ISysFmInfo extends ApiJsonMsg {
fm_ver: string;
upd_date: string;
}
export interface ISysHwInfo extends ApiJsonMsg {
hw_ver: string;
mf_date: string;
}
export interface ISysInfo {
sn: string;
}
export function wt_sys_get_fm_info() {
const msg: ApiJsonMsg = {
module: WtModuleID.SYSTEM,
cmd: WtSytemCmd.WT_SYS_GET_FM_INFO,
};
sendJsonMsg(msg);
}
export function wt_sys_reboot() {
const msg: ApiJsonMsg = {
module: WtModuleID.SYSTEM,
cmd: WtSytemCmd.WT_SYS_REBOOT,
};
sendJsonMsg(msg);
}
export function wt_sys_get_sys_info() {
const msg: ApiJsonMsg = {
module: WtModuleID.SYSTEM,
cmd: WtSytemCmd.WT_SYS_GET_SYS_INFO,
};
sendJsonMsg(msg);
}

110
src/api/apiUart.ts Normal file
View File

@ -0,0 +1,110 @@
import type {ApiBinaryMsg} from "@/api/binDataDef";
import {WtDataType} from "@/api/binDataDef";
import {type ApiJsonMsg, sendBinMsg, sendJsonMsg, WtModuleID} from "@/api/index";
export enum WtUartCmd {
UNKNOWN = 0,
/* UART PERIPHERAL */
GET_AVAILABLE_NUMS = 1,
GET_BAUD = 4,
SET_BAUD = 5,
GET_CONFIG = 6, /* data bits, parity and stop bits */
SET_CONFIG = 7,
GET_FLOW_CTRL, /* flow control function RTS/CTS*/
SET_FLOW_CTRL,
GET_PINS_NUM, /* not implemented change pinout function */
SET_PINS_NUM, /* not implemented */
GET_MODE, /* not implemented UART/RS485/IrDA */
SET_MODE, /* not implemented UART/RS485/IrDA */
GET_STATUS = 20, /* is uart enabled and other information */
SET_STATUS, /* set specific uart port disable */
GET_DATA_TYPE = 22, // 0x03 or 0x04
SET_DATA_TYPE = 23, // 0x03 or 0x04
GET_DEFAULT_NUM = 24,
}
enum ANSI_ESCAPE_CODE {
REFRESH_WINDOW = '\x1b[7t',
CLEAR_WINDOW = '\x1b[2J'
}
export interface IUartConfig {
data_bits: 5 | 6 | 7 | 8;
parity : 0 | 1 | 2;
stop_bits: 1 | 15 | 2;
}
export interface IUartMsgConfig extends ApiJsonMsg, IUartConfig {
sub_mod: number;
}
export interface IUartMsgBaud extends ApiJsonMsg {
sub_mod: number;
baud: number;
}
export interface IUartMsgNum extends ApiJsonMsg {
num: number;
}
export function uart_send_msg(payload: Uint8Array, sub_mod: number) {
/* hard code uart num for now */
const msg: ApiBinaryMsg = {
sub_mod: sub_mod,
data_type: WtDataType.RAW,
module: WtModuleID.UART,
payload: payload,
}
sendBinMsg(msg);
}
export function uart_get_baud(uart_num: number) {
const cmd = {
cmd: WtUartCmd.GET_BAUD,
module: WtModuleID.UART,
sub_mod: uart_num,
}
sendJsonMsg(cmd);
}
export function uart_set_baud(baud: number, uart_num: number) {
const cmd: IUartMsgBaud = {
cmd: WtUartCmd.SET_BAUD,
module: WtModuleID.UART,
baud: baud,
sub_mod: uart_num,
}
sendJsonMsg(cmd);
}
export function uart_get_config(uart_num: number) {
const cmd = {
cmd: WtUartCmd.GET_CONFIG,
module: WtModuleID.UART,
sub_mod: uart_num,
}
sendJsonMsg(cmd);
}
export function uart_set_config(uart_config: IUartConfig, uart_num: number) {
const cmd: IUartMsgConfig = {
cmd: WtUartCmd.SET_CONFIG,
module: WtModuleID.UART,
sub_mod: uart_num,
data_bits: uart_config.data_bits,
parity: uart_config.parity,
stop_bits: uart_config.stop_bits,
}
sendJsonMsg(cmd);
}
export function uart_get_default_num() {
const cmd = {
cmd: WtUartCmd.GET_DEFAULT_NUM,
module: WtModuleID.UART,
}
sendJsonMsg(cmd);
}

View File

@ -2,20 +2,49 @@ import {type ApiJsonMsg, sendJsonMsg, WtModuleID} from '@/api'
export enum WifiCmd {
UNKNOWN = 0,
WIFI_API_JSON_STA_GET_AP_INFO,
WIFI_API_JSON_CONNECT,
WIFI_API_JSON_GET_SCAN,
WIFI_API_JSON_DISCONNECT,
WIFI_API_JSON_AP_GET_INFO,
WIFI_API_JSON_STA_GET_AP_INFO = 1,
WIFI_API_JSON_CONNECT = 2,
WIFI_API_JSON_GET_SCAN = 3,
WIFI_API_JSON_DISCONNECT = 4,
WIFI_API_JSON_AP_GET_INFO = 5,
WIFI_API_JSON_GET_MODE = 6,
WIFI_API_JSON_SET_MODE = 7,
WIFI_API_JSON_AP_SET_CRED = 8,
WIFI_API_JSON_STA_GET_STATIC_INFO = 9,
WIFI_API_JSON_STA_SET_STATIC_CONF = 10,
}
interface WifiMsgOut extends ApiJsonMsg {
export enum WifiMode {
/* permanent */
WIFI_AP_AUTO_STA_ON = 0,
WIFI_AP_STA_OFF = 4, /* 100 */
WIFI_AP_OFF_STA_ON = 5, /* 101 */
WIFI_AP_ON_STA_OFF = 6, /* 110 */
WIFI_AP_STA_ON = 7, /* 111 */
/* temporary */
WIFI_AP_STOP = 8,
WIFI_AP_START = 9,
WIFI_STA_STOP = 10,
WIFI_STA_START = 11,
}
export enum WifiStatus {
WIFI_MODE_NULL = 0, /**< null mode */
WIFI_MODE_STA, /**< WiFi station mode */
WIFI_MODE_AP, /**< WiFi soft-AP mode */
WIFI_MODE_APSTA, /**< WiFi station + soft-AP mode */
}
export interface WiFiCredential extends ApiJsonMsg {
ssid?: string;
password?: string;
err?: string;
}
export function wifi_get_scan_list() {
const msg : WifiMsgOut = {
const msg : WiFiCredential = {
module: WtModuleID.WIFI,
cmd: WifiCmd.WIFI_API_JSON_GET_SCAN,
}
@ -23,7 +52,7 @@ export function wifi_get_scan_list() {
}
export function wifi_sta_get_ap_info() {
const msg : WifiMsgOut = {
const msg : WiFiCredential = {
module: WtModuleID.WIFI,
cmd: WifiCmd.WIFI_API_JSON_STA_GET_AP_INFO,
}
@ -31,7 +60,7 @@ export function wifi_sta_get_ap_info() {
}
export function wifi_ap_get_info() {
const msg : WifiMsgOut = {
const msg : WiFiCredential = {
module: WtModuleID.WIFI,
cmd: WifiCmd.WIFI_API_JSON_AP_GET_INFO,
}
@ -39,7 +68,7 @@ export function wifi_ap_get_info() {
}
export function wifi_connect_to(ssid: string, password: string) {
const msg: WifiMsgOut = {
const msg: WiFiCredential = {
module: WtModuleID.WIFI,
cmd: WifiCmd.WIFI_API_JSON_CONNECT,
ssid: ssid,
@ -49,6 +78,19 @@ export function wifi_connect_to(ssid: string, password: string) {
}
export interface WifiInfo extends ApiJsonMsg {
rssi: number;
ssid: string;
password: string;
gateway: string;
ip: string;
mac: string;
netmask: string;
dns_main: string;
dns_backup: string;
wifiLogo?: string;
}
export interface WifiScanInfo extends ApiJsonMsg {
rssi: number;
ssid: string;
gateway: string;
@ -59,5 +101,86 @@ export interface WifiInfo extends ApiJsonMsg {
}
export interface WifiList extends ApiJsonMsg {
scan_list: Array<WifiInfo>;
scan_list: Array<WifiScanInfo>;
}
export interface IWifiMode extends ApiJsonMsg {
mode?: WifiMode;
status?: WifiStatus;
ap_on_delay?: number;
ap_off_delay?: number;
err?: string;
}
export function wifi_set_mode(req_mode: WifiMode, ap_on_delay = -1, ap_off_delay = -1) {
let msg: IWifiMode;
if (req_mode === WifiMode.WIFI_AP_AUTO_STA_ON && ap_on_delay !== -1 && ap_off_delay !== -1) {
msg = {
module: WtModuleID.WIFI,
cmd: WifiCmd.WIFI_API_JSON_SET_MODE,
mode: req_mode,
ap_on_delay: ap_on_delay,
ap_off_delay: ap_off_delay,
};
} else {
msg = {
module: WtModuleID.WIFI,
cmd: WifiCmd.WIFI_API_JSON_SET_MODE,
mode: req_mode,
};
}
sendJsonMsg(msg);
}
export function wifi_get_mode() {
const msg: IWifiMode = {
module: WtModuleID.WIFI,
cmd: WifiCmd.WIFI_API_JSON_GET_MODE,
};
sendJsonMsg(msg);
}
export function wifi_ap_set_credential(ssid: string, password: string) {
const msg : WiFiCredential = {
module: WtModuleID.WIFI,
cmd: WifiCmd.WIFI_API_JSON_AP_SET_CRED,
ssid: ssid,
password: password,
}
sendJsonMsg(msg);
}
export interface IWifiStaStaticInfo {
static_ip_en: number;
static_dns_en: number;
ip: string;
gateway: string;
netmask: string;
dns_main: string;
dns_backup: string;
}
export function wifi_sta_get_static_info() {
const msg: ApiJsonMsg = {
module: WtModuleID.WIFI,
cmd: WifiCmd.WIFI_API_JSON_STA_GET_STATIC_INFO,
}
sendJsonMsg(msg);
}
export function wifi_sta_set_static_conf(static_info: IWifiStaStaticInfo) {
const msg: IWifiStaStaticInfo & ApiJsonMsg = {
module: WtModuleID.WIFI,
cmd: WifiCmd.WIFI_API_JSON_STA_SET_STATIC_CONF,
static_dns_en: static_info.static_dns_en,
static_ip_en: static_info.static_ip_en,
ip: static_info.ip,
gateway: static_info.gateway,
netmask: static_info.netmask,
dns_main: static_info.dns_main,
dns_backup: static_info.dns_backup,
}
sendJsonMsg(msg);
}

View File

@ -31,9 +31,11 @@ export interface ServerMsg {
}
export enum WtModuleID {
SYSTEM = 0,
WIFI = 1,
DATA_FLOW = 2,
UART = 4,
OTA = 5,
}
export function sendJsonMsg(apiJsonMsg: ApiJsonMsg) {

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" fill="#5f6368"><path d="M480-360 280-560h400L480-360Z"/></svg>

After

(image error) Size: 127 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" fill="#5f6368"><path d="m280-400 200-200 200 200H280Z"/></svg>

After

(image error) Size: 127 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#1f1f1f"><path d="m256-200-56-56 224-224-224-224 56-56 224 224 224-224 56 56-224 224 224 224-56 56-224-224-224 224Z"/></svg>

After

(image error) Size: 222 B

1
src/assets/icon/help.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" fill="#5f6368"><path d="M478-240q21 0 35.5-14.5T528-290q0-21-14.5-35.5T478-340q-21 0-35.5 14.5T428-290q0 21 14.5 35.5T478-240Zm-36-154h74q0-33 7.5-52t42.5-52q26-26 41-49.5t15-56.5q0-56-41-86t-97-30q-57 0-92.5 30T342-618l66 26q5-18 22.5-39t53.5-21q32 0 48 17.5t16 38.5q0 20-12 37.5T506-526q-44 39-54 59t-10 73Zm38 314q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Z"/></svg>

After

(image error) Size: 668 B

1
src/assets/icon/lock.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#1f1f1f"><path d="M240-80q-33 0-56.5-23.5T160-160v-400q0-33 23.5-56.5T240-640h40v-80q0-83 58.5-141.5T480-920q83 0 141.5 58.5T680-720v80h40q33 0 56.5 23.5T800-560v400q0 33-23.5 56.5T720-80H240Zm0-80h480v-400H240v400Zm240-120q33 0 56.5-23.5T560-360q0-33-23.5-56.5T480-440q-33 0-56.5 23.5T400-360q0 33 23.5 56.5T480-280ZM360-640h240v-80q0-50-35-85t-85-35q-50 0-85 35t-35 85v80ZM240-160v-400 400Z"/></svg>

After

(image error) Size: 499 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#1f1f1f"><path d="M240-160h480v-400H240v400Zm240-120q33 0 56.5-23.5T560-360q0-33-23.5-56.5T480-440q-33 0-56.5 23.5T400-360q0 33 23.5 56.5T480-280ZM240-160v-400 400Zm0 80q-33 0-56.5-23.5T160-160v-400q0-33 23.5-56.5T240-640h280v-80q0-83 58.5-141.5T720-920q83 0 141.5 58.5T920-720h-80q0-50-35-85t-85-35q-50 0-85 35t-35 85v80h120q33 0 56.5 23.5T800-560v400q0 33-23.5 56.5T720-80H240Z"/></svg>

After

(image error) Size: 486 B

1
src/assets/icon/play.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#1f1f1f"><path d="M320-200v-560l440 280-440 280Zm80-280Zm0 134 210-134-210-134v268Z"/></svg>

After

(image error) Size: 190 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#1f1f1f"><path d="M280-80 120-240l160-160 56 58-62 62h406v-160h80v240H274l62 62-56 58Zm-80-440v-240h486l-62-62 56-58 160 160-160 160-56-58 62-62H280v160h-80Z"/></svg>

After

(image error) Size: 264 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#1f1f1f"><path d="M240-320h320v-80H240v80Zm400 0h80v-80h-80v80ZM240-480h80v-80h-80v80Zm160 0h320v-80H400v80ZM160-160q-33 0-56.5-23.5T80-240v-480q0-33 23.5-56.5T160-800h640q33 0 56.5 23.5T880-720v480q0 33-23.5 56.5T800-160H160Zm0-80h640v-480H160v480Zm0 0v-480 480Z"/></svg>

After

(image error) Size: 370 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M0 0h24v24H0z" fill="none"/><path d="M12.87 15.07l-2.54-2.51.03-.03c1.74-1.94 2.98-4.17 3.71-6.53H17V4h-7V2H8v2H1v1.99h11.17C11.5 7.92 10.44 9.75 9 11.35 8.07 10.32 7.3 9.19 6.69 8h-2c.73 1.63 1.73 3.17 2.98 4.56l-5.09 5.02L4 19l5-5 3.11 3.11.76-2.04zM18.5 10h-2L12 22h2l1.12-3h4.75L21 22h2l-4.5-12zm-2.62 7l1.62-4.33L19.12 17h-3.24z"/></svg>

After

(image error) Size: 411 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" fill="#5f6368"><path d="M280-120q-33 0-56.5-23.5T200-200v-520h-40v-80h200v-40h240v40h200v80h-40v520q0 33-23.5 56.5T680-120H280Zm400-600H280v520h400v-520ZM360-280h80v-360h-80v360Zm160 0h80v-360h-80v360ZM280-720v520-520Z"/></svg>

After

(image error) Size: 292 B

View File

@ -1,4 +1,17 @@
export const toServer = new BroadcastChannel("toServer");
export const toClient = new BroadcastChannel("toClient");
export const toWebsocketCtrl = new BroadcastChannel("toWebsocketCtrl");
export const toClientCtrl = new BroadcastChannel("toClientCtrl");
// Define a fallback mock class only if BroadcastChannel is undefined
const BC: typeof BroadcastChannel = typeof BroadcastChannel !== 'undefined'
? BroadcastChannel
: class {
constructor(name: string) {
// no-op
}
postMessage(_: any) {}
close() {}
addEventListener(_: string, __: any) {}
removeEventListener(_: string, __: any) {}
} as unknown as typeof BroadcastChannel;
export const toServer = new BC("toServer");
export const toClient = new BC("toClient");
export const toWebsocketCtrl = new BC("toWebsocketCtrl");
export const toClientCtrl = new BC("toClientCtrl");

View File

@ -1,3 +1,19 @@
export function isDevMode() {
return import.meta.env.VITE_APP_MODE === 'dev';
}
export function isOTAEnabled() {
return import.meta.env.VITE_ENABLE_OTA === 'true' || false;
}
export function isTrialMode() {
return import.meta.env.VITE_TRIAL_MODE === "true" || false;
}
export function getTrialDate() {
return import.meta.env.VITE_TRIAL_DATE || "1970-01-01";
}
export function getTrialMsg() {
return import.meta.env.VITE_TRIAL_MSG || "感谢您试用允斯开放固件,若您喜欢,欢迎关注我的B站或者加入允斯群,新项目和更新都会在第一时间在这里发布. 使用愉快^_^";
}

View File

@ -2,7 +2,7 @@ import {ElMessage, ElNotification} from "element-plus";
type NotificationType = 'error' | 'warning' | 'info' | 'success' ;
export function globalNotify(msg: string, type: NotificationType) {
export function globalNotify(msg: string, type: NotificationType = "info") {
ElMessage({
message: msg,
grouping: true,
@ -13,7 +13,7 @@ export function globalNotify(msg: string, type: NotificationType) {
})
}
export function globalNotifyRightSide(msg: string, type: NotificationType) {
export function globalNotifyRightSide(msg: string, type: NotificationType = "info") {
ElNotification({
message: msg,
type: type,

View File

@ -0,0 +1,176 @@
/**
* @file Composable for managing a task scheduler that executes callbacks at specified intervals.
* This scheduler avoids time drift and handles race conditions from stale timers.
*/
import { ref, onUnmounted } from 'vue'
import { isDevMode } from './buildMode'
interface ScheduledTask {
id: string
intervalMS: number
nextExecutionTime: number
executeCallback: () => Promise<void> | void
oneTime: boolean
version: number
}
// --- Module-level state (Singleton pattern) ---
/** A sorted array of tasks to be executed. The task at index 0 is always the next one. */
const scheduledTasks = ref<ScheduledTask[]>([])
/** Tracks the latest version for a given task ID to prevent stale timers from running. */
const taskVersions = new Map<string, number>()
/** The ID of the currently active `setTimeout` instance. */
let currentTimerId: number | null = null
/** A reactive flag indicating if the scheduler has any pending tasks. */
export const isSchedulerRunning = ref(false)
/** A lock to prevent concurrent task executions */
let isTaskExecuting = false
export function useCommandLoopManager() {
// --- Private Scheduler Core ---
/**
* Executes the task at the front of the queue and reschedules it if it's recurring.
*/
const executeNextTask = async () => {
// If a task is already running, wait before trying to execute the next one.
if (isTaskExecuting) {
if (isDevMode()) console.log('[Scheduler] Delaying task execution: another task is already running.')
// This is a simple back-off strategy.
currentTimerId = window.setTimeout(executeNextTask, 50)
return
}
if (scheduledTasks.value.length === 0) {
isSchedulerRunning.value = false
return
}
const taskToExecute = scheduledTasks.value.shift()!
// Stale check
const latestVersion = taskVersions.get(taskToExecute.id)
if (taskToExecute.version !== latestVersion) {
if (isDevMode()) console.log(`[Scheduler] Discarding stale task '${taskToExecute.id}' v${taskToExecute.version}.`)
scheduleNextExecution() // The queue has changed, so recalculate.
return
}
// Acquire the lock and execute the callback.
try {
isTaskExecuting = true
if (isDevMode()) console.log(`[Scheduler] Executing task '${taskToExecute.id}' v${taskToExecute.version}`)
await taskToExecute.executeCallback()
} catch (error) {
console.error(`[Scheduler] Error in task '${taskToExecute.id}':`, error)
} finally {
isTaskExecuting = false // ALWAYS release the lock
}
// If it's a recurring task, reschedule it.
if (!taskToExecute.oneTime) {
taskToExecute.nextExecutionTime = Date.now() + taskToExecute.intervalMS
// Re-insert the task and re-sort the queue.
scheduledTasks.value.push(taskToExecute)
scheduledTasks.value.sort((a, b) => a.nextExecutionTime - b.nextExecutionTime)
} else {
taskVersions.delete(taskToExecute.id)
}
// Set the timer for the next task in the queue.
scheduleNextExecution()
}
/**
* Sets a single master timer for the next task in the queue.
*/
const scheduleNextExecution = () => {
if (currentTimerId !== null) {
window.clearTimeout(currentTimerId)
currentTimerId = null
}
if (scheduledTasks.value.length === 0) {
isSchedulerRunning.value = false
return
}
const nextTask = scheduledTasks.value[0]
const timeout = Math.max(0, nextTask.nextExecutionTime - Date.now())
currentTimerId = window.setTimeout(executeNextTask, timeout)
isSchedulerRunning.value = true
if (isDevMode()) {
console.log(`[Scheduler] Next task '${nextTask.id}' v${nextTask.version} scheduled in ${timeout}ms.`)
}
}
// --- Public API ---
/**
* Registers a new task, updates an existing one, or removes a task.
* This is the single entry point for all scheduling changes.
* @param id A unique identifier for the task.
* @param intervalMS The interval in milliseconds. Pass 0 or an invalid value to unregister.
* @param executeCallback The function to execute.
* @param oneTime If true, the task runs once and is not rescheduled.
*/
const registerLoop = (
id: string,
intervalMS: number | string,
executeCallback: () => Promise<void> | void,
oneTime = false,
) => {
scheduledTasks.value = scheduledTasks.value.filter((task) => task.id !== id)
const interval = typeof intervalMS === 'string' ? parseInt(intervalMS, 10) : intervalMS
if (!interval || isNaN(interval) || interval <= 0) {
if (isDevMode()) console.log(`[Scheduler] Unregistered task '${id}'.`)
taskVersions.delete(id)
scheduleNextExecution()
return
}
const newVersion = (taskVersions.get(id) || 0) + 1
taskVersions.set(id, newVersion)
const newTask: ScheduledTask = {
id,
intervalMS: interval,
nextExecutionTime: Date.now() + interval,
executeCallback,
oneTime,
version: newVersion,
}
scheduledTasks.value.push(newTask)
scheduledTasks.value.sort((a, b) => a.nextExecutionTime - b.nextExecutionTime)
if (isDevMode()) {
console.log(`[Scheduler] Registered task '${id}' v${newVersion} with interval ${interval}ms.`)
}
scheduleNextExecution()
}
/**
* A convenience helper to explicitly remove a task from the scheduler.
* @param id The identifier of the task to remove.
*/
const unregisterLoop = (id: string) => {
registerLoop(id, -1, () => {})
}
// --- Lifecycle Hook ---
onUnmounted(() => {
if (currentTimerId !== null) window.clearTimeout(currentTimerId)
scheduledTasks.value = []
taskVersions.clear()
isSchedulerRunning.value = false
})
return { registerLoop, unregisterLoop, isRunning: isSchedulerRunning }
}

View File

@ -0,0 +1,44 @@
import {registerModule} from "@/router/msgRouter";
import {type ApiJsonMsg, type ControlMsg, ControlMsgType, WtModuleID} from "@/api";
import {isDevMode} from "@/composables/buildMode";
import {useDataFlowStore} from "@/stores/useDataFlowStore";
import {type IInstanceList, WtDataFlowCmd} from "@/api/apiDataFlow";
export function useDataFlowModule() {
const dfStore = useDataFlowStore()
function onClientCtrl(msg: ControlMsg) {
if (msg.type !== ControlMsgType.WS_EVENT) {
return
}
}
function onClientMsg(msg: ApiJsonMsg) {
switch (msg.cmd as WtDataFlowCmd) {
case WtDataFlowCmd.GET_INS_LIST: {
const insList = msg as IInstanceList;
dfStore.instanceList = insList.instances;
break;
}
case WtDataFlowCmd.GET_ATTACH_LIST: {
break;
}
default:
break;
}
if (isDevMode()) {
console.log(msg);
}
}
registerModule(WtModuleID.DATA_FLOW, {
ctrlCallback: onClientCtrl,
serverJsonMsgCallback: onClientMsg,
serverBinMsgCallback: () => {},
});
}

View File

@ -0,0 +1,83 @@
import { useDataViewerStore } from '@/stores/dataViewerStore'
import { watch } from 'vue'
import type { IDataBuf } from '@/stores/dataViewerStore'
import { isDevMode } from '@/composables/buildMode'
function decodeUtf8(u8Arr: Uint8Array) {
try {
const decoder = new TextDecoder()
const decodedText = decoder.decode(u8Arr) // Attempt to decode
return decodedText.replace(/\uFFFD/g, '') // Remove all <20> characters
} catch (error) {
return ''
}
}
export function useSequentialUart() {
const dataViewerStore = useDataViewerStore() as any
async function sendCommand(command: string): Promise<string> {
return new Promise((resolve) => {
let responseBuffer = ''
let responseTimeout: number | null = null
const RESPONSE_WAIT_TIME = 10 // Wait 500ms after last response before resolving
// Index to track which messages we've already processed
let lastProcessedIndex = dataViewerStore.dataFiltered.length - 1
const stopWatch = watch(
() => dataViewerStore.dataFiltered,
(newBuf: IDataBuf[]) => {
if (isDevMode()) {
console.log('watch data', newBuf)
}
// Process only new messages
for (let i = lastProcessedIndex + 1; i < newBuf.length; i++) {
const message = newBuf[i]
if (message.isRX) {
// Add to response buffer
responseBuffer += decodeUtf8(message.data)
// Reset timeout to wait for more responses
if (responseTimeout) {
clearTimeout(responseTimeout)
}
responseTimeout = window.setTimeout(() => {
stopWatch()
resolve(responseBuffer)
}, RESPONSE_WAIT_TIME)
}
}
// Update last processed index
lastProcessedIndex = newBuf.length - 1
},
{ deep: true }
)
dataViewerStore.addString(command, false, true)
// Set a maximum timeout in case no response is received
const maxTimeout = window.setTimeout(() => {
stopWatch()
resolve(responseBuffer)
}, 1000) // 5 seconds maximum wait time
})
}
async function sendCommands(commands: string[]): Promise<string[]> {
const responses: string[] = []
for (const command of commands) {
const response = await sendCommand(command)
responses.push(response)
}
if (isDevMode()) {
console.log('sendCommands', responses)
}
return responses
}
return { sendCommands }
}

View File

@ -0,0 +1,54 @@
import {useSystemStore} from "@/stores/useSystemStore";
import {registerModule} from "@/router/msgRouter";
import {type ApiJsonMsg, ControlEvent, type ControlMsg, ControlMsgType, WtModuleID} from "@/api";
import {type ISysFmInfo, type ISysInfo, wt_sys_get_fm_info, wt_sys_get_sys_info, WtSytemCmd} from "@/api/apiSystem";
import {isDevMode} from "@/composables/buildMode";
export function useSystemModule() {
const sysStore = useSystemStore()
function onClientCtrl(msg: ControlMsg) {
if (msg.type !== ControlMsgType.WS_EVENT) {
return
}
if (msg.data === ControlEvent.CONNECTED) {
wt_sys_get_fm_info();
wt_sys_get_sys_info();
sysStore.rebootInProgress = false;
}
}
function onClientMsg(msg: ApiJsonMsg) {
switch (msg.cmd as WtSytemCmd) {
case WtSytemCmd.WT_SYS_REBOOT:
sysStore.rebootInProgress = true;
break;
case WtSytemCmd.WT_SYS_GET_FM_INFO: {
const fm_info = msg as ISysFmInfo;
sysStore.curFmInfo.date = fm_info.upd_date;
sysStore.curFmInfo.ver = fm_info.fm_ver;
break;
}
case WtSytemCmd.WT_SYS_GET_SYS_INFO: {
const sysInfo: ISysInfo = msg as ISysInfo & ApiJsonMsg;
Object.assign(sysStore.sysInfo, sysInfo);
break;
}
}
if (isDevMode()) {
console.log(msg);
}
}
registerModule(WtModuleID.SYSTEM, {
ctrlCallback: onClientCtrl,
serverJsonMsgCallback: onClientMsg,
serverBinMsgCallback: () => {},
});
}

View File

@ -0,0 +1,81 @@
import type { IUartMsgConfig, IUartMsgNum } from '@/api/apiUart'
import type { IUartMsgBaud } from '@/api/apiUart'
import { uart_get_baud, uart_get_config, WtUartCmd } from '@/api/apiUart'
import { useUartStore } from '@/stores/useUartStore'
import { registerModule } from '@/router/msgRouter'
import * as api from '@/api'
import { isDevMode } from './buildMode'
import { useDataViewerStore } from '@/stores/dataViewerStore'
import type { ApiBinaryMsg } from '@/api/binDataDef'
import { ControlEvent } from '@/api'
import * as df from '@/api/apiDataFlow'
import { uart_get_default_num } from '@/api/apiUart'
export function useUartModule() {
const dataViewerStore = useDataViewerStore()
const uartStore = useUartStore()
function updateUartData() {
/* TODO: hard code for the moment, 0 is UART instance id (can be changed in the future) */
uart_get_default_num()
df.wt_data_flow_attach_cur_to_sender(0)
}
const onUartJsonMsg = (msg: api.ApiJsonMsg) => {
switch (msg.cmd as WtUartCmd) {
case WtUartCmd.GET_BAUD:
case WtUartCmd.SET_BAUD: {
const uartMsg = msg as IUartMsgBaud
if (uartMsg.baud) {
dataViewerStore.setUartBaud(uartMsg.baud)
}
break
}
case WtUartCmd.GET_CONFIG:
case WtUartCmd.SET_CONFIG: {
const uartMsg = msg as IUartMsgConfig
dataViewerStore.uartConfig.data_bits = uartMsg.data_bits
dataViewerStore.uartConfig.stop_bits = uartMsg.stop_bits
dataViewerStore.uartConfig.parity = uartMsg.parity
break
}
case WtUartCmd.GET_DEFAULT_NUM:
uartStore.uartNum = (msg as IUartMsgNum).num
uart_get_baud(uartStore.uartNum)
uart_get_config(uartStore.uartNum)
break
default:
if (isDevMode()) {
console.log('uart not treated', msg)
}
break
}
}
const onUartBinaryMsg = (msg: ApiBinaryMsg) => {
if (isDevMode()) {
console.log('uart', msg)
}
dataViewerStore.addSegment(new Uint8Array(msg.payload), true)
}
const onClientCtrl = (msg: api.ControlMsg) => {
if (msg.type !== api.ControlMsgType.WS_EVENT) {
return
}
if (msg.data === ControlEvent.DISCONNECTED) {
dataViewerStore.acceptIncomingData = false
} else if (msg.data === ControlEvent.CONNECTED) {
updateUartData()
dataViewerStore.acceptIncomingData = true
}
}
registerModule(api.WtModuleID.UART, {
ctrlCallback: onClientCtrl,
serverJsonMsgCallback: onUartJsonMsg,
serverBinMsgCallback: onUartBinaryMsg
})
}

View File

@ -0,0 +1,99 @@
import {registerModule} from "@/router/msgRouter";
import {type ApiJsonMsg, ControlEvent, type ControlMsg, ControlMsgType, WtModuleID} from "@/api";
import {useUpdateStore} from "@/stores/useUpdateStore";
import {
type IOTAFmInfo,
type IOTAProgress,
WtOTACmd,
WtOTAProgressStatus,
wt_ota_get_progress,
wt_ota_get_update_info,
} from "@/api/apiOTA";
import {isDevMode} from "@/composables/buildMode";
import {useSystemStore} from "@/stores/useSystemStore";
export function useUpdateModule() {
const updateStore = useUpdateStore()
const sysStore = useSystemStore()
function onClientCtrl(msg: ControlMsg) {
if (msg.type !== ControlMsgType.WS_EVENT) {
return
}
if (msg.data === ControlEvent.CONNECTED) {
wt_ota_get_update_info();
wt_ota_get_progress();
}
}
function onClientMsg(msg: ApiJsonMsg) {
switch (msg.cmd as WtOTACmd) {
case WtOTACmd.WT_OTA_GET_UPDATE_INFO: {
const info = msg as IOTAFmInfo;
Object.assign(updateStore.newFmInfo, info);
if (updateStore.newFmInfo.fm_ver !== sysStore.curFmInfo.ver && updateStore.newFmInfo.fm_ver[0] !== '-'
&& (updateStore.updateStatus === 'IDLE' || updateStore.updateStatus === 'FAILED')) {
updateStore.canUpdate = true;
} else {
updateStore.canUpdate = false;
}
break;
}
case WtOTACmd.WT_OTA_DO_UPDATE:
break;
case WtOTACmd.WT_OTA_GET_PROGRESS: {
const progress = msg as IOTAProgress;
updateStore.updateStatus = progress.status;
if (progress.total_size !== 0) {
updateStore.updateProgress = (progress.progress / progress.total_size) * 100;
} else {
updateStore.updateProgress = 0;
}
if (progress.status === WtOTAProgressStatus.IDLE) {
if (updateStore.newFmInfo.fm_ver !== sysStore.curFmInfo.ver && updateStore.newFmInfo.fm_ver[0] !== '-') {
updateStore.canUpdate = true;
} else {
updateStore.canUpdate = false;
}
updateStore.clearProgressInterval();
updateStore.progressBarStatus = '';
} else if (progress.status === WtOTAProgressStatus.FAILED) {
if (updateStore.newFmInfo.fm_ver !== sysStore.curFmInfo.ver && updateStore.newFmInfo.fm_ver[0] !== '-') {
updateStore.canUpdate = true;
} else {
updateStore.canUpdate = false;
}
updateStore.clearProgressInterval();
updateStore.progressBarStatus = 'exception';
} else if (progress.status === WtOTAProgressStatus.IN_PROGRESS) {
updateStore.setProgressInterval();
updateStore.progressBarStatus = '';
updateStore.canUpdate = false;
} else if (progress.status === WtOTAProgressStatus.OK) {
updateStore.clearProgressInterval();
updateStore.canUpdate = false;
updateStore.progressBarStatus = 'success';
}
break;
}
default:
break;
}
if (isDevMode()) {
console.log(msg);
}
}
registerModule(WtModuleID.OTA, {
ctrlCallback: onClientCtrl,
serverJsonMsgCallback: onClientMsg,
serverBinMsgCallback: () => {
},
});
}

View File

@ -2,7 +2,7 @@ import MyWorker from '@/composables/websocket/ws.sharedworker?sharedworker'
import {WebsocketWrapper} from "@/composables/websocket/websocketWrapper";
import {toClient, toClientCtrl, toServer} from "@/composables/broadcastChannelDef";
import type {ControlMsg, ServerMsg} from "@/api";
import {ControlEvent, ControlMsgType} from "@/api";
import {ControlMsgType} from "@/api";
import {isDevMode} from "@/composables/buildMode";
export interface IWebsocketService {
@ -14,12 +14,13 @@ export interface IWebsocketService {
deinit(): void;
send(msg: ServerMsg): void;
getSocketStatus(): void;
}
/**
* Websocket that run in a shared worker, shared across tabs
*/
class WebsocketShared implements IWebsocketService{
class WebsocketShared implements IWebsocketService {
private static instance: IWebsocketService;
private worker: SharedWorker;
@ -82,6 +83,10 @@ class WebsocketShared implements IWebsocketService{
this.ctrlCallback(ev.data);
}
getSocketStatus() {
this.worker.port.postMessage({type: ControlMsgType.WS_GET_STATE} as ControlMsg)
}
}
class WebsocketClassic implements IWebsocketService{
@ -115,10 +120,14 @@ class WebsocketClassic implements IWebsocketService{
send(msg: ServerMsg): void {
this.socket.send(msg);
}
getSocketStatus(): void {
this.socket.getSocketStatus();
}
}
export function getWebsocketService(): IWebsocketService {
if (typeof SharedWorker !== 'undefined') {
if (typeof SharedWorker !== 'undefined' && typeof localStorage !== 'undefined') {
return WebsocketShared.getInstance();
} else {
return WebsocketClassic.getInstance();

View File

@ -1,5 +1,4 @@
import type {ApiJsonMsg, ControlMsg, ServerMsg} from "@/api";
import type {ControlMsg, ServerMsg} from "@/api";
import {ControlEvent, ControlMsgType} from "@/api";
import {isDevMode} from "@/composables/buildMode";
@ -9,6 +8,8 @@ interface IWebsocket {
close(): void;
send(msg: ServerMsg): void;
getSocketStatus(): void;
}
class WebsocketDummy implements IWebsocket {
@ -20,6 +21,9 @@ class WebsocketDummy implements IWebsocket {
send(msg: ServerMsg) {
}
getSocketStatus(): void {
}
}
class OneTimeWebsocket implements IWebsocket {
@ -61,6 +65,8 @@ class OneTimeWebsocket implements IWebsocket {
console.log("No heart beat, break connection");
this.close();
this.clear();
// } else if (this.socket.readyState === this.socket.CONNECTING) {
// this.close();
}
if (isDevMode()) {
console.log("interval: ", this.heartBeatTimeCount, "state: ", this.socket.readyState);
@ -159,6 +165,26 @@ class OneTimeWebsocket implements IWebsocket {
this.ctrlCallback(msg);
this.closeCallback();
}
getSocketStatus() {
let type: ControlEvent;
switch (this.socket.readyState) {
case WebSocket.CONNECTING:
type = ControlEvent.CONNECTING;
break;
case WebSocket.OPEN:
type = ControlEvent.CONNECTED;
break;
default:
type = ControlEvent.DISCONNECTED;
break;
}
const msg: ControlMsg = {
type: ControlMsgType.WS_EVENT,
data: type,
};
this.ctrlCallback(msg);
}
}
export class WebsocketWrapper {
@ -219,4 +245,8 @@ export class WebsocketWrapper {
send(msg: ServerMsg) {
this.socket.send(msg)
}
getSocketStatus() {
this.socket.getSocketStatus();
}
}

View File

@ -1,12 +1,11 @@
import type {ControlMsg, ServerMsg} from "@/api";
declare const self: SharedWorkerGlobalScope;
import {ControlEvent, ControlMsgType} from "@/api";
import {WebsocketWrapper} from "@/composables/websocket/websocketWrapper";
import {toClient, toClientCtrl, toServer} from "@/composables/broadcastChannelDef";
import {ControlEvent, ControlMsgType} from "@/api";
import {isDevMode} from "@/composables/buildMode";
declare const self: SharedWorkerGlobalScope;
const websocket = new WebsocketWrapper();
let host = "";
@ -30,6 +29,8 @@ self.onconnect = function(event) {
host = e.data.data;
websocket.init(host, msgBroadcast, ctrlBroadcast);
}
} else if (e.data.type === ControlMsgType.WS_GET_STATE) {
websocket.getSocketStatus();
}
};
const msg: ControlMsg = {

View File

@ -1,19 +1,52 @@
import { createI18n } from 'vue-i18n';
import {createI18n} from 'vue-i18n';
import zh from '@/locales/zh'
import en from '@/locales/en'
import fr from '@/locales/fr'
// const locale = localStorage.getItem('lang') || 'zh';
export const locale = 'zh';
const userLanguage = navigator.language || 'en';
// Get the language code (e.g., 'en' from 'en-US')
export const locale = userLanguage.split('-')[0];
const messages = {
zh,
en,
fr,
} as const;
type Locale = keyof typeof messages;
export const availableLanguages = Object.keys(messages);
// export const locale = 'zh';
console.log(userLanguage, locale, availableLanguages)
const i18n = createI18n({
globalInjection: true,
legacy: false,
locale: locale,
fallbackLocale: 'zh',
messages: {
zh,
// en,
}
messages: messages
});
export function getFlagFromLang(lang: string) {
if (lang === 'zh') {
return '🇨🇳';
} else if (lang === 'en') {
return '🇺🇸';
} else if (lang === 'fr') {
return '🇫🇷';
}
return '🏳️';
}
export function setLang(lang: string): void {
if (availableLanguages.includes(lang)) {
i18n.global.locale.value = lang as Locale;
}
}
export function getLang() {
return i18n.global.locale;
}
export default i18n;

View File

@ -1,3 +1,235 @@
export default {
disconnected: "disconnected"
emoji: {
flag: "🇺🇸",
},
disconnected: "Disconnected",
connected: "Connected",
connecting: "Connecting",
use: "use",
author: "author",
studioYunSi: "Yunsi Studio",
authorEmail: "Author email",
TencentQQGroup: "QQ Group",
Discord: "Discord",
BiliBili: "BiliBili",
suggestion: "suggestion",
feature: "feature",
version: "Version",
releaseTime: "Release Time",
credit: "Credit",
aboutWebHost: "About the Web Host Application",
aboutDebugger: "About the Debugger",
officialWebsite: "Official Website",
email: "Email",
note: "Note",
welcomeMessage: "Welcome to reach out anytime",
serialNumber: "Serial Number",
ws: {
disconnected: "Disconnected",
connected: "Connected",
connecting: "Connecting",
},
page: {
home: "Home",
wifi: "Wi-Fi",
about: "About",
uart: "Uart",
widget: "Widget",
feedback: "Feedback",
close: "Close",
update: "Update",
fullscreen: "Fullscreen",
windowed: "Windowed"
},
uart: {
port: "Port",
startCommunication: "Start Communication",
stopCommunication: "Stop Communication",
commonlyUsed: "Common",
baudrate: "Baud Rate",
customBaud: "Custom Baud",
use: "Use",
actual: "Actual",
dataBits: "Data Bits",
stopBits: "Stop Bits",
parity: "Parity",
parityNone: "None",
parityOdd: "Odd",
parityEven: "Even",
flowControl: "Flow Control",
send: "Send",
clear: "Clear",
clearTooltip: "Only clears the display area, can be restored with refresh.",
updateTooltip: "Sync with cache + filter",
autoUpdateTooltip: "Only stop refreshing the display area; the background continues to receive data.",
receive: "Receive",
displayOptions: "Display Options",
display: "Display",
show: "Show",
text: "Text",
timestamp: "Timestamp",
enable: "Enable",
lineWrap: "Line Wrap",
highlight: "Highlight",
frameBreakStrategy: "Frame Break Strategy",
priority: "Priority",
rule: "Rule",
ruleTips:
"<p>Timeout=-1: Disable timeout frame break</p>" +
"<p>Timeout=0: Immediate break, any received data is considered complete</p>" +
"<p>Match after break: Typical \\n scenario</p>" +
"<p>Match before break: For scenarios with special frame headers</p>" +
"<p>Fixed byte frame break: Useful for large data transfer, e.g., break frame every 1024 bytes for easy data viewing</p>",
value: "Value",
timeout: "Timeout",
match: "Match",
byte: "Byte",
begin: "b",
end: "b",
other: "Other",
decodeAnsiEscapeCodes: "Decode ANSI Escape Codes",
ansiTooltips:
"<p>ANSI escape codes have many uses for terminals and text, such as changing text colors, among other effects.</p>\n" +
"<p>\n Learn more ->\n <a target=\"_blank\" href=\"https://en.wikipedia.org/wiki/ANSI_escape_code\">" +
"https://en.wikipedia.org/wiki/ANSI_escape_code\n </a></p>",
filter: "Filter",
textAndEscape: "Text with \\n\\x support",
autoUpdateNewData: "Auto-refresh new data",
updateFrequency: "Data Display Update Interval (ms)",
updateFrequencyTooltip: "Increasing the interval can reduce CPU usage.",
addHeader: "Add Header",
addFooter: "Add Footer",
passthrough: "Passthrough",
proxy: "Proxy",
serverPort: "Server Port",
connectedClient: "Connected Client",
refresh: "Refresh",
interface: "Interface",
noClientConnected: "No Client Connected",
import: "Import",
export: "Export",
reset: "Reset",
resetTooltip: "Takes effect after refreshing the page.",
saveToLocal: "Save to Local",
saveToLocalTooltip: "If multiple pages exist, they will overwrite each other.",
add: "Add",
edit: "Edit",
drag: "Drag",
ipChangeAlert: "Changing the IP address will cause the configuration to be lost.",
layout: "Layout",
landscape: "Landscape",
portrait: "Portrait",
responsive: "Responsive",
configPannel: "Config",
displayPannel: "Display",
macroPannel: "Quick Send",
autoScrollToBottom: "Auto Scroll",
clearScreen: "Clear",
autoUpdate: "Auto Update",
tempDisplayTooltip: "Data that does not meet the frame-break rules (e.g., not timed out) is temporarily displayed in real-time in this area. If it exceeds 8192 bytes, it will automatically break frames.",
loopSend: "Loop Send",
loopSendTooltip: "The actual frequency is affected by the interface refresh rate. For more accuracy, you can try turning off 'auto-refresh'.",
sendFormat: "Send Format",
cachedFrame: "Cached",
format: "Format",
},
wifi: {
settings: "Settings",
setFailed: "Settings failed to set",
setSuccess: "Settings saved",
connection: "Connection",
scanning: "Scanning",
scan: "Scan",
scanDone: "Scan done",
warnWifiName: "Enter Wi-Fi Name",
password: "Password",
connectInfoHTML: "Changing Wi-Fi will disconnect this interface from the passthrough device if not connected through its hotspot.",
connect: "Connect",
mode: "Mode",
save: "Save",
station: "Station",
intelligent: "Smart",
APOnly: "Hotspot Only",
disconnected: "Disconnected",
modeTipsHtml: "<p>\n" +
"<el-textsize=\"small\">Smart Mode:</el-text>\n" +
"After connecting to Wi-Fi, the hotspot will turn off automatically after 30 seconds if no device is connected. It will turn on after 5 seconds if disconnected from AP.\n" +
"</p>\n" +
"<p>\n" +
"<el-textsize=\"small\">Coexistence Mode:</el-text>\n" +
"Convenient but impacts stability and increases power consumption.\n" +
"</p>\n" +
"<p>\n" +
"<el-textsize=\"small\">Hotspot-Only Mode Drawback:</el-text>\n" +
"No network connection.\n" +
"</p>",
enabled: "Enabled",
disabled: "Disabled",
stationInfo: "Terminal (STA)",
hotspotInfo: "Hotspot (AP)",
signalStrength: "Signal Strength",
gateway: "Gateway",
netmask: "Netmask",
primaryDNS: "Primary DNS",
backupDNS: "Backup DNS",
IPmode: "IP Allocation Mode",
DNSmode: "DNS Mode",
internalAddress: "Internal Address",
autoIP: "Automatic (DHCP)",
staticIP: "Static IP",
autoDNS: "Automatic (Use Gateway)",
staticDNS: "Static DNS",
APauto_STA: "Smart Hotspot + Persistent Terminal (AP+STA)",
APonly: "Hotspot Only (AP)",
AP_STA: "Persistent Hotspot + Persistent Terminal (AP+STA)",
connectionSuccess: "Connection Successful",
enterAPName: "Entre the AP name",
debuggerNotConnected: "Debugger not connected",
},
widget: {
editGrid: 'Edit Grid',
editCell: 'Edit Cell',
loopWidget: 'Loop Widget',
loopWidgetDesc: 'Container for command sequences.',
addGrid: 'Add to Grid',
dataViewer: 'Data Viewer',
dataViewerDesc: 'Displays raw text data from UART.',
exportSettings: 'Export Settings',
importSettings: 'Import Settings',
resetToDefault: 'Reset to Default',
gridItemName: 'Widget name',
dropHere: 'Drop here',
run: 'Run',
loop: 'Loop',
delay: 'Delay',
addCommand: 'Add Command',
loopInterval: 'Loop Interval',
uartViewOnce: 'UART View Widget can only be added once.'
},
common: {
debuggerConnected: 'Debugger connected',
ok: 'OK'
},
navbar: {
navigationSidebar: 'Navigation Sidebar',
getSomeFries: "Let's go to the dock and get some fries",
},
};

236
src/locales/fr.ts Normal file
View File

@ -0,0 +1,236 @@
export default {
emoji: {
flag: "🇫🇷",
},
disconnected: "Déconnecté",
connected: "Connecté",
connecting: "Connexion..",
use: "utiliser",
author: "Auteur",
studioYunSi: "Studio Yunsi",
authorEmail: "Email de l'auteur",
TencentQQGroup: "Groupe QQ",
Discord: "Discord",
BiliBili: "BiliBili",
suggestion: "suggestion",
feature: "fonctionnalité",
version: "Version",
releaseTime: "Date de Publication",
credit: "Remerciements",
aboutWebHost: "À propos de l'Hôte Web",
aboutDebugger: "À propos du Débogueur",
officialWebsite: "Site Officiel",
email: "E-mail",
note: "Remarque",
welcomeMessage: "N'hésitez pas à venir nous solliciter.",
serialNumber: "Numéro de série",
ws: {
disconnected: "Déconnecté",
connected: "Connecté",
connecting: "Connexion..",
},
page: {
home: "Accueil",
wifi: "Wi-Fi",
about: "À propos",
uart: "Uart",
widget: "Widget",
feedback: "Feedback",
close: "Fermer",
update: "Mise à jour",
fullscreen: "Plein écran",
windowed: "Fenêtré",
},
uart: {
port: "Port",
startCommunication: "Démarrer la communication",
stopCommunication: "Arrêter la communication",
commonlyUsed: "Fréquemment utilisé",
baudrate: "Taux de Baud",
customBaud: "Baud",
use: "Utiliser",
actual: "Actuel",
dataBits: "Bits de Données",
stopBits: "Bits d'Arrêt",
parity: "Parité",
parityNone: "Aucune",
parityOdd: "Impair(Odd)",
parityEven: "Pair(Even)",
flowControl: "Contrôle de Flux",
send: "Envoyer",
clear: "Effacer",
clearTooltip: "Ne supprime que la zone d'affichage, peut être restaurée en actualisant.",
updateTooltip: "Synchroniser avec le cache + filtrer",
autoUpdateTooltip: "Arrête uniquement le rafraîchissement de la zone d'affichage ; l'arrière-plan continue de recevoir des données.",
receive: "Recevoir",
displayOptions: "Options d'Affichage",
display: "Affichage",
show: "Afficher",
text: "Texte",
timestamp: "Horodatage",
enable: "Activer",
lineWrap: "Retour à la Ligne",
highlight: "Surligner",
frameBreakStrategy: "Stratégie de Coupure de Trame",
priority: "Priorité",
rule: "Règle",
ruleTips:
"<p>Délai d'expiration=-1 : Désactiver la coupure de trame par délai d'expiration</p>" +
"<p>Délai d'expiration=0 : Coupure immédiate, toutes données reçues sont considérées complètes</p>" +
"<p>Match après coupure : Scénario typique \\n</p>" +
"<p>Match avant coupure : Pour des scénarios avec en-têtes de trame spécifiques</p>" +
"<p>Coupure de trame par octets fixes : Utile pour le transfert de grandes quantités de données, par exemple, couper la trame tous les 1024 octets pour faciliter la visualisation des données</p>",
value: "Valeur",
timeout: "Timeout",
match: "Match",
byte: "Byte",
begin: "b",
end: "b",
other: "Autres",
decodeAnsiEscapeCodes: "Décode Échappement ANSI",
ansiTooltips:
"<p>Les codes d'échappement ANSI ont de nombreuses utilisations pour les terminaux et le texte, comme changer les couleurs du texte, entre autres effets.</p>" +
"<p>\n En savoir plus ->\n " +
"<a target=\"_blank\" href=\"https://en.wikipedia.org/wiki/ANSI_escape_code\">\n" +
"https://en.wikipedia.org/wiki/ANSI_escape_code\n </a>\n</p>",
filter: "Filtrer",
textAndEscape: "Texte;supporte\\n\\x",
autoUpdateNewData: "Auto-update nouvelles données",
updateFrequency: "Délais rafraîchissement des Données (ms)",
updateFrequencyTooltip: "Augmenter l'intervalle peut réduire l'utilisation des ressources CPU.",
addHeader: "Ajouter un En-tête",
addFooter: "Ajouter un Pied de page",
passthrough: "Transmission",
proxy: "Proxy",
serverPort: "Port Serveur",
connectedClient: "Client Connecté",
refresh: "Rafraîchir",
interface: "Interface",
noClientConnected: "Aucun Client Connecté",
import: "Importer",
export: "Exporter",
reset: "Réinitialiser",
resetTooltip: "Prend effet après le rafraîchissement de la page.",
saveToLocal: "Enregistrer Localement",
saveToLocalTooltip: "S'il existe plusieurs pages, elles se chevaucheront mutuellement.",
add: "Ajouter",
edit: "Éditer",
drag: "Glisser",
ipChangeAlert: "Le changement d'adresse IP entraînera la perte de la configuration.",
layout: "Disposition",
landscape: "Paysage",
portrait: "Portrait",
responsive: "Résponsive",
configPannel: "Configuration",
displayPannel: "Données",
macroPannel: "Envoie Rapide",
autoScrollToBottom: "Auto Scroll",
clearScreen: "Effacer",
autoUpdate: "Auto Update",
tempDisplayTooltip: "es données qui ne respectent pas les règles de rupture de trame (par exemple : non expirées) s'affichent temporairement en temps réel dans cette zone. Au-delà de 8192 octets, une rupture de trame est automatique.",
loopSend: "Envoi en Boucle",
loopSendTooltip: "La fréquence réelle est influencée par le taux de rafraîchissement de l'interface. Pour plus de précision, vous pouvez essayer de désactiver 'l'actualisation automatique'.",
sendFormat: "Format d'Envoi",
cachedFrame: "Cache",
format: "Format",
},
wifi: {
settings: "Paramètres",
setFailed: "Echec d'enregistrement de paramètres",
setSuccess: "Paramètres enregistrés",
connection: "Connexion",
scanning: "Recherche en cours",
scan: "Rechercher",
scanDone: "Fin recherche de Wi-Fi",
warnWifiName: "Entrez le nom du Wi-Fi",
password: "Mot de passe",
connectInfoHTML: "Changer de Wi-Fi déconnectera cette interface du dispositif de transmission s'il ne passe pas par le point d'accès.",
connect: "Connecter",
mode: "Mode",
save: "Enregistrer",
station: "Station",
intelligent: "Intelligent",
APOnly: "Point d'accès uniquement",
disconnected: "Déconnecté",
modeTipsHtml: "<p>\n" +
"<el-textsize=\"small\">Mode intelligent :</el-text>\n" +
"Après la connexion au Wi-Fi, le point d'accès s'éteindra automatiquement après 30 secondes si aucun appareil n'est connecté. Il s'allumera après 5 secondes si la connexion AP est perdue.\n" +
"</p>\n" +
"<p>\n" +
"<el-textsize=\"small\">Mode coexistence :</el-text>\n" +
"Pratique mais réduit la stabilité et augmente la consommation d'énergie.\n" +
"</p>\n" +
"<p>\n" +
"<el-textsize=\"small\">Inconvénient du mode point d'accès seul :</el-text>\n" +
"Pas de connexion réseau.\n" +
"</p>",
enabled: "Activé",
disabled: "Désactivé",
stationInfo: "Terminal(STA)",
hotspotInfo: "Point d'Accès(AP)",
signalStrength: "Puissance du Signal",
gateway: "Passerelle",
netmask: "Masque de Sous-réseau",
primaryDNS: "DNS Primaire",
backupDNS: "DNS Secondaire",
IPmode: "Mode d'Attribution IP",
DNSmode: "Mode DNS",
internalAddress: "Adresse Interne",
autoIP: "Automatique (DHCP)",
staticIP: "IP Statique",
autoDNS: "Automatique (gateway)",
staticDNS: "DNS Statique",
APauto_STA: "Point d'Accès Intelligent + Terminal Permanent (AP+STA)",
APonly: "Point d'Accès Seul (AP)",
AP_STA: "Point d'Accès Permanent + Terminal Permanent (AP+STA)",
connectionSuccess: "Connexion Réussie",
enterAPName: "Entrez le nom du AP",
debuggerNotConnected: "Debugger non connecté",
},
widget: {
editGrid: 'Modifier la grille',
editCell: 'Modifier les cellules',
loopWidget: 'Widget Boucle',
loopWidgetDesc: 'Conteneur pour les séquences de commandes.',
addGrid: 'Ajouter à la grille',
dataViewer: 'Visualiseur de données',
dataViewerDesc: "Affiche les données texte brutes de l'UART.",
exportSettings: 'Exporter les paramètres',
importSettings: 'Importer les paramètres',
resetToDefault: 'Réinitialiser par défaut',
gridItemName: "Nom",
dropHere: 'Déposer ici',
run: 'Exécuter',
loop: 'Boucle',
delay: 'Délai',
addCommand: 'Ajouter une commande',
loopInterval: 'Intervalle de répétition',
uartViewOnce: "Le widget de vue UART ne peut être ajouté qu'une seule fois."
},
common: {
debuggerConnected: 'Débogueur connecté',
ok: 'OK'
},
navbar: {
navigationSidebar: 'Barre latérale de navigation',
getSomeFries: 'Allons au quai prendre des frites',
},
};

View File

@ -7,8 +7,8 @@ type NestedKeyOf<ObjectType extends object> = {
: `${Key}`
}[keyof ObjectType & (string | number)];
type TranslationKeys = NestedKeyOf<typeof zh>;
export type TranslationKeys = NestedKeyOf<typeof zh>;
export function translate<K extends TranslationKeys>(key: K | string): string {
return i18n.global.t(key.toLowerCase());
export function translate(key: TranslationKeys | string): string {
return i18n.global.t(key);
}

View File

@ -1,7 +1,30 @@
export default {
emoji: {
flag: "🇨🇳",
},
disconnected: "未连接",
connected: "已连接",
connecting: "连接中",
use: "使用",
author: "作者",
studioYunSi: "允斯工作室",
authorEmail: "作者邮箱",
TencentQQGroup: "QQ群",
Discord: "Discord",
BiliBili: "哔哩哔哩",
suggestion: "建议",
feature: "需求",
version: "版本",
releaseTime: "发布时间",
credit: "鸣谢",
aboutWebHost: "关于网页版上位机",
aboutDebugger: "关于调试器",
officialWebsite: "官网",
email: "邮箱",
note: "备注",
welcomeMessage: "欢迎来打扰啊~",
serialNumber: "序列号",
ws: {
disconnected: "未连接",
@ -13,8 +36,204 @@ export default {
home: "主页",
wifi: "Wi-Fi",
about: "关于",
uart: "UART透传",
uart: "UART",
widget: "组件",
feedback: "反馈",
close: "关闭",
update: "更新",
fullscreen: "全屏",
windowed: "窗口",
},
uart: {
port: "接口",
startCommunication: "开始数据收发",
stopCommunication: "停止数据收发",
commonlyUsed: "常用",
baudrate: "波特率",
customBaud: "自定义波特率",
use: "使用",
actual: "实际",
dataBits: "数据位",
stopBits: "停止位",
parity: "校验位",
parityNone: "无(None)",
parityOdd: "奇(Odd)",
parityEven: "偶(Even)",
flowControl: "流控制",
send: "发送",
clear: "清空",
clearTooltip: "仅清除显示区域,可用刷新恢复",
updateTooltip: "与缓存同步+过滤",
autoUpdateTooltip: "仅停止刷新显示区,后台继续接收数据",
receive: "接收",
displayOptions: "显示选项",
display: "显示框",
show: "显示",
text: "文本",
timestamp: "时间戳",
enable: "启用",
lineWrap: "换行",
highlight: "高亮",
frameBreakStrategy: "断帧策略",
priority: "优先级",
rule: "规则",
ruleTips:
"<p>超时=-1 禁用超时断帧</p>" +
"<p>超时=0 当机立断,收到任何数据都视为完整数据</p>" +
"<p>匹配断后:典型\\n的场景</p>" +
"<p>匹配断前:用于有特殊帧头的场景</p>" +
"<p>固定字节断帧传输大量数据比如可以每隔1024字节断帧方便查看数据</p>",
value: "值",
timeout: "超时",
match: "匹配",
byte: "字节",
begin: "断",
end: "断",
other: "其他",
decodeAnsiEscapeCodes: "解码ANSI转义码",
ansiTooltips:
"<p>ANSI转义码对终端和文本有很多作用比如改变文本颜色等。</p>\n" +
"<p>\n" +
" 简单了解->\n" +
" <a target=\"_blank\" href=\"https://yunsi.studio/wireless-debugger/docs/uart-webhost/ansi-escape-code\">\n" +
" https://yunsi.studio/wireless-debugger/docs/uart-webhost/ansi-escape-code\n" +
" </a>\n" +
"</p>",
filter: "过滤",
textAndEscape: "文本,支持\\n\\x",
autoUpdateNewData: "新数据自动刷新",
updateFrequency: "数据显示刷新间隔(ms)",
updateFrequencyTooltip: "提高间隔可减少CPU资源的使用",
addHeader: "增加帧头",
addFooter: "增加帧尾",
passthrough: "透传",
proxy: "透传",
serverPort: "服务器端口",
connectedClient: "已连接的客户端",
refresh: "刷新",
interface: "接口",
noClientConnected: "无客户端连接",
import: "导入",
export: "导出",
reset: "重置",
resetTooltip: "刷新页面后生效",
saveToLocal: "保存到本地",
saveToLocalTooltip: "若存在多个页面,会相互覆盖",
add: "添加",
edit: "编辑",
drag: "拖拽",
ipChangeAlert: "IP地址改变会导致配置丢失",
layout: "布局",
landscape: "横/行",
portrait: "竖/列",
responsive: "自适应",
configPannel: "设置窗",
displayPannel: "数据窗",
macroPannel: "快捷窗",
autoScrollToBottom: "自动滚动到底部",
clearScreen: "清屏",
autoUpdate: "自动刷新",
tempDisplayTooltip: "未满足断帧规则的数据未超时暂时实时显示在此区域。超过8192字节自动断帧",
loopSend: "循环发送",
loopSendTooltip: "实际频率受界面刷新率影响,如需要更精确,可以尝试关闭'自动刷新'",
sendFormat: "发送格式",
cachedFrame: "缓存帧数",
format: "格式化",
},
wifi: {
settings: "配置",
setFailed: "设置失败",
setSuccess: "配置成功",
connection: "连接",
scanning: "扫描中",
scan: "扫描",
scanDone: "扫描成功",
warnWifiName: "请输入WIFI名",
password: "密码",
connectInfoHTML: "如果不是通过透传器的热点连接更换Wi-Fi将导致此界面与透传器断开连接。",
connect: "连接",
mode: "模式",
save: "保存",
station: "终端",
intelligent: "智能",
APOnly: "仅开启热点",
disconnected: "未连接",
modeTipsHtml: "<p>\n" +
"<el-textsize=\"small\">智能模式:</el-text>\n" +
"成功连接至Wi-Fi后,如果此设备的热点未被其他设备连接,将在30秒后自动关闭热点;如果此设备与AP断开连接,将在5秒后自动开启热点\n" +
"</p>\n" +
"<p>\n" +
"<el-textsize=\"small\">热点+终端共存模式:</el-text>\n" +
"方便使用,但是影响稳定性,增加功耗\n" +
"</p>\n" +
"<p>\n" +
"<el-textsize=\"small\">单热点模式缺点:</el-text>\n" +
"无网络\n" +
"</p>",
enabled: "已开启",
disabled: "未开启",
stationInfo: "终端(STA)",
hotspotInfo: "自发热点(AP)",
signalStrength: "信号强度",
gateway: "网关",
netmask: "掩码",
primaryDNS: "首选DNS",
backupDNS: "备用DNS",
IPmode: "IP分配模式",
DNSmode: "DNS模式",
internalAddress: "内网地址",
autoIP: "自动 (DHCP)",
staticIP: "静态IP",
autoDNS: "自动 (使用网关)",
staticDNS: "静态DNS",
APauto_STA: "智能热点+常开终端 (AP+STA)",
APonly: "仅开启热点 (AP)",
AP_STA: "常开热点+常开终端 (AP+STA)",
connectionSuccess: "连接成功",
enterAPName: "请输入AP名",
debuggerNotConnected: "调试器未连接",
},
widget: {
editGrid: '编辑网格',
editCell: '编辑单元',
loopWidget: '循环小部件',
loopWidgetDesc: '用于命令序列的容器。',
addGrid: '添加到网格',
dataViewer: '数据显示器',
dataViewerDesc: '显示来自UART的原始文本数据。',
exportSettings: '导出设置',
importSettings: '导入设置',
resetToDefault: '重置',
gridItemName: '组件名称',
dropHere: '在此处放置',
run: '运行',
loop: '循环',
delay: '延迟',
addCommand: '添加命令',
loopInterval: '循环间隔',
uartViewOnce: 'UART视图组件只能添加一次。'
},
common: {
debuggerConnected: '调试器已连接',
ok: '好的'
},
navbar: {
navigationSidebar: '导航侧栏',
getSomeFries: '走,去码头整点薯条',
},
}

View File

@ -4,6 +4,8 @@ import '@/assets/page.css'
import '@/assets/navigation.css'
import 'element-plus/dist/index.css';
import 'vuetify/styles'
import { createVuetify } from 'vuetify'
import { createApp } from 'vue'
import { createPinia } from 'pinia'
@ -17,5 +19,6 @@ const app = createApp(App)
app.use(createPinia())
app.use(i18n);
app.use(router)
app.use(createVuetify())
app.mount('#app')

View File

@ -1,54 +1,101 @@
import {createRouter, createWebHistory} from 'vue-router'
import Home from '@/views/Home.vue'
import {createRouter, createWebHashHistory, type RouteLocationNormalizedLoaded} from 'vue-router'
import Wifi from '@/views/Wifi.vue'
import Feedback from '@/views/Feedback.vue'
import About from '@/views/About.vue'
import Uart from '@/views/Uart.vue'
import Page404 from '@/views/404.vue'
import Update from '@/views/Update.vue'
import WidgetPannel from '@/views/WidgetPannel.vue'
import {translate} from "@/locales";
import {isOTAEnabled} from "@/composables/buildMode";
import {reactive, watch} from "vue";
import {getLang} from "@/i18n";
const languageState = reactive({
lang: getLang()
});
interface AppRouteMeta {
title?: string;
titleKey?: string;
}
const updateMetaTitles = () => {
router.getRoutes().forEach(route => {
const meta = route.meta as AppRouteMeta;
if (meta.titleKey) {
meta.title = translate(meta.titleKey);
}
});
};
function updateDocumentTitle(route: RouteLocationNormalizedLoaded) {
const meta = route.meta as AppRouteMeta;
document.title = typeof route.meta.title === 'string'
? `${translate(meta.titleKey || "")} | ${translate('studioYunSi')}`
: '允斯调试器';
}
// Watch for language changes to update the titles dynamically
watch(() => languageState.lang, () => {
// Recompute all route meta titles
updateMetaTitles();
updateDocumentTitle(router.currentRoute.value);
}, {deep: true});
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
history: createWebHashHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'home',
meta: {title: translate("page.home")},
// component: Wifi
redirect: () => '/wifi',
meta: { titleKey: 'page.home' },
redirect: () => '/uart',
}, {
path: '/home:ext(.*)',
meta: {title: translate("page.home")},
meta: { titleKey: 'page.home' },
redirect: () => '/',
}, {
path: '/wifi:ext(.*)',
meta: {title: translate('page.wifi')},
meta: { titleKey: 'page.wifi' },
component: Wifi,
}, {
path: '/about:ext(.*)',
meta: {title: translate('page.about')},
meta: { titleKey: 'page.about' },
component: About,
}, {
path: '/uart:ext(.*)',
meta: {title: translate('page.uart')},
meta: { titleKey: 'page.uart' },
component: Uart,
}, {
path: '/widget:ext(.*)',
meta: { titleKey: 'page.widget' },
component: WidgetPannel,
}, {
path: '/feedback:ext(.*)',
meta: {title: translate('page.feedback')},
meta: { titleKey: 'page.feedback' },
name: 'feedback',
component: Feedback,
}, {
path: '/:catchAll(.*)', // This will match all paths that aren't matched by above routes
path: '/update:ext(.*)',
meta: { titleKey: 'page.update' },
name: 'update',
component: isOTAEnabled() ? Update : Page404,
}, {
path: '/:catchAll(.*)', // Catch-all route for 404
name: 'NotFound',
component: Page404,
},
]
})
// Update document title dynamically
router.beforeEach((to, from, next) => {
document.title = typeof to.meta.title === 'string' ? to.meta.title + " | 允斯工作室" : '允斯调试器';
updateDocumentTitle(to);
next();
});
export default router
// Initialize titles on load
updateMetaTitles();
export default router;

View File

@ -12,6 +12,9 @@ const moduleMap = new Map<number, IModuleCallback>();
export function registerModule(moduleId: number, moduleCallback: IModuleCallback): boolean {
if (moduleMap.has(moduleId)) {
if (isDevMode()) {
console.log("module ", moduleId, "already registered");
}
return false;
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,11 @@
import {defineStore} from "pinia";
import {type Ref, ref} from "vue";
import type {InstanceInfo} from "@/api/apiDataFlow";
export const useDataFlowStore = defineStore('data_flow', () => {
const instanceList: Ref<InstanceInfo[]> = ref([]);
return {
instanceList,
}
});

View File

@ -0,0 +1,28 @@
import {defineStore} from "pinia";
import {ref} from "vue";
export const useSystemStore = defineStore('system', () => {
const curFmInfo = ref({
ver: "-",
date: "-",
});
const hwInfo = ref({
ver: "-",
date: "-",
})
const sys_info = ref({
sn: "-",
});
const rebootInProgress = ref(false);
return {
curFmInfo,
hwInfo,
sysInfo: sys_info,
rebootInProgress,
}
});

View File

@ -0,0 +1,8 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
export const useUartStore = defineStore('uart', () => {
const uartNum = ref(1);
return { uartNum }
})

View File

@ -0,0 +1,45 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
import {wt_ota_get_progress} from "@/api/apiOTA";
export const useUpdateStore = defineStore('update', () => {
const canUpdate = ref(false);
const updateProgress = ref(0);
const updateStatus = ref('');
const progressBarStatus = ref('');
let progressIntervalID = -1;
const newFmInfo = ref({
fm_size: 0,
fm_ver: "-",
upd_date: "-",
upd_note: "-",
})
function setProgressInterval() {
if (progressIntervalID < 0) {
progressIntervalID = setInterval(() => {
wt_ota_get_progress();
}, 1000);
}
}
function clearProgressInterval() {
if (progressIntervalID >= 0) {
clearInterval(progressIntervalID);
progressIntervalID = -1;
}
}
return {
canUpdate,
updateProgress,
updateStatus,
progressBarStatus,
newFmInfo,
setProgressInterval,
clearProgressInterval,
}
})

View File

@ -0,0 +1,424 @@
import { computed, markRaw, ref, toRaw, watch } from 'vue'
import { defineStore } from 'pinia'
import type { WidgetItem } from '@/types/grid'
import { globalNotify } from '@/composables/notification'
import { ElMessageBox } from 'element-plus'
import UartAtCommand from '@/views/widgets/uartAtCommand.vue'
import WidgetLoop from '@/views/widgets/widgetLoop.vue'
import textDataViewer from '@/views/text-data-viewer/textDataViewer.vue'
const componentMap: { [key: string]: any } = {
WidgetLoop,
textDataViewer,
UartAtCommand
}
const getComponentName = (component: any): string | null => {
const rawComponent = toRaw(component)
for (const name in componentMap) {
if (componentMap[name] === rawComponent) {
return name
}
}
return null
}
const getDefaultLayout = (): WidgetItem[] => [
{
x: 0,
y: 0,
w: 10,
h: 10,
i: 0,
name: 'Widget A',
static: false,
widget: markRaw(WidgetLoop),
widgetProps: [
{
id: 1,
componentType: markRaw(UartAtCommand),
props: { label: 'Device ID', command: 'AT+ID?', response: 'ID:xxxx' }
},
{
id: 2,
componentType: markRaw(UartAtCommand),
props: { label: 'Version', command: 'AT+VER?', response: 'V1.0.0' }
},
{
id: 3,
componentType: markRaw(UartAtCommand),
props: { label: 'Reset', command: 'AT+RESET', response: 'OK' }
}
]
},
{
x: 10,
y: 0,
w: 10,
h: 10,
i: 1,
name: 'Widget B',
static: false,
widget: markRaw(WidgetLoop),
widgetProps: [
{
id: 1,
componentType: markRaw(UartAtCommand),
props: {
label: 'Scan WiFi',
command: 'AT+WSCANasdfasdfasdf',
response: 'SCAN OKasd fsdaf asdf asdf asdf asdf '
}
},
{
id: 2,
componentType: markRaw(UartAtCommand),
props: {
label: 'Connect WiFi',
command: 'AT+WCONN=ssid,pwd',
response: 'CONN OK'
}
}
]
},
{
x: 0,
y: 10,
w: 10,
h: 10,
i: 2,
name: 'Widget C',
static: false,
widget: markRaw(WidgetLoop),
widgetProps: [
{
id: 1,
componentType: markRaw(UartAtCommand),
props: {
label: 'Ping Test',
command: 'AT+PING=google.com',
response: 'PING OK'
}
}
]
},
{
x: 10,
y: 10,
w: 10,
h: 10,
i: 3,
name: 'Widget D',
static: false,
widget: markRaw(textDataViewer),
widgetProps: []
}
]
const throttle = (fn: Function, wait: number) => {
let inThrottle: boolean, lastFn: number, lastTime: number
return function (this: any, ...args: any[]) {
const context = this
if (!inThrottle) {
fn.apply(context, args)
lastTime = Date.now()
inThrottle = true
} else {
clearTimeout(lastFn)
lastFn = window.setTimeout(() => {
if (Date.now() - lastTime >= wait) {
fn.apply(context, args)
lastTime = Date.now()
}
}, Math.max(wait - (Date.now() - lastTime), 0))
}
}
}
export const useWidgetStore = defineStore('widget', () => {
const layout = ref<WidgetItem[]>(getDefaultLayout())
const editCell = ref(false)
const editGrid = ref(false)
const showOptions = ref(true)
const isUartViewAdded = computed(() =>
layout.value.some((item) => toRaw(item.widget) === textDataViewer)
)
watch(
() => editGrid.value,
(newValue) => {
if (newValue) {
editCell.value = false
}
}
)
watch(
() => editCell.value,
(newValue) => {
if (newValue) {
editGrid.value = false
}
}
)
const getNextId = (): number => {
const numericIds = layout.value.map((item) => Number(item.i)).filter((id) => !isNaN(id))
if (numericIds.length === 0) {
return 0
}
return Math.max(...numericIds) + 1
}
const addLoopWidget = () => {
const nextId = getNextId()
let y = 0
if (layout.value.length > 0) {
y = Math.max(...layout.value.map((item) => item.y + item.h))
}
const newWidget: WidgetItem = {
x: 0,
y: y,
w: 10,
h: 5,
i: nextId,
name: `New Loop Widget`,
static: false,
widget: markRaw(WidgetLoop),
widgetProps: []
}
layout.value.push(newWidget)
}
const addUartViewWidget = () => {
if (isUartViewAdded.value) {
globalNotify('UART View Widget can only be added once.', 'warning')
return
}
const nextId = getNextId()
let y = 0
if (layout.value.length > 0) {
y = Math.max(...layout.value.map((item) => item.y + item.h))
}
const newWidget: WidgetItem = {
x: 0,
y: y,
w: 10,
h: 10,
i: nextId,
name: 'UART Data Viewer',
static: false,
widget: markRaw(textDataViewer),
widgetProps: []
}
layout.value.push(newWidget)
}
const deleteWidget = (index: number) => {
layout.value.splice(index, 1)
}
const resetToDefault = () => {
ElMessageBox.confirm(
'This will reset your layout to the default settings. Are you sure?',
'Warning',
{
confirmButtonText: 'OK',
cancelButtonText: 'Cancel',
type: 'warning'
}
)
.then(() => {
layout.value = getDefaultLayout()
globalNotify('Layout reset to default.', 'success')
})
.catch(() => {
globalNotify('Layout reset cancelled.', 'info')
})
}
const exportSettings = () => {
try {
const layoutToSave = toRaw(layout.value).map((item) => {
const rawItem = toRaw(item)
return {
...rawItem,
widget: getComponentName(rawItem.widget),
widgetProps: rawItem.widgetProps?.map((prop: any) => {
const rawProp = toRaw(prop)
return {
...rawProp,
componentType: getComponentName(rawProp.componentType)
}
})
}
})
const dataStr = JSON.stringify(layoutToSave, null, 2)
const dataUri = 'data:application/json;charset=utf-8,' + encodeURIComponent(dataStr)
const exportFileDefaultName = 'at-command-settings.json'
const linkElement = document.createElement('a')
linkElement.setAttribute('href', dataUri)
linkElement.setAttribute('download', exportFileDefaultName)
linkElement.click()
globalNotify('Settings exported successfully.', 'success')
} catch (error) {
console.error('Failed to export settings:', error)
globalNotify('Failed to export settings.', 'error')
}
}
const importSettings = () => {
const input = document.createElement('input')
input.type = 'file'
input.accept = '.json'
input.onchange = (e) => {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = (event) => {
try {
const fileContent = event.target?.result as string
const parsedLayout = JSON.parse(fileContent)
// Basic validation
if (!Array.isArray(parsedLayout)) {
throw new Error('Invalid format: expected an array of widgets.')
}
const newLayout = parsedLayout
.map((item: any) => {
if (item.widget && componentMap[item.widget]) {
item.widget = markRaw(componentMap[item.widget])
} else {
console.warn(`Unknown widget type "${item.widget}" during import. Skipping item.`)
return null
}
if (item.widgetProps) {
item.widgetProps.forEach((prop: any) => {
if (prop.componentType && componentMap[prop.componentType]) {
prop.componentType = markRaw(componentMap[prop.componentType])
} else if (prop.componentType) {
console.warn(
`Unknown componentType "${prop.componentType}" for widget "${item.name}". It will be ignored.`
)
prop.componentType = null
}
})
item.widgetProps = item.widgetProps.filter((prop: any) => prop.componentType)
}
return item
})
.filter(Boolean) // remove null items
layout.value = newLayout
globalNotify('Settings imported successfully.', 'success')
} catch (error: any) {
console.error('Failed to import settings:', error)
globalNotify(`Failed to import settings: ${error.message}`, 'error')
}
}
reader.readAsText(file)
}
input.click()
}
const saveLayoutToLocalStorage = () => {
try {
const layoutToSave = toRaw(layout.value).map((item) => {
const rawItem = toRaw(item)
return {
...rawItem,
widget: getComponentName(rawItem.widget),
widgetProps: rawItem.widgetProps?.map((prop: any) => {
const rawProp = toRaw(prop)
return {
...rawProp,
componentType: getComponentName(rawProp.componentType)
}
})
}
})
localStorage.setItem('at-command-layout', JSON.stringify(layoutToSave))
} catch (error) {
console.error('Failed to save layout to localStorage:', error)
}
}
const loadLayoutFromLocalStorage = () => {
const savedLayoutJSON = localStorage.getItem('at-command-layout')
if (!savedLayoutJSON) return
try {
const parsedLayout = JSON.parse(savedLayoutJSON)
if (!Array.isArray(parsedLayout)) {
throw new Error('Invalid format in localStorage: expected an array of widgets.')
}
const newLayout = parsedLayout
.map((item: any) => {
if (item.widget && componentMap[item.widget]) {
item.widget = markRaw(componentMap[item.widget])
} else {
console.warn(`Unknown widget type "${item.widget}" from localStorage. Skipping item.`)
return null
}
if (item.widgetProps) {
item.widgetProps.forEach((prop: any) => {
if (prop.componentType && componentMap[prop.componentType]) {
prop.componentType = markRaw(componentMap[prop.componentType])
} else if (prop.componentType) {
console.warn(
`Unknown componentType "${prop.componentType}" for widget "${item.name}" from localStorage. It will be ignored.`
)
prop.componentType = null
}
})
item.widgetProps = item.widgetProps.filter((prop: any) => prop.componentType)
}
return item
})
.filter(Boolean) // remove null items
layout.value = newLayout
globalNotify('Settings restored from last session.', 'success')
} catch (error: any) {
console.error('Failed to load layout from localStorage:', error)
globalNotify(`Failed to load settings from localStorage: ${error.message}`, 'error')
localStorage.removeItem('at-command-layout')
}
}
watch(
layout,
throttle(() => saveLayoutToLocalStorage(), 1000),
{ deep: true }
)
loadLayoutFromLocalStorage()
return {
layout,
editGrid,
editCell,
showOptions,
isUartViewAdded,
addLoopWidget,
addUartViewWidget,
deleteWidget,
resetToDefault,
exportSettings,
importSettings,
getNextId
}
})

26
src/types/grid.ts Normal file
View File

@ -0,0 +1,26 @@
import type { Component } from 'vue'
export interface UartCommandData {
command: string
label: string
response: string
}
export interface DraggableComponent<T = Record<string, any>> {
id: number
componentType: Component | string
props: T
}
export interface WidgetItem {
x: number
y: number
w: number
h: number
i: number
name: string
static: boolean
widget: Component | string
widgetIconName?: string
widgetProps: DraggableComponent[]
}

View File

@ -1,22 +1,25 @@
<script setup lang="ts">
import {useSystemStore} from "@/stores/useSystemStore";
import {translate} from "@/locales";
const version = import.meta.env.VITE_APP_GIT_TAG || "v0.0.0";
const compileTime = import.meta.env.VITE_APP_LAST_COMMIT || "1970-00-00";
const sysStore = useSystemStore();
</script>
<template>
<div class="text-layout">
<el-divider></el-divider>
<el-divider>关于</el-divider>
<el-divider>{{ translate('page.about') }}</el-divider>
<el-divider></el-divider>
<el-collapse>
<el-collapse-item title="关于网页版上位机">
<el-collapse-item :title="translate('aboutWebHost')">
<el-descriptions border :column="1" class="mt-5 description-style">
<el-descriptions-item label="版本">{{ version }}</el-descriptions-item>
<el-descriptions-item label="发布时间">{{ compileTime }}</el-descriptions-item>
<el-descriptions-item label="许可证">MIT</el-descriptions-item>
<el-descriptions-item :label="translate('version')">{{ version }}</el-descriptions-item>
<el-descriptions-item :label="translate('releaseTime')">{{ compileTime }}</el-descriptions-item>
</el-descriptions>
<el-descriptions title="鸣谢" border :column="1" class="mt-5 description-style">
<el-descriptions :title="translate('credit')" border :column="1" class="mt-5 description-style">
<el-descriptions-item label="vuejs"><a target="_blank" href="https://github.com/vuejs/vue/blob/main/LICENSE">MIT</a>
</el-descriptions-item>
<el-descriptions-item label="typescript"><a
@ -40,13 +43,15 @@ const compileTime = import.meta.env.VITE_APP_LAST_COMMIT || "1970-00-00";
</el-descriptions-item>
</el-descriptions>
</el-collapse-item>
<el-collapse-item title="关于下位机">
<el-collapse-item :title="translate('aboutDebugger')">
<el-descriptions border :column="1" class="mt-5 description-style">
<el-descriptions-item label="官网"><a target="_blank" href="https://yunsi.studio/wireless-proxy">允斯工作室</a></el-descriptions-item>
<el-descriptions-item label="版本">-</el-descriptions-item>
<el-descriptions-item :label="translate('officialWebsite')"><a target="_blank" href="https://yunsi.studio/wireless-debugger">https://yunsi.studio/wireless-debugger</a></el-descriptions-item>
<el-descriptions-item :label="translate('version')">{{ sysStore.curFmInfo.ver }}</el-descriptions-item>
<el-descriptions-item :label="translate('releaseTime')">{{ sysStore.curFmInfo.date }}</el-descriptions-item>
<el-descriptions-item :label="translate('serialNumber')">{{ sysStore.sysInfo.sn }}</el-descriptions-item>
</el-descriptions>
<el-descriptions title="鸣谢" border :column="1" class="mt-5 description-style">
<el-descriptions :title="translate('credit')" border :column="1" class="mt-5 description-style">
<el-descriptions-item label="windowsair"><a target="_blank" href="https://github.com/windowsair/wireless-esp8266-dap">wireless-esp8266-dap</a>
</el-descriptions-item>
</el-descriptions>
@ -54,22 +59,22 @@ const compileTime = import.meta.env.VITE_APP_LAST_COMMIT || "1970-00-00";
</el-collapse>
<el-descriptions title="作者:空空(kerms)" border :column="1" class="mt-5 description-style">
<el-descriptions-item label="官网"><a target="_blank" href="https://yunsi.studio/">允斯工作室https://yunsi.studio/</a></el-descriptions-item>
<el-descriptions :title="translate('author') + ' :空空(kerms)'" border :column="1" class="mt-5 description-style">
<el-descriptions-item :label="translate('officialWebsite')"><a target="_blank" href="https://yunsi.studio/">https://yunsi.studio/</a></el-descriptions-item>
<el-descriptions-item label="github"><a target="_blank" href="https://github.com/kerms">https://github.com/kerms</a>
</el-descriptions-item>
<el-descriptions-item label="邮箱">kerms@niazo.org</el-descriptions-item>
<el-descriptions-item :label="translate('email')">kerms@niazo.org</el-descriptions-item>
<el-descriptions-item label="BiliBili"><a target="_blank" href="https://space.bilibili.com/3461571571353885">3461571571353885</a>
</el-descriptions-item>
<el-descriptions-item label="QQ群">642246000</el-descriptions-item>
<el-descriptions-item label="备注">欢迎大家来打扰啊</el-descriptions-item>
<el-descriptions-item :label="translate('TencentQQGroup')">642246000</el-descriptions-item>
<el-descriptions-item :label="translate('note')">{{ translate('welcomeMessage') }}</el-descriptions-item>
</el-descriptions>
</div>
<el-divider></el-divider>
</template>
<style scoped>
<style scoped lang="postcss">
.description-style :deep(.el-descriptions__label) {
@apply w-32
}

View File

@ -2,12 +2,13 @@
<div class="text-layout">
<el-divider></el-divider>
<el-divider>反馈</el-divider>
<el-divider>{{ translate('page.feedback') }}</el-divider>
<el-divider></el-divider>
<el-descriptions title="反馈/建议/需要新功能" border :column="1">
<el-descriptions-item label="QQ群">642246000</el-descriptions-item>
<el-descriptions-item label="作者邮箱">kerms@niazo.org</el-descriptions-item>
<el-descriptions :title="translate('page.feedback') + '/' + translate('suggestion') + '/' + translate('feature')" border :column="1">
<el-descriptions-item :label="translate('TencentQQGroup')">642246000</el-descriptions-item>
<el-descriptions-item :label="translate('authorEmail')">kerms@niazo.org</el-descriptions-item>
<!-- TODO: add discord + BiliBili / instagram ? -->
</el-descriptions>
</div>
@ -16,5 +17,5 @@
<script setup lang="ts">
import {translate} from "@/locales";
</script>

View File

@ -1,9 +1,480 @@
<template>
<div class="button-m-0 messages-container flex flex-grow overflow-hidden" :class="{'flex-col': store.winLayoutMode ==='col'}">
<div v-show="store.winLeft.show" ref="win1Ref" class="bg-gray-50 flex-shrink-0 overflow-auto"
:class="{
'max-w-60': store.winLayoutMode==='row', 'xl:max-w-80': store.winLayoutMode==='row',
'min-w-60': store.winLayoutMode==='row', 'xl:min-w-80': store.winLayoutMode==='row'
}"
>
<text-data-config></text-data-config>
</div>
<div v-show="store.winLeft.show && (winDataView.show || store.winRight.show)" ref="firstWinResizeRef"></div>
<div v-show="winDataView.show" class="flex flex-col flex-grow overflow-hidden p-2">
<textDataViewer :showDataConfig="store.winLeft.show"></textDataViewer>
</div>
<div v-show="winDataView.show && store.winRight.show" ref="thirdWinResizeRef"></div>
<div v-show="store.winRight.show" ref="win2Ref" :class="{
'max-w-80': store.winLayoutMode==='row', 'xl:max-w-96': store.winLayoutMode==='row',
'min-w-80': store.winLayoutMode==='row', 'xl:min-w-96': store.winLayoutMode==='row'
}"
class="bg-gray-50 flex flex-col flex-shrink-0 min-h-32 overflow-auto p-2">
<TextDataMacro @winSizeRefresh="handleWinSizeRefresh"></TextDataMacro>
</div>
</div>
<teleport to="#page-spec-slot">
<div>
<el-popover
placement="bottom"
trigger="click"
:hide-after="0"
transition="none"
>
<div class="button-m-0 flex flex-col space-y-2">
<div class="custom-style flex justify-center">
<el-segmented v-model="store.winLayoutMode" :options="layoutOptions" size="small"/>
</div>
<el-checkbox v-model="store.winAutoLayout" border size="small"
:disabled="store.winLayoutMode==='col'">
{{ $t('uart.responsive') }}
</el-checkbox>
<el-checkbox v-model="store.winLeft.show" border size="small" :disabled="store.winAutoLayout">
{{ $t("uart.configPannel") }}
</el-checkbox>
<el-checkbox v-model="winDataView.show" border size="small" :disabled="store.winAutoLayout">
{{ $t('uart.displayPannel') }}
</el-checkbox>
<el-checkbox v-model="store.winRight.show" border size="small" :disabled="store.winAutoLayout">
{{ $t('uart.macroPannel') }}
</el-checkbox>
</div>
<template #reference>
<el-button class="min-h-full" type="primary" :size="layoutConf.isMedium ? 'small' : 'default'">
{{ $t('uart.layout') }}
</el-button>
</template>
</el-popover>
</div>
<div class="mx-1"></div>
</teleport>
</template>
<script setup lang="ts">
import {computed, onMounted, onUnmounted, reactive, type Ref, ref, type UnwrapRef, watch} from "vue";
import {breakpointsTailwind, useBreakpoints} from '@vueuse/core'
import {useDataViewerStore} from '@/stores/dataViewerStore';
import * as api from '@/api';
import {ControlEvent} from '@/api';
import {
type IUartMsgBaud,
type IUartMsgConfig,
type IUartMsgNum,
uart_get_baud,
uart_get_config,
uart_get_default_num,
WtUartCmd
} from '@/api/apiUart';
/* TODO: use https://antoniandre.github.io/splitpanes/ */
import { type ApiBinaryMsg } from '@/api/binDataDef'
import * as df from '@/api/apiDataFlow'
import textDataViewer from '@/views/text-data-viewer/textDataViewer.vue'
import textDataConfig from '@/views/text-data-viewer/textDataConfig.vue'
import { registerModule } from '@/router/msgRouter'
import { isDevMode } from '@/composables/buildMode'
import { useWsStore } from '@/stores/websocket'
import { useUartStore } from '@/stores/useUartStore'
import TextDataMacro from '@/views/text-data-viewer/textDataMacro.vue'
import { translate } from '@/locales'
import { useUartModule } from '@/composables/useUartModule'
const store = useDataViewerStore()
const wsStore = useWsStore()
const uartStore = useUartStore()
const firstWinResizeRef = ref(document.body);
const thirdWinResizeRef = ref(document.body);
const win1Ref = ref(document.body);
const win2Ref = ref(document.body);
const breakpoints = useBreakpoints(breakpointsTailwind)
const layoutConf = reactive({
isSmall: breakpoints.smaller("sm"),
isMedium: breakpoints.smaller("lg"),
});
const layoutOptions = computed(() => [{
label: translate("uart.landscape"),
value: 'row'
}, {
label: translate("uart.portrait"),
value: 'col'
}]);
interface WinProperty {
show: boolean;
width: string;
height: string;
borderSize: number;
}
const winDataView = reactive({
show: true,
})
const ctx = reactive({
curResizeTarget: "none",
curHeightOffset: 0,
});
function updateCursor(i: HTMLElement) {
if (store.winLayoutMode === 'row') {
i.style.cursor = "col-resize";
} else {
i.style.cursor = "row-resize";
}
}
function updateWin(r: Ref<HTMLElement>, p: UnwrapRef<WinProperty>) {
if (store.winLayoutMode === 'row') {
r.value.style.minHeight = "";
r.value.style.maxHeight = ""
if (winDataView.show) {
r.value.style.minWidth = p.width;
r.value.style.maxWidth = p.width;
}
} else {
r.value.style.minWidth = ""
r.value.style.maxWidth = ""
if (winDataView.show) {
r.value.style.minHeight = p.height;
r.value.style.maxHeight = p.height;
}
}
}
function updateCursors() {
updateCursor(firstWinResizeRef.value);
updateCursor(thirdWinResizeRef.value);
}
function updateResizer() {
updateCursors();
updateWin(win1Ref, store.winLeft);
updateWin(win2Ref, store.winRight);
}
function mouseResize(e: MouseEvent) {
const curTarget = e.target as HTMLElement
if (store.winLayoutMode === 'row') {
let f = e.clientX;
if (ctx.curResizeTarget === "first") {
win1Ref.value.style.minWidth = f + "px";
win1Ref.value.style.maxWidth = f + "px";
} else {
if (isDevMode()) {
console.log("Row clientX", e.clientX, "clientY", e.clientY,
"layerX", e.layerX, "layerY", e.layerY, "offsetX", e.offsetX, "offsetY", e.offsetY,
"pageX", e.pageX, "pageY", e.pageY, win2Ref.value.clientHeight);
}
win2Ref.value.style.minWidth = document.body.scrollWidth - f - store.winRight.borderSize + "px";
win2Ref.value.style.maxWidth = document.body.scrollWidth - f - store.winRight.borderSize + "px";
}
} else {
/* col mode */
let f = e.clientY;
if (ctx.curResizeTarget === "first") {
win1Ref.value.style.minHeight = f - ctx.curHeightOffset + "px";
win1Ref.value.style.maxHeight = f - ctx.curHeightOffset + "px";
} else {
if (isDevMode()) {
console.log("Col clientX", e.clientX, "clientY", e.clientY,
"layerX", e.layerX, "layerY", e.layerY, "offsetX", e.offsetX, "offsetY", e.offsetY,
"pageX", e.pageX, "pageY", e.pageY, curTarget.offsetWidth, ctx.curHeightOffset);
}
win2Ref.value.style.minHeight = ctx.curHeightOffset - f + "px";
win2Ref.value.style.maxHeight = ctx.curHeightOffset - f + "px";
}
}
}
function touchResize(e: TouchEvent) {
let t = e.touches[0];
let f: number;
if (store.winLayoutMode === 'row') {
f = t.clientX;
if (ctx.curResizeTarget === "first") {
win1Ref.value.style.minWidth = f + "px";
win1Ref.value.style.maxWidth = f + "px";
} else {
win2Ref.value.style.minWidth = document.body.scrollWidth - f - store.winRight.borderSize + "px";
win2Ref.value.style.maxWidth = document.body.scrollWidth - f - store.winRight.borderSize + "px";
}
} else {
/* column layout mode */
f = t.clientY;
if (ctx.curResizeTarget === "first") {
/* setting window */
win1Ref.value.style.minHeight = f - ctx.curHeightOffset + "px";
win1Ref.value.style.maxHeight = f - ctx.curHeightOffset + "px";
} else {
/* quick access window */
win2Ref.value.style.minHeight = document.body.scrollHeight - f - store.winRight.borderSize + "px";
win2Ref.value.style.maxHeight = document.body.scrollHeight - f - store.winRight.borderSize + "px";
}
}
}
function startResize(event: Event) {
// Normalize touch and mouse events
if (event.type.includes('touch')) {
ctx.curHeightOffset = (event as TouchEvent).touches[0].clientY;
} else {
ctx.curHeightOffset = (event as MouseEvent).clientY;
}
const divRef = event.target;
if (divRef === firstWinResizeRef.value) {
ctx.curResizeTarget = "first";
ctx.curHeightOffset -= win1Ref.value.clientHeight;
// ctx.curOffset = win1Ref.value.clientHeight;
} else if (divRef === thirdWinResizeRef.value) {
ctx.curResizeTarget = "third";
ctx.curHeightOffset += win2Ref.value.clientHeight;
}
win1Ref.value.style.transition = 'initial';
win2Ref.value.style.transition = 'initial';
document.addEventListener("mousemove", mouseResize, false);
document.addEventListener("touchmove", touchResize, false);
store.winAutoLayout = false;
}
function stopResize() {
if (win1Ref.value) {
win1Ref.value.style.transition = '';
if (store.winLayoutMode === "row") {
store.winLeft.width = win1Ref.value.style.minWidth;
} else {
store.winLeft.height = win1Ref.value.style.minHeight;
}
}
if (win2Ref.value) {
win2Ref.value.style.transition = '';
if (store.winLayoutMode === "row") {
store.winRight.width = win2Ref.value.style.minWidth;
} else {
store.winRight.height = win2Ref.value.style.minHeight;
}
}
document.body.style.cursor = '';
document.removeEventListener("mousemove", mouseResize, false);
document.removeEventListener("touchmove", touchResize, false);
}
watch(() => store.winLayoutMode, (value) => {
updateResizer();
if (value === "col") {
store.winAutoLayout = false;
}
});
watch([
() => layoutConf.isSmall,
() => store.winAutoLayout
], (value) => {
if (store.winAutoLayout) {
store.winRight.show = !value[0];
win1Ref.value.style.minWidth = "";
win1Ref.value.style.maxWidth = "";
}
}, {
immediate: true,
});
watch([
() => layoutConf.isMedium,
() => store.winAutoLayout
], (value) => {
if (store.winAutoLayout) {
store.winLeft.show = !value[0];
win1Ref.value.style.minWidth = "";
win1Ref.value.style.maxWidth = "";
win2Ref.value.style.minWidth = "";
win2Ref.value.style.maxWidth = "";
winDataView.show = true;
}
}, {
immediate: true
});
watch(() => winDataView.show, value => {
if (!value) {
win1Ref.value.style.minWidth = "";
win1Ref.value.style.maxWidth = "";
win1Ref.value.style.maxHeight = "";
win1Ref.value.style.maxHeight = "";
win2Ref.value.style.minWidth = "";
win2Ref.value.style.maxWidth = "";
win2Ref.value.style.maxHeight = "";
win2Ref.value.style.maxHeight = "";
}
});
watch(() => store.winRight.show, value => {
if (!value && !winDataView.show) {
win1Ref.value.style.maxHeight = "";
win1Ref.value.style.maxHeight = "";
win1Ref.value.style.maxWidth = "";
win1Ref.value.style.maxWidth = "";
}
});
const onUartJsonMsg = (msg: api.ApiJsonMsg) => {
switch (msg.cmd as WtUartCmd) {
case WtUartCmd.GET_BAUD:
case WtUartCmd.SET_BAUD:{
const uartMsg = msg as IUartMsgBaud;
if (uartMsg.baud) {
store.setUartBaud(uartMsg.baud)
}
break;
}
case WtUartCmd.GET_CONFIG:
case WtUartCmd.SET_CONFIG:{
const uartMsg = msg as IUartMsgConfig;
store.uartConfig.data_bits = uartMsg.data_bits;
store.uartConfig.stop_bits = uartMsg.stop_bits;
store.uartConfig.parity = uartMsg.parity;
break;
}
case WtUartCmd.GET_DEFAULT_NUM:
uartStore.uartNum = (msg as IUartMsgNum).num;
uart_get_baud(uartStore.uartNum);
uart_get_config(uartStore.uartNum);
break;
default:
if (isDevMode()) {
console.log("uart not treated", msg);
}
break
}
};
const onUartBinaryMsg = (msg: ApiBinaryMsg) => {
if (isDevMode()) {
console.log("uart", msg);
}
store.addSegment(new Uint8Array(msg.payload), true);
};
const onClientCtrl = (msg: api.ControlMsg) => {
if (msg.type !== api.ControlMsgType.WS_EVENT) {
return
}
if (msg.data === ControlEvent.DISCONNECTED) {
store.acceptIncomingData = false;
} else if (msg.data === ControlEvent.CONNECTED) {
updateUartData();
store.acceptIncomingData = true;
}
};
function updateUartData() {
/* TODO: hard code for the moment, 0 is UART instance id (can be changed in the future) */
uart_get_default_num();
df.wt_data_flow_attach_cur_to_sender(0);
}
function handleWinSizeRefresh() {
if (!store.winAutoLayout) {
if (win1Ref.value) {
if (store.winLayoutMode === "row") {
win1Ref.value.style.minWidth = store.winLeft.width;
} else {
win1Ref.value.style.minHeight = store.winLeft.height;
win1Ref.value.style.maxHeight = store.winRight.height;
}
}
if (win2Ref.value) {
if (store.winLayoutMode === "row") {
win2Ref.value.style.minWidth = store.winRight.width;
} else {
win2Ref.value.style.minHeight = store.winRight.height;
win2Ref.value.style.maxHeight = store.winRight.height;
}
}
}
}
onMounted(() => {
useUartModule()
firstWinResizeRef.value.style.borderWidth = store.winLeft.borderSize + "px";
thirdWinResizeRef.value.style.borderWidth = store.winRight.borderSize + "px";
updateCursors()
if (firstWinResizeRef.value) {
firstWinResizeRef.value.addEventListener("mousedown", startResize, false);
firstWinResizeRef.value.addEventListener("touchstart", startResize, false);
}
if (thirdWinResizeRef.value) {
thirdWinResizeRef.value.addEventListener("mousedown", startResize, false);
thirdWinResizeRef.value.addEventListener("touchstart", startResize, false);
}
document.addEventListener("mouseup", stopResize, false);
document.addEventListener("touchend", stopResize, false);
updateUartData();
store.acceptIncomingData = wsStore.state === ControlEvent.CONNECTED;
handleWinSizeRefresh()
});
onUnmounted(() => {
if (firstWinResizeRef.value) {
firstWinResizeRef.value.removeEventListener("mousedown", startResize, false);
firstWinResizeRef.value.removeEventListener("touchstart", startResize, false);
}
if (thirdWinResizeRef.value) {
thirdWinResizeRef.value.removeEventListener("mousedown", startResize, false);
thirdWinResizeRef.value.removeEventListener("touchstart", startResize, false);
}
document.removeEventListener("mouseup", stopResize, false);
document.removeEventListener("touchend", stopResize, false);
});
</script>
<template>
<div class="text-layout">
<h2 class="page-title opacity-10">尽请期待</h2>
</div>
</template>
<style scoped>
.button-m-0 :deep(.el-button + .el-button) {
margin-left: 0;
}
.custom-style .el-segmented {
--el-segmented-item-selected-color: var(--el-text-color-primary);
--el-segmented-item-selected-bg-color: var(--el-color-primary);
--el-border-radius-base: 16px;
}
.button-m-0 :deep(.el-checkbox) {
margin-right: 0;
}
</style>

108
src/views/Update.vue Normal file
View File

@ -0,0 +1,108 @@
<template>
<div class="text-layout description-style">
<h1 class="page-title">
固件更新
</h1>
<p class="text-center">(需联网)</p>
<el-divider></el-divider>
<el-descriptions title="当前版本" border :column="1">
<el-descriptions-item label="硬件版本">{{ sysStore.hwInfo.ver }}</el-descriptions-item>
<el-descriptions-item label="固件版本">{{ sysStore.curFmInfo.ver }}</el-descriptions-item>
<el-descriptions-item label="固件日期">{{ sysStore.curFmInfo.date }}</el-descriptions-item>
</el-descriptions>
<el-divider></el-divider>
<el-descriptions title="最新版本" border :column="1">
<template #extra>
<div class="flex">
<el-tooltip placement="top" effect="light">
<template #content>
<p>2秒延迟后重启</p>
</template>
<el-button @click="doReboot" type="warning" :disabled="sysStore.rebootInProgress">
重启{{ sysStore.rebootInProgress ? '中' : ''}}
</el-button>
</el-tooltip>
<el-button @click="doUpdate" type="primary" :disabled="!updateStore.canUpdate">
更新
</el-button>
</div>
</template>
<el-descriptions-item label="固件版本">{{ updateStore.newFmInfo.fm_ver }}</el-descriptions-item>
<el-descriptions-item label="更新日期">{{ updateStore.newFmInfo.upd_date }}</el-descriptions-item>
<el-descriptions-item label="固件大小">{{ updateStore.newFmInfo.fm_size }}</el-descriptions-item>
<el-descriptions-item label="更新进度">
<el-alert v-if="updateStore.updateStatus === 'OK'" title="更新已完成,重启后,刷新网页生效" type="success" show-icon :closable="false" />
<el-progress v-else :percentage="updateStore.updateProgress" :format="format" :status="updateStore.progressBarStatus"/>
</el-descriptions-item>
<el-descriptions-item label="更新内容">
<pre>{{ updateStore.newFmInfo.upd_note }}</pre>
</el-descriptions-item>
</el-descriptions>
<el-divider @click="showHidden = !showHidden">底部</el-divider>
<div v-if="showHidden">
<p>直链更新仅用于测试请勿使用</p>
<el-input placeholder="https://..." v-model="directLinkUpdate"></el-input>
<el-button type="primary" @click="doDirectLinkUpdate">更新</el-button>
</div>
</div>
</template>
<script setup lang="ts">
import {onMounted, onUnmounted, ref} from "vue";
import {
wt_ota_do_update, wt_ota_do_url_update,
wt_ota_get_progress, wt_ota_get_update_info,
} from "@/api/apiOTA";
import {useSystemStore} from "@/stores/useSystemStore";
import {wt_sys_reboot} from "@/api/apiSystem";
import {useUpdateStore} from "@/stores/useUpdateStore";
const sysStore = useSystemStore();
const updateStore = useUpdateStore();
const showHidden = ref(false)
const directLinkUpdate = ref("");
const format = (percentage: number) => (percentage.toFixed(2) + '%')
function doUpdate() {
wt_ota_do_update();
updateStore.setProgressInterval();
}
function doReboot() {
wt_sys_reboot();
}
function doDirectLinkUpdate() {
if (directLinkUpdate.value.length === 0) {
return;
}
updateStore.setProgressInterval();
wt_ota_do_url_update(directLinkUpdate.value);
}
onMounted(() => {
wt_ota_get_update_info();
wt_ota_get_progress();
});
onUnmounted(() => {
updateStore.clearProgressInterval();
});
</script>
<style scoped>
.description-style :deep(.el-descriptions__label) {
@apply w-32
}
</style>

345
src/views/WidgetPannel.vue Normal file
View File

@ -0,0 +1,345 @@
<template>
<div class="flex flex-col h-screen">
<div v-show="widgetStore.showOptions" class="flex h-32 overflow-y-auto m-2">
<div class="flex gap-4">
<div class="flex flex-col gap-2 border-r pr-4">
<el-checkbox v-model="widgetStore.editGrid" border class="w-full">{{
translate('widget.editGrid')
}}</el-checkbox>
<el-checkbox v-model="widgetStore.editCell" border class="w-full">{{
translate('widget.editCell')
}}</el-checkbox>
</div>
<div class="flex flex-col items-center">
<div
class="w-40 h-24 bg-gray-200 border-2 rounded-md p-2 flex flex-col justify-center items-center text-center cursor-move"
draggable="true"
@dragstart="dragStart('loop', $event)"
@drag="drag"
@dragend="dragEnd"
>
<span class="font-bold">{{ translate('widget.loopWidget') }}</span>
<p class="text-xs">{{ translate('widget.loopWidgetDesc') }}</p>
</div>
<el-button @click="widgetStore.addLoopWidget" size="small" class="w-full mt-1">{{
translate('widget.addGrid')
}}</el-button>
</div>
<div class="flex flex-col items-center">
<div
class="w-40 h-24 bg-gray-200 border-2 rounded-md p-2 flex flex-col justify-center items-center text-center"
:class="{
'cursor-move': !widgetStore.isUartViewAdded,
'cursor-not-allowed opacity-50': widgetStore.isUartViewAdded
}"
:draggable="!widgetStore.isUartViewAdded"
@dragstart="dragStart('uart', $event)"
@drag="drag"
@dragend="dragEnd"
>
<span class="font-bold">{{ translate('widget.dataViewer') }}</span>
<p class="text-xs">{{ translate('widget.dataViewerDesc') }}</p>
</div>
<el-button
@click="widgetStore.addUartViewWidget"
size="small"
class="w-full mt-1"
:disabled="widgetStore.isUartViewAdded"
>{{ translate('widget.addGrid') }}</el-button
>
</div>
<div class="flex flex-col items-start gap-2 border-l pl-4">
<div>
<el-button @click="widgetStore.exportSettings" size="small">{{
translate('widget.exportSettings')
}}</el-button>
</div>
<div>
<el-button @click="widgetStore.importSettings" size="small">{{
translate('widget.importSettings')
}}</el-button>
</div>
<div>
<el-button type="danger" @click="widgetStore.resetToDefault" size="small">{{
translate('widget.resetToDefault')
}}</el-button>
</div>
</div>
</div>
</div>
<div ref="gridWrapper" class="flex-1 flex flex-col w-full min-h-0">
<div class="flex-1 bg-gray-100 overflow-y-auto min-h-0">
<GridLayout
ref="gridlayout"
v-model:layout="widgetStore.layout"
:col-num="20"
:row-height="30"
:is-draggable="widgetStore.editGrid"
:is-resizable="widgetStore.editGrid"
:auto-size="false"
:compact-type="null"
:vertical-compact="false"
>
<grid-item
v-for="(item, index) in widgetStore.layout"
:key="item.i"
v-bind="item"
class="rounded-md flex flex-col text-xs p-1"
:class="{
'bg-blue-300': String(item.i) !== dropId,
'bg-gray-400 opacity-50': String(item.i) === dropId
}"
>
<template v-if="String(item.i) !== dropId">
<div class="flex justify-between pb-0.5">
<InlineSvg :name="getWidgetIconName(item)" width="20"></InlineSvg>
<el-button
v-show="widgetStore.editGrid"
type="danger"
size="small"
class="self-center px-1"
@click="widgetStore.deleteWidget(index)"
>
<InlineSvg name="close" width="20"></InlineSvg>
</el-button>
<div :id="`tp-widget-before-${item.i}`"></div>
<div v-if="widgetStore.editGrid" class="w-full">
<el-input
v-model="item.name"
size="small"
:placeholder="translate('widget.gridItemName')"
/>
</div>
<div
v-else-if="item.name"
class="truncate font-bold self-center text-center text-sm w-full"
>
{{ item.name }}
</div>
<p v-else></p>
<!-- empty space to align the check tag -->
<el-check-tag
v-if="widgetStore.editGrid"
:checked="item.static"
type="danger"
class="self-center px-1"
@click="item.static = !item.static"
>
<InlineSvg v-show="item.static" name="lock" width="20"></InlineSvg>
<InlineSvg v-show="!item.static" name="lock_open" width="20"></InlineSvg>
</el-check-tag>
<div v-show="!widgetStore.editGrid" :id="`tp-widget-${item.i}`"></div>
</div>
<div class="bg-white overflow-y-auto flex flex-col flex-grow">
<component
:is="item.widget"
v-model="widgetStore.layout[index]"
:editCell="widgetStore.editCell"
/>
</div>
</template>
<div v-else class="flex justify-center items-center h-full">
<p class="font-bold text-white">{{ translate('widget.dropHere') }}</p>
</div>
</grid-item>
</GridLayout>
</div>
</div>
</div>
<teleport to="#nav-right-slot">
<ElCheckTag
:checked="widgetStore.showOptions"
type="primary"
@click="widgetStore.showOptions = !widgetStore.showOptions"
>{{ translate('widget.editGrid') }}</ElCheckTag
>
</teleport>
</template>
<script setup lang="ts">
import { markRaw, ref, onMounted, onBeforeUnmount } from 'vue'
import { GridLayout, GridItem } from 'vue-grid-layout-v3'
import { ElInput, ElCheckbox, ElCheckTag, ElButton } from 'element-plus'
import UartAtCommand from './widgets/uartAtCommand.vue'
import WidgetLoop from './widgets/widgetLoop.vue'
import textDataViewer from '@/views/text-data-viewer/textDataViewer.vue'
import { useUartModule } from '@/composables/useUartModule'
import { globalNotify } from '@/composables/notification'
import { useWidgetStore } from '@/stores/useWidgetStore'
import type { WidgetItem } from '@/types/grid'
import { translate } from '@/locales'
const widgetStore = useWidgetStore()
const gridlayout = ref<InstanceType<typeof GridLayout> | null>(null)
const gridWrapper = ref<HTMLElement | null>(null)
const getWidgetIconName = (item: WidgetItem) => {
if (typeof item.widget === 'object' && item.widget !== null && 'widgetIconName' in item.widget) {
return (item.widget as any).widgetIconName
}
return 'default-icon' // or some other default
}
const throttle = (fn: Function, wait: number) => {
let inThrottle: boolean, lastFn: number, lastTime: number
return function (this: any, ...args: any[]) {
const context = this
if (!inThrottle) {
fn.apply(context, args)
lastTime = Date.now()
inThrottle = true
} else {
clearTimeout(lastFn)
lastFn = window.setTimeout(() => {
if (Date.now() - lastTime >= wait) {
fn.apply(context, args)
lastTime = Date.now()
}
}, Math.max(wait - (Date.now() - lastTime), 0))
}
}
}
const mouseAt = { x: -1, y: -1 }
function syncMousePosition(event: MouseEvent) {
mouseAt.x = event.clientX
mouseAt.y = event.clientY
}
onMounted(() => {
useUartModule()
document.addEventListener('dragover', syncMousePosition)
})
onBeforeUnmount(() => {
document.removeEventListener('dragover', syncMousePosition)
})
const dropId = 'drop-placeholder'
const dragging = ref<{ type: string; w: number; h: number } | null>(null)
function dragStart(itemType: string, event: DragEvent) {
if (itemType === 'loop') {
dragging.value = { type: 'loop', w: 10, h: 5 }
} else if (itemType === 'uart') {
dragging.value = { type: 'uart', w: 10, h: 10 }
}
event.dataTransfer?.setData('text/plain', itemType)
}
const drag = throttle(() => {
if (!dragging.value || !gridWrapper.value || !gridlayout.value) return
const parentRect = gridWrapper.value.getBoundingClientRect()
const mouseInGrid =
mouseAt.x > parentRect.left &&
mouseAt.x < parentRect.right &&
mouseAt.y > parentRect.top &&
mouseAt.y < parentRect.bottom
const placeholderIndex = widgetStore.layout.findIndex((item) => String(item.i) === dropId)
if (mouseInGrid) {
const gLayout = gridlayout.value as any
const colNum = 20
const rowHeight = 30
const margin = [10, 10]
const scrollContainer = gLayout.$el.parentElement
if (!scrollContainer) return
const colWidth =
(gLayout.$el.offsetWidth -
margin[0] * (colNum - 1) -
(gLayout.containerPadding?.[0] || margin[0]) * 2) /
colNum
const xInGrid = mouseAt.x - parentRect.left + scrollContainer.scrollLeft
const yInGrid = mouseAt.y - parentRect.top + scrollContainer.scrollTop
let gridX = Math.round(xInGrid / (colWidth + margin[0]) - dragging.value.w / 2)
let gridY = Math.round(yInGrid / (rowHeight + margin[1]) - dragging.value.h / 2)
gridX = Math.max(0, Math.min(gridX, colNum - dragging.value.w))
gridY = Math.max(0, gridY)
if (placeholderIndex === -1) {
widgetStore.layout.push({
x: gridX,
y: gridY,
w: dragging.value.w,
h: dragging.value.h,
i: dropId
} as any)
} else {
widgetStore.layout[placeholderIndex].x = gridX
widgetStore.layout[placeholderIndex].y = gridY
}
} else {
if (placeholderIndex !== -1) {
widgetStore.layout.splice(placeholderIndex, 1)
}
}
}, 50)
function dragEnd() {
if (!dragging.value) return
const parentRect = gridWrapper.value?.getBoundingClientRect()
const placeholderIndex = widgetStore.layout.findIndex((item) => String(item.i) === dropId)
if (placeholderIndex === -1) {
dragging.value = null
return
}
const placeholder = widgetStore.layout[placeholderIndex]
const mouseInGrid =
parentRect &&
mouseAt.x > parentRect.left &&
mouseAt.x < parentRect.right &&
mouseAt.y > parentRect.top &&
mouseAt.y < parentRect.bottom
if (mouseInGrid) {
if (dragging.value.type === 'uart') {
const uartViewExists = widgetStore.layout.some(
(item) => item.widget === textDataViewer && String(item.i) !== dropId
)
if (uartViewExists) {
globalNotify(translate('widget.uartViewOnce'), 'warning')
widgetStore.layout.splice(placeholderIndex, 1) // remove placeholder
dragging.value = null
return
}
}
const newWidget = {
x: placeholder.x,
y: placeholder.y,
w: dragging.value.w,
h: dragging.value.h,
i: widgetStore.getNextId(),
name: dragging.value.type === 'loop' ? 'New Loop Widget' : 'UART Data Viewer',
static: false,
widget: markRaw(dragging.value.type === 'loop' ? WidgetLoop : textDataViewer),
widgetProps: []
}
widgetStore.layout.splice(placeholderIndex, 1, newWidget as any)
} else {
// Not in grid, just remove placeholder
widgetStore.layout.splice(placeholderIndex, 1)
}
dragging.value = null
}
</script>
<style scoped>
:deep(.el-check-tag) {
padding: 0;
}
</style>

View File

@ -1,17 +1,17 @@
<template>
<div class="text-layout">
<h1 class="page-title">
Wi-Fi 配置
Wi-Fi {{ translate('wifi.settings') }}
</h1>
<el-divider></el-divider>
<h2 class="mb-4 text-xl font-bold tracking-tight md:text-2xl lg:text-3xl">连接Wi-Fi</h2>
<h2 class="mb-4 text-xl font-bold tracking-tight md:text-2xl lg:text-3xl">{{ translate('wifi.connection') }} Wi-Fi</h2>
<el-form label-width="auto" ref="formRef" :model="ssidValidateForm" class="m-auto">
<el-form-item
label="Wi-Fi"
label="Wi-Fi"
prop="wifiSsid"
:rules="[
{ required: true, message: '请输入WIFI名'},
{ required: true, message: translate('wifi.warnWifiName')},
]"
>
<div class="flex w-full">
@ -36,7 +36,7 @@
</div>
</div>
</el-form-item>
<el-form-item label="密码">
<el-form-item :label="translate('wifi.password')">
<el-input
v-model="ssidValidateForm.password"
show-password
@ -46,40 +46,71 @@
</el-form-item>
<div class="mb-2">
<el-alert type="info" show-icon>
如果不是通过透传器的热点连接更换Wi-Fi将导致此界面与透传器断开连接
{{ translate("wifi.connectInfoHTML")}}
</el-alert>
</div>
<div class="flex justify-center">
<el-button @click="onConnectClick" type="primary">连接</el-button>
<el-button @click="onConnectClick" type="primary">{{ translate('wifi.connect') }}</el-button>
</div>
</el-form>
<el-divider></el-divider>
<div class="flex items-center">
<h5 class="text-md font-bold text-gray-800 w-32">Wi-Fi {{ translate('wifi.mode') }}</h5>
<div class="flex shrink-0">
<el-tooltip effect="light">
<template #content>
<div v-html="translate('wifi.modeTipsHtml')"></div>
</template>
<InlineSvg name="help" class="w-3.5 h-3.5 text-gray-500 cursor-help"></InlineSvg>
</el-tooltip>
</div>
<el-select v-model="wifiMode" :disabled="wsStore.state != ControlEvent.CONNECTED">
<el-option
v-for="item in wifiModeOptions"
:key="item.key"
:value="item.key"
:label="item.label"
/>
</el-select>
<el-button type="primary" @click="wifiChangeMode" :loading="wifiMode_loading">{{ translate('wifi.save') }}</el-button>
</div>
<el-divider></el-divider>
<el-descriptions
title="Wi-Fi终端信息"
:column="1"
border
class="description-style"
>
<el-descriptions-item label="asd">
<template #label >
<div>
信号强度
</div>
<template #title>
Wi-Fi {{ translate('wifi.stationInfo') }}
<el-tag v-if="!isConnected" type="danger">{{ translate('wifi.disconnected') }}</el-tag>
</template>
<template #default >
{{ wifiStaApInfo.rssi }}
<template #extra>
<el-switch v-model="wifiSta_On" :disabled="!isConnected || !wifiAp_On"
:active-text="translate('wifi.enabled')" :inactive-text="translate('wifi.disabled')" :loading="wifiMode_loading"
:before-change="()=>beforeWifiModeChange('STA')"
/>
</template>
</el-descriptions-item>
<el-descriptions-item span="1">
<el-descriptions-item span="4">
<template #label>
<div>
SSID
{{ translate('wifi.signalStrength') }}
</div>
</template>
{{ wifiStaApInfo.ssid }}
<template #default>
<p> {{ wifi_rssi_to_percent(wifiStaApInfo.rssi) }} % ({{ wifiStaApInfo.rssi }} dBm)</p>
</template>
</el-descriptions-item>
<el-descriptions-item span="4">
<template #label>
<div>
Wi-Fi(SSID)
</div>
</template>
<p>{{ wifiStaApInfo.ssid }}</p>
</el-descriptions-item>
<!-- <el-descriptions-item span="6" >-->
<!-- <template #label>-->
@ -87,67 +118,164 @@
<!-- 密码-->
<!-- </div>-->
<!-- </template>-->
<!-- <password-viewer password="asdasdasd"></password-viewer>-->
<!-- <password-viewer :password="wifiStaApInfo.password"></password-viewer>-->
<!-- </el-descriptions-item>-->
<el-descriptions-item span="4">
<template #label>
<div>
IP
</div>
</template>
{{ wifiStaApInfo.ip }}
</el-descriptions-item>
<el-descriptions-item span="4">
<template #label>
<div>
MAC
</div>
</template>
{{ wifiStaApInfo.mac }}
<p>{{ wifiStaApInfo.mac }}</p>
</el-descriptions-item>
<el-descriptions-item span="4">
<template #label>
<div>IP({{ translate('wifi.internalAddress') }})</div>
</template>
<p>{{ wifiStaApInfo.ip }}</p>
</el-descriptions-item>
<el-descriptions-item span="4">
<template #label>
<div>
网关
{{ translate('wifi.gateway') }}
</div>
</template>
{{ wifiStaApInfo.gateway }}
<p>{{ wifiStaApInfo.gateway }}</p>
</el-descriptions-item>
<el-descriptions-item span="4">
<template #label>
<div>
{{ translate('wifi.netmask') }}
</div>
</template>
<p>{{ wifiStaApInfo.netmask }}</p>
</el-descriptions-item>
<el-descriptions-item span="4">
<template #label>
<div>
{{ translate('wifi.primaryDNS') }}
</div>
</template>
<p>{{ wifiStaApInfo.dns_main }}</p>
</el-descriptions-item>
<el-descriptions-item span="4">
<template #label>
<div>
{{ translate('wifi.backupDNS') }}
</div>
</template>
<p>{{ wifiStaApInfo.dns_backup }}</p>
</el-descriptions-item>
<el-descriptions-item span="4">
<template #label>
<div>
掩码
{{ translate('wifi.IPmode') }}
</div>
</template>
{{ wifiStaApInfo.netmask }}
<el-select v-model="wifiStaticInfo.static_ip_en" :disabled="!isConnected">
<el-option
v-for="item in staIPModeOptions"
:key="item.key"
:value="item.key"
:label="item.label"
/>
</el-select>
</el-descriptions-item>
<el-descriptions-item span="4" v-if="wifiStaticInfo.static_ip_en">
<template #label>
<div>IP({{ translate('wifi.internalAddress') }})</div>
</template>
<el-input v-model="wifiStaticInfo.ip"></el-input>
</el-descriptions-item>
<el-descriptions-item span="4" v-if="wifiStaticInfo.static_ip_en">
<template #label>
<div>
{{ translate('wifi.gateway') }}
</div>
</template>
<el-input v-model="wifiStaticInfo.gateway"></el-input>
</el-descriptions-item>
<el-descriptions-item span="4" v-if="wifiStaticInfo.static_ip_en">
<template #label>
<div>
{{ translate('wifi.netmask') }}
</div>
</template>
<el-input v-model="wifiStaticInfo.netmask"></el-input>
</el-descriptions-item>
<el-descriptions-item span="4">
<template #label>
<div>
{{ translate('wifi.DNSmode') }}
</div>
</template>
<el-select v-model="wifiStaticInfo.static_dns_en" :disabled="!isConnected">
<el-option
v-for="item in staDNSModeOptions"
:key="item.key"
:value="item.key"
:label="item.label"
/>
</el-select>
</el-descriptions-item>
<el-descriptions-item span="4" v-if="wifiStaticInfo.static_dns_en">
<template #label>
<div>
{{ translate('wifi.primaryDNS') }}
</div>
</template>
<el-input v-model="wifiStaticInfo.dns_main"></el-input>
</el-descriptions-item>
<el-descriptions-item span="4" v-if="wifiStaticInfo.static_dns_en">
<template #label>
<div>
{{ translate('wifi.backupDNS') }}
</div>
</template>
<el-input v-model="wifiStaticInfo.dns_backup"></el-input>
</el-descriptions-item>
</el-descriptions>
<div class="flex justify-center mt-4">
<el-button type="primary" :loading="wifiMode_loading" @click="wifiStaSetStaticInfo">{{ translate('wifi.save') }}</el-button>
</div>
<el-divider></el-divider>
<el-descriptions
title="Wi-Fi热点信息"
:column="1"
border
class="description-style"
>
<template #title>
Wi-Fi {{ translate('wifi.hotspotInfo') }}
<el-tag v-if="!isConnected" type="danger">{{ translate('wifi.disconnected') }}</el-tag>
</template>
<template #extra>
<el-switch v-model="wifiAp_On" :disabled="!isConnected || !wifiSta_On"
:loading="wifiMode_loading" :active-text="translate('wifi.enabled')" :inactive-text="translate('wifi.disabled')"
:before-change="()=>beforeWifiModeChange('AP')"
/>
</template>
<el-descriptions-item span="6">
<template #label>
<div>
SSID
Wi-Fi(SSID)
</div>
</template>
{{ wifiApInfo.ssid }}
<div class="flex">
<el-input v-model="wifiApInfo.ssid"></el-input>
</div>
</el-descriptions-item>
<el-descriptions-item span="6">
<template #label>
<div>
{{ translate('wifi.password') }}
</div>
</template>
<el-input v-model="wifiApInfo.password"></el-input>
</el-descriptions-item>
<!-- <el-descriptions-item span="6">-->
<!-- <template #label>-->
<!-- <div>-->
<!-- 密码-->
<!-- </div>-->
<!-- </template>-->
<!-- <password-viewer password="asdasdasd"></password-viewer>-->
<!-- </el-descriptions-item>-->
<el-descriptions-item span="4">
<template #label>
<div>
@ -169,7 +297,7 @@
<el-descriptions-item span="4">
<template #label>
<div>
网关
{{ translate('wifi.gateway') }}
</div>
</template>
{{ wifiApInfo.gateway }}
@ -178,67 +306,137 @@
<el-descriptions-item span="4">
<template #label>
<div>
掩码
{{ translate('wifi.netmask') }}
</div>
</template>
{{ wifiApInfo.netmask }}
</el-descriptions-item>
</el-descriptions>
<div class="flex justify-center mt-4">
<el-button type="primary" :loading="wifiMode_loading" @click="wifiApChangeCredential">{{ translate('wifi.save') }}</el-button>
</div>
<el-divider></el-divider>
</div>
</template>
<script setup lang="ts">
import {computed, onMounted, onUnmounted, reactive, ref} from "vue";
import {computed, type ComputedRef, onMounted, onUnmounted, reactive, ref} from "vue";
import {
wifi_sta_get_ap_info,
type IWifiMode,
wifi_ap_get_info,
wifi_ap_set_credential,
wifi_connect_to,
wifi_get_mode,
wifi_get_scan_list,
wifi_set_mode,
wifi_sta_get_ap_info,
WifiCmd,
type WifiInfo,
type WifiList,
wifi_ap_get_info, wifi_connect_to
WifiMode,
type WifiScanInfo,
type WiFiCredential,
WifiStatus, wifi_sta_get_static_info,
type IWifiStaStaticInfo, wifi_sta_set_static_conf,
} from "@/api/apiWifi";
import type {FormInstance} from "element-plus";
import InlineSvg from "@/components/InlineSvg.vue";
import type {ApiJsonMsg, ControlMsg, ServerMsg} from "@/api";
import type {ApiJsonMsg, ControlMsg} from "@/api";
import {ControlEvent, ControlMsgType, WtModuleID} from "@/api";
import {registerModule, unregisterModule} from "@/router/msgRouter";
import {useWsStore} from "@/stores/websocket";
import {globalNotify, globalNotifyRightSide} from "@/composables/notification";
import {isDevMode} from "@/composables/buildMode";
import {translate} from "@/locales";
const formRef = ref<FormInstance>()
let wifiListPlaceholder = ref("我的WIFI")
let wifiListPlaceholder = ref("MY-WIFI")
let ssidValidateForm = reactive({
wifiSsid: "",
password: "",
})
let wifiSta_On = ref(false);
const wifiMode_loading = ref(false)
let wifiAp_On = ref(false);
let wifiMode = ref(-1);
let wifiModeOptions = computed( () => [
{
label: translate('wifi.APauto_STA'),
key: WifiMode.WIFI_AP_AUTO_STA_ON,
}, {
label: translate('wifi.APonly'),
key: WifiMode.WIFI_AP_ON_STA_OFF,
}, {
label: translate('wifi.AP_STA'),
key: WifiMode.WIFI_AP_STA_ON,
}, /*
{
value: "仅开启终端STA",
key: 2,
},*/
])
let wsStore = useWsStore();
const defWifiInfo: WifiInfo = {
cmd: 1,
module: 1,
gateway: "未连接",
ip: "未连接",
mac: "未连接",
gateway: "-",
ip: "-",
mac: "-",
dns_main: "-",
dns_backup: "-",
rssi: 0,
netmask: "未连接",
ssid: "未连接",
netmask: "-",
ssid: "-",
password: "",
}
const staIPModeOptions = [
{
label: translate('wifi.autoIP'),
key: 0,
}, {
label: translate('wifi.staticIP'),
key: 1,
},
]
const staDNSModeOptions = [
{
label: translate('wifi.autoDNS'),
key: 0,
}, {
label: translate('wifi.staticDNS'),
key: 1,
},
]
const isConnected = computed(() => wsStore.state === ControlEvent.CONNECTED)
let wifiStaApInfo = reactive<WifiInfo>({...defWifiInfo});
let wifiApInfo = reactive<WifiInfo>({...defWifiInfo});
let wifiStaticInfo = reactive<IWifiStaStaticInfo>({
dns_backup: "0.0.0.0",
dns_main: "0.0.0.0",
gateway: "0.0.0.0",
ip: "0.0.0.0",
netmask: "0.0.0.0",
static_dns_en: 0,
static_ip_en: 0,
});
let scanning = ref(false);
let scan_cb: any;
let connectBtnClicked = 0;
let options: Array<WifiInfo> = [];
let options: Array<WifiScanInfo> = [];
const scanText = computed(() => {
return scanning.value ? "扫描中" : "扫描";
return scanning.value ? translate("wifi.scanning") : translate("wifi.scan");
});
const querySearch = (queryString: string, cb: any) => {
@ -262,7 +460,8 @@ const onClientMsg = (msg: ApiJsonMsg) => {
}
if (connectBtnClicked) {
connectBtnClicked = 0;
globalNotifyRightSide(wifiStaApInfo.ssid + " 连接成功", "success");
globalNotifyRightSide(wifiStaApInfo.ssid + " " + translate('wifi.connectionSuccess'), "success");
wifi_sta_get_static_info();
}
break;
}
@ -285,7 +484,7 @@ const onClientMsg = (msg: ApiJsonMsg) => {
scan_cb(options);
scan_cb = null;
}
globalNotifyRightSide("扫描完成", "success");
globalNotifyRightSide(translate('wifi.scanDone'), "success");
break;
}
case WifiCmd.WIFI_API_JSON_DISCONNECT:
@ -295,6 +494,60 @@ const onClientMsg = (msg: ApiJsonMsg) => {
Object.assign(wifiApInfo, info);
break;
}
case WifiCmd.WIFI_API_JSON_SET_MODE:
wifi_get_mode();
/* falls through */
case WifiCmd.WIFI_API_JSON_GET_MODE: {
const modeInfo = msg as IWifiMode;
wifiMode_loading.value = false;
if (modeInfo.err !== undefined) {
globalNotifyRightSide(translate('wifi.setFailed'), "error");
return;
}
if (modeInfo.status !== undefined) {
wifiAp_On.value = modeInfo.status === WifiStatus.WIFI_MODE_AP || modeInfo.status === WifiStatus.WIFI_MODE_APSTA;
wifiSta_On.value = modeInfo.status === WifiStatus.WIFI_MODE_STA || modeInfo.status === WifiStatus.WIFI_MODE_APSTA;
}
if (modeInfo.mode !== undefined) {
if (modeInfo.mode < WifiMode.WIFI_AP_STOP) {
wifiMode.value = modeInfo.mode;
} else if (modeInfo.mode === WifiMode.WIFI_AP_START) {
wifiAp_On.value = true;
} else if (modeInfo.mode === WifiMode.WIFI_AP_STOP) {
wifiAp_On.value = false;
} else if (modeInfo.mode === WifiMode.WIFI_STA_START) {
wifiSta_On.value = true;
} else if (modeInfo.mode === WifiMode.WIFI_STA_STOP) {
wifiSta_On.value = false;
}
}
break;
}
case WifiCmd.WIFI_API_JSON_AP_SET_CRED: {
const wifiCred = msg as WiFiCredential;
if (wifiCred.err !== undefined) {
globalNotifyRightSide(wifiCred.err, "error");
} else {
globalNotifyRightSide(translate('wifi.setSuccess'), "success");
}
wifiMode_loading.value = false;
break;
}
case WifiCmd.WIFI_API_JSON_STA_GET_STATIC_INFO: {
const staticInfo = msg as IWifiStaStaticInfo & ApiJsonMsg;
Object.assign(wifiStaticInfo, staticInfo);
break;
}
case WifiCmd.WIFI_API_JSON_STA_SET_STATIC_CONF:
wifiMode_loading.value = false;
break;
default:
if (isDevMode()) {
console.log(msg);
}
break;
}
};
@ -311,12 +564,14 @@ const onClientCtrl = (msg: ControlMsg) => {
if (msg.data === ControlEvent.CONNECTED) {
wifi_sta_get_ap_info();
wifi_ap_get_info();
wifi_get_mode();
wifi_sta_get_static_info();
}
};
function onScanClick() {
if (wsStore.state !== ControlEvent.CONNECTED) {
globalNotify("调试器未连接", 'error');
if (!isConnected.value) {
globalNotify(translate('wifi.debuggerNotConnected'), 'error');
return;
}
scanning.value = true;
@ -324,8 +579,8 @@ function onScanClick() {
}
function onConnectClick() {
if (wsStore.state !== ControlEvent.CONNECTED) {
globalNotify("调试器未连接", 'error');
if (!isConnected.value) {
globalNotify(translate('wifi.debuggerNotConnected'), 'error');
return;
}
if (ssidValidateForm.wifiSsid !== "") {
@ -334,6 +589,49 @@ function onConnectClick() {
}
}
function beforeWifiModeChange(ap_sta: "AP" | "STA" = "AP") {
if (ap_sta === "AP") {
wifiMode_loading.value = true;
wifi_set_mode(wifiAp_On.value ? WifiMode.WIFI_AP_STOP : WifiMode.WIFI_AP_START);
} else {
wifiMode_loading.value = true;
wifi_set_mode(wifiSta_On.value ? WifiMode.WIFI_STA_STOP : WifiMode.WIFI_STA_START);
}
wifi_sta_get_ap_info();
return false;
}
function wifiChangeMode() {
wifiMode_loading.value = true;
wifi_set_mode(wifiMode.value);
}
function wifi_rssi_to_percent(rssi: number)
{
if (rssi <= -100) {
return 0;
} else if (rssi >= -50) {
return 100;
} else {
return 2 * (rssi + 100);
}
}
function wifiApChangeCredential() {
if (wifiApInfo.ssid === "") {
globalNotifyRightSide(translate('wifi.enterAPName'), "error");
return;
}
wifiMode_loading.value = true;
wifi_ap_set_credential(wifiApInfo.ssid, wifiApInfo.password);
}
function wifiStaSetStaticInfo() {
wifiMode_loading.value = true;
wifi_sta_set_static_conf(wifiStaticInfo);
wifi_sta_get_ap_info();
}
onMounted(() => {
registerModule(WtModuleID.WIFI, {
ctrlCallback: onClientCtrl,
@ -342,17 +640,18 @@ onMounted(() => {
});
wifi_sta_get_ap_info();
wifi_ap_get_info();
wifi_get_mode();
wifi_sta_get_static_info();
});
onUnmounted(() => {
unregisterModule(WtModuleID.WIFI);
});
</script>
<style scoped>
<style scoped lang="postcss">
.description-style :deep(.el-descriptions__label) {
@apply w-32
}

View File

@ -1,26 +1,22 @@
<template>
<nav class="relative px-2 py-0.5 sm:py-1 flex justify-between items-center border-b h-full">
<div class="flex">
<button @click.prevent="sideMenuOpen=true" class="flex items-center hover:text-blue-600 pl-1 mx-4">
<button @click.prevent="sideMenuOpen=true" class="flex items-center hover:text-blue-600 pl-1 mx-2 sm:mx-4">
<svg class="block h-3 lg:h-4 lg:w-4 fill-current" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<title>导航侧栏</title>
<title>{{ translate('navbar.navigationSidebar') }}</title>
<path d="M0 3h20v2H0V3zm0 6h20v2H0V9zm0 6h20v2H0v-2z"></path>
</svg>
<el-badge v-if="updateStore.canUpdate" is-dot></el-badge>
</button>
<router-link to="/" class="text-3xl px-4 font-bold leading-none hidden items-center sm:flex" title="走,去码头整点薯条">
<router-link to="/" class="text-3xl px-4 font-bold leading-none hidden items-center sm:flex" :title="translate('navbar.getSomeFries')">
<InlineSvg name="favicon" class="h-5 lg:h-8"></InlineSvg>
</router-link>
<div id="nav-right-slot"></div>
<!-- <a class="text-3xl px-4 font-bold leading-none" href="/">-->
<!-- <InlineSvg name="home" class="h-10"></InlineSvg>-->
<!-- </a>-->
<!-- <router-link to="/" class="flex items-center text-sm text-blue-600 font-bold">主页</router-link>-->
<!-- <a class="flex items-center text-sm text-blue-600 font-bold" href="/">主页6</a>-->
<div class="flex pt-0.5 sm:pt-1 ml-4 text-sm items-center sm:hidden">
<router-link :to="route.fullPath">{{ route.meta.title }}</router-link>
<div class="flex pt-0.5 sm:pt-1 ml-4 text-xs items-center sm:hidden">
<router-link :to="route.fullPath">{{ $route.meta.title }}</router-link>
</div>
</div>
@ -35,6 +31,20 @@
<!-- <a class="md:ml-auto md:mr-3"></a>-->
<div class="flex h-full">
<div id="page-spec-slot" class="content-center h-full flex flex-row"></div>
<div class="mr-2">
<el-select v-model="language" class="min-w-20 h-full" @change="handleLanguageChange">
<el-option value="en">🇺🇸 English</el-option>
<el-option value="zh">🇨🇳 简体中文</el-option>
<el-option value="fr">🇫🇷 Français</el-option>
<template #label>
<div class="flex">
<InlineSvg name="translate" class="w-4 mr-1"></InlineSvg>
{{ languageFlag }}
</div>
</template>
</el-select>
</div>
<div class="lg:hidden">
<el-button :type="wsColor" size="small" class="transition duration-1000 min-h-full">
<InlineSvg v-show="wsColor!=='success'" name="link-off" class="mr-2" width="20"></InlineSvg>
@ -67,8 +77,11 @@
<div class="flex flex-col justify-between m-4 mt-0">
<ul>
<li v-for="(item, index) in menuItems" class="mb-1" :key="index">
<router-link @click="sideMenuOpen=false" :title="item.name" :to="item.href" :class="[sideMenuItemClass, item?.class]">{{ item.name }}</router-link>
<li v-for="(item, index) in sideBarItems" class="mb-1" :key="index">
<router-link @click="sideMenuOpen=false" :title="item.name" :to="item.href" :class="[sideMenuItemClass, item?.class]">
{{ item.name }}
<el-badge v-if="item?.badge?.value" is-dot></el-badge>
</router-link>
</li>
</ul>
</div>
@ -77,9 +90,9 @@
<div>
<el-button @click="toggle">
<InlineSvg v-if="!isFullscreen" name="open-in-full" width="16px" fill="#000000"></InlineSvg>
<p v-if="!isFullscreen">全屏</p>
<p v-if="!isFullscreen">{{ translate('page.fullscreen') }}</p>
<InlineSvg v-if="isFullscreen" name="close-fullscreen" width="16px" fill="#000000"></InlineSvg>
<p v-if="isFullscreen">缩小</p>
<p v-if="isFullscreen">{{ translate('page.windowed') }}</p>
</el-button>
</div>
</template>
@ -105,18 +118,31 @@
<script lang="ts" setup>
import InlineSvg from "@/components/InlineSvg.vue";
import {computed, ref} from "vue";
import {computed, type ComputedRef, type Ref, ref} from "vue";
import {useWsStore} from "@/stores/websocket";
import {translate} from "@/locales";
import {ControlEvent} from "@/api";
import {useRoute} from "vue-router";
import { useFullscreen } from '@vueuse/core'
import {useUpdateStore} from "@/stores/useUpdateStore";
import {isOTAEnabled} from "@/composables/buildMode";
import {getFlagFromLang, locale, setLang} from "@/i18n"
const wsStore = useWsStore();
const updateStore = useUpdateStore();
const {isFullscreen, toggle} = useFullscreen();
const route = useRoute();
const language = ref(locale);
const sideMenuItemClass = "block p-4 text-sm font-semibold hover:bg-blue-50 hover:text-blue-600 rounded"
const languageFlag = computed(() => {
return getFlagFromLang(language.value);
});
function handleLanguageChange(lang: string) {
setLang(lang);
}
const sideMenuItemClass = "block p-4 text-sm font-semibold hover:bg-blue-50 hover:text-blue-600 rounded flex"
const sideMenuOpen = ref(false);
const stateMenuOpen = ref(false)
@ -137,22 +163,40 @@ const wsColor = computed(() => {
});
const wsState = computed(() => {
return translate(wsStore.state);
return translate(wsStore.state.toLocaleLowerCase());
});
type Item = {
name: string;
href: string;
class?: string;
badge?: Ref<boolean>;
};
const menuItems: Item[] = ([
/* {
name: translate("page.home"),
href: "/",
}, */{
const menuItems: ComputedRef<Item[]> = computed(() => ([
{
name: translate("page.uart"),
href: "/uart",
}, {
name: translate("page.wifi"),
href: "/wifi",
}, {
name: translate("page.feedback"),
href: "/feedback",
},
]));
const sideBarItems: ComputedRef<Item[]> = computed(() => {
const items: Item[] = [
{
name: translate("page.wifi"),
href: "/wifi",
}, {
name: translate("page.uart"),
href: "/uart",
}, {
name: translate("page.widget"),
href: "/widget",
}, {
name: translate("page.about"),
href: "/about",
@ -160,7 +204,18 @@ const menuItems: Item[] = ([
name: translate("page.feedback"),
href: "/feedback",
},
]);
];
if (isOTAEnabled()) {
items.push({
name: translate("page.update"),
href: "/update",
badge: computed(() => updateStore.canUpdate),
})
}
return items;
});
</script>
@ -188,5 +243,9 @@ const menuItems: Item[] = ([
padding: 0;
}
.el-select :deep(.el-select__wrapper) {
@apply h-full;
}
</style>

View File

@ -0,0 +1,457 @@
<template>
<div>
<el-tabs v-model="store.configPanelTab" class="mx-2 custom-tabs fit">
<el-tab-pane name="first" class="min-h-80">
<template #label>{{ $t("uart.port") }}</template>
<div class="flex flex-col gap-2">
<el-form :size="store.winLeft.show ? '' : 'small'" label-position="left" label-width="auto">
<el-form-item
class="mb-2"
>
<template #label>{{ $t("uart.baudrate") }}</template>
<div class="flex w-full">
<el-select v-model="store.uartBaud" :teleported="false" @change="onUartBaudChange">
<template #header>
<div class="overflow-auto max-h-40">
<div class="flex gap-0">
<el-input-number
v-model="uartCustomBaud"
:placeholder="translate('uart.customBaud')"
size="small"
:controls="false"
:min="110"
class="flex-grow"
></el-input-number>
<el-button size="small" @click="onUseCustomUartBaud">{{ $t('uart.use') }}</el-button>
<!-- <el-button size="small" @click="onConfirm" class="ml-0">增加</el-button>-->
</div>
<el-option-group :label="translate('uart.commonlyUsed')">
<el-option
v-for="item in store.predefinedUartBaudFrequent"
:key="item.baud"
:value="item.baud"
class="border-b list-none"
/>
</el-option-group>
<el-option-group :label="translate('uart.other')">
<el-option
v-for="item in store.uartBaudList"
:key="item.baud"
:value="item.baud"
class="border-b list-none"
/>
</el-option-group>
</div>
</template>
</el-select>
</div>
</el-form-item>
<p class="text-xs">{{ $t('uart.actual') }} {{ $t('uart.baudrate') }}:{{ store.uartBaudReal }}</p>
<el-form-item :label="translate('uart.dataBits')" class="mb-2">
<el-select v-model="store.uartConfig.data_bits" :teleported="false"
placeholder="Select" @change="onUartConfigChange">
<el-option
v-for="item in uartDataBitsOptions"
:key="item.key"
:value="item.key"
:label="item.label"
/>
</el-select>
</el-form-item>
<el-form-item :label="translate('uart.parity')" class="mb-2">
<el-select v-model="store.uartConfig.parity" :teleported="false"
placeholder="Select" @change="onUartConfigChange">
<el-option
v-for="item in uartParityOptions"
:key="item.key"
:value="item.key"
:label="item.label"
/>
</el-select>
</el-form-item>
<el-form-item :label="translate('uart.stopBits')">
<el-select v-model="store.uartConfig.stop_bits" :teleported="false"
placeholder="Select" @change="onUartConfigChange">
<el-option
v-for="item in uartStopBitsOptions"
:key="item.key"
:value="item.key"
:label="item.label"
/>
</el-select>
</el-form-item>
<div class="flex justify-center">
<el-button :type="store.acceptIncomingData ? 'danger': 'success'"
:disabled="wsStore.state !== ControlEvent.CONNECTED"
@click="store.acceptIncomingData = !store.acceptIncomingData"
>
{{ store.acceptIncomingData ? $t("uart.stopCommunication") : $t("uart.startCommunication") }}
</el-button>
</div>
</el-form>
</div>
</el-tab-pane>
<!-- ///////////////////////////////////////////////////////////////// -->
<el-tab-pane name="second">
<template #label>{{ $t("uart.displayPannel") }}</template>
<div class="flex flex-col">
<el-collapse v-model="collapseActiveName">
<el-collapse-item name="1">
<template #title>
{{ $t('uart.displayOptions') }}
</template>
<template #default>
<div class="flex flex-col gap-2">
<div class="flex flex-col">
<el-checkbox border v-model="store.showText" :label="translate('uart.text')"/>
</div>
<div class="flex flex-col">
<el-checkbox border v-model="store.showHex" label="HEX"/>
</div>
<div class="flex flex-col">
<el-checkbox border v-model="store.showHexdump" label="HEXDUMP"/>
</div>
<div class="flex flex-col">
<el-checkbox border v-model="store.showTimestamp" :label="translate('uart.timestamp')"/>
</div>
<div class="flex flex-col">
<el-checkbox border v-model="store.enableLineWrap" :label="translate('uart.lineWrap')"/>
</div>
<el-tag type="success">
<el-text type="success">RX HEXDUMP {{ $t("uart.highlight") }}</el-text>
<el-color-picker v-model="store.RxHexdumpColor" show-alpha :predefine="store.predefineColors"
size="small"/>
</el-tag>
<el-tag type="primary">
<el-text type="primary">TX HEXDUMP {{ $t("uart.highlight") }}</el-text>
<el-color-picker v-model="store.TxHexdumpColor" show-alpha :predefine="store.predefineColors"
size="small"/>
</el-tag>
</div>
</template>
</el-collapse-item>
<el-collapse-item name="2" :title="translate('uart.frameBreakStrategy')">
<VueDraggable v-model="store.frameBreakRules" target="tbody" handle=".sort-target"
:animation="150"
:on-move="checkMove">
<table class="w-full bg-white">
<thead>
<tr class="text-sm h-7">
<th>{{ $t('uart.priority') }}</th>
<th>
<div class="flex justify-center">
{{ translate('uart.rule' as TranslationKeys) }}
<el-tooltip placement="top" effect="light">
<template #content>
<div v-html="translate('uart.ruleTips')"></div>
</template>
<InlineSvg name="help" class="w-4 text-gray-500 cursor-help"></InlineSvg>
</el-tooltip>
</div>
</th>
<th>{{ translate('uart.value' as TranslationKeys) }}</th>
</tr>
</thead>
<tbody class="text-xs text-center">
<tr v-for="(item, index) in store.frameBreakRules" :key="index">
<td :class="item.draggable ? 'sort-target' : ''">
{{ item.draggable ? index : 'NaN' }}
</td>
<td :class="item.draggable ? 'sort-target' : ''">
{{ translate("uart." + item.name) }}
</td>
<td>
<div v-if="item.type === 'number'">
<el-input-number v-if="item.name === 'timeout'" v-model="store.frameBreakDelay" :min="item.min || 0" size="small" style="width: 100px"/>
<el-input-number v-else v-model="store.frameBreakSize" :min="item.min || 0" size="small" style="width: 100px"/>
</div>
<div v-else>
<el-input class="break-input" v-model="store.frameBreakSequence" :placeholder="translate('uart.textAndEscape')" size="small"
style="width: 100px">
<template #prepend>
<el-button size="small" @click="store.frameBreakAfterSequence = false">
<span
:class="store.frameBreakAfterSequence ? 'text-gray-400' : 'text-blue-400 font-bold'">
{{ translate("uart.begin") }}
</span>
</el-button>
</template>
<template #append>
<el-button size="small" @click="store.frameBreakAfterSequence = true">
<span
:class="store.frameBreakAfterSequence ? 'text-blue-400 font-bold' : 'text-gray-300'">
{{ translate("uart.end") }}
</span>
</el-button>
</template>
</el-input>
</div>
</td>
</tr>
</tbody>
</table>
</VueDraggable>
</el-collapse-item>
<el-collapse-item name="3" :title="translate('uart.other')">
<template #default>
<div class="flex flex-col gap-2">
<el-tooltip
class="box-item"
effect="light"
placement="right-start"
>
<template #content>
<div v-html="translate('uart.ansiTooltips')"></div>
</template>
<el-checkbox border v-model="store.enableAnsiDecode">{{ translate('uart.decodeAnsiEscapeCodes') }}</el-checkbox>
</el-tooltip>
<el-input v-model="store.filterValue" :placeholder="translate('uart.textAndEscape')" clearable>
<template #prepend>
{{ translate("uart.filter") }}
</template>
</el-input>
<div class="border rounded flex flex-col">
<el-checkbox border v-model="store.dataFilterAutoUpdate">{{ translate('uart.autoUpdateNewData') }}</el-checkbox>
<el-tooltip :content="translate('uart.updateFrequencyTooltip')" placement="right" effect="light"
:show-after="500">
<div class="flex gap-4 p-2">
<el-text>{{ translate('uart.updateFrequency') }}</el-text>
<el-input-number
:step="10"
:min="10"
size="small"
v-model="store.batchUpdateTime"
>
</el-input-number>
</div>
</el-tooltip>
</div>
</div>
</template>
</el-collapse-item>
</el-collapse>
<!-- <div class="flex flex-col">-->
<!-- <el-text type="success">断帧设置</el-text>-->
<!-- <el-input v-model="store.frameBreakSequence" class="max-w-52">-->
<!-- <template #prepend>-->
<!-- 文本匹配断帧-->
<!-- </template>-->
<!-- </el-input>-->
<!-- <el-input v-model="store.frameBreakDelay" type="number" class="max-w-52">-->
<!-- <template #prepend>-->
<!-- 超时断帧-->
<!-- </template>-->
<!-- </el-input>-->
<!-- </div>-->
<!-- <div class="flex flex-col flex-wrap">-->
<!-- <el-button size="small">滚动到底</el-button>-->
<!-- <div>显示-->
<!-- <el-checkbox size="small" border>数据差异高亮</el-checkbox>-->
<!-- <el-checkbox size="small" border>TX高亮</el-checkbox>-->
<!-- <el-checkbox size="small" border>显示RX</el-checkbox>-->
<!-- <el-checkbox size="small" border>显示TX</el-checkbox>-->
<!-- <el-checkbox size="small" border>RX右对齐</el-checkbox>-->
<!-- </div>-->
<!-- &lt;!&ndash; <div>专有协议&ndash;&gt;-->
<!-- &lt;!&ndash; <el-button size="small">输入格式</el-button>&ndash;&gt;-->
<!-- &lt;!&ndash; <el-button size="small">输出格式</el-button>&ndash;&gt;-->
<!-- &lt;!&ndash; </div>&ndash;&gt;-->
<!-- </div>-->
</div>
</el-tab-pane>
<!-- ///////////////////////////////////////////////////////////// -->
<el-tab-pane :label="translate('uart.send')" name="third">
<template #label>{{ $t("uart.send") }}</template>
<div class="flex flex-col gap-2">
<el-input v-model="store.textPrefixValue" :placeholder="translate('uart.textAndEscape')" clearable>
<template #prepend>
{{ translate('uart.addHeader') }}
</template>
</el-input>
<el-input v-model="store.textSuffixValue" :placeholder="translate('uart.textAndEscape')" clearable>
<template #append>
{{ translate('uart.addFooter') }}
</template>
</el-input>
</div>
</el-tab-pane>
<el-tab-pane :label="translate('uart.proxy')" name="fourth" class="min-h-80">
<template #label>{{ $t("uart.passthrough") }}</template>
<div class="flex flex-col gap-2">
<div class="border rounded bg-white p-2">
<span class="border-r px-2">TCP {{ translate('uart.serverPort') }}</span>
<span class="px-2 cursor-not-allowed">1346</span>
</div>
<div>
<p><el-button @click="refreshTCPClientList" size="small" type="primary" :plain="true">{{ translate('uart.refresh') }}</el-button> {{ translate('uart.connectedClient') }}</p>
<el-table :data="dfStore.instanceList.filter((item) => (item.port_info as ISocketInfo).local_port === 1346)" :empty-text="translate('uart.noClientConnected')">
<el-table-column label="IP" prop="port_info.foreign_ip" />
<el-table-column :label="translate('uart.port')" prop="port_info.foreign_port"/>
</el-table>
</div>
</div>
</el-tab-pane>
</el-tabs>
</div>
</template>
<script setup lang="ts">
import {VueDraggable} from 'vue-draggable-plus'
import {computed, ref} from "vue";
import {useDataViewerStore} from "@/stores/dataViewerStore";
import {useWsStore} from "@/stores/websocket";
import {globalNotify} from "@/composables/notification";
import {ControlEvent} from "@/api";
import type {MoveEvent} from "sortablejs";
import InlineSvg from "@/components/InlineSvg.vue";
import {useDataFlowStore} from "@/stores/useDataFlowStore";
import {wt_data_flow_get_instance_list, type ISocketInfo} from "@/api/apiDataFlow";
import {uart_set_baud, uart_set_config} from "@/api/apiUart";
import {useUartStore} from "@/stores/useUartStore";
import {translate, type TranslationKeys} from "@/locales";
const store = useDataViewerStore()
const uartStore = useUartStore()
const wsStore = useWsStore()
const dfStore = useDataFlowStore()
const collapseActiveName = ref(["1", "2", "3"])
const uartCustomBaud = ref(1500000)
const uartDataBitsOptions = [
{
key: 5,
label: "5 bits",
}, {
key: 6,
label: "6 bits",
}, {
key: 7,
label: "7 bits",
}, {
key: 8,
label: "8 bits",
}
]
const uartParityOptions = computed(() => [
{
key: 0,
label: translate("uart.parityNone"),
}, {
key: 1,
label: translate("uart.parityOdd"),
}, {
key: 2,
label: translate("uart.parityEven"),
}
]);
const uartStopBitsOptions = [
{
key: 1,
label: "1",
}, {
key: 15,
label: "1.5",
}, {
key: 2,
label: "2",
}
]
const onUseCustomUartBaud = () => {
if (uartCustomBaud.value) {
store.uartBaud = uartCustomBaud.value;
onUartBaudChange();
} else {
globalNotify("波特率格式错误", "warning")
}
}
function onUartBaudChange() {
uart_set_baud(store.uartBaud, uartStore.uartNum);
}
function onUartConfigChange() {
uart_set_config(store.uartConfig, uartStore.uartNum);
}
function checkMove(event: MoveEvent) {
// Find index of related element
const toIndex: number = Array.from(event.to.children).indexOf(event.related);
return !!store.frameBreakRules[toIndex].draggable;
}
function refreshTCPClientList() {
dfStore.instanceList = [];
wt_data_flow_get_instance_list();
}
</script>
<style scoped>
.custom-tabs {
box-sizing: border-box;
}
.custom-tabs :deep(.el-tabs__item.is-top) {
padding: unset;
}
.custom-tabs :deep(.el-tabs__nav.is-top) {
@apply w-full flex justify-around
}
.custom-tabs :deep(.el-collapse-item__wrap) {
transition: all 0s; /* Customize the duration and easing */
}
.sortable-chosen {
background-color: var(--el-color-primary-light-9);
}
.sort-target {
cursor: move;
}
tr td {
@apply p-1;
}
.break-input :deep(.el-input-group__prepend), .break-input :deep(.el-input-group__append) {
background-color: unset;
@apply p-0 min-w-6
}
</style>

View File

@ -0,0 +1,182 @@
<template>
<div class="flex items-center mb-2 flex-wrap gap-2">
<el-button type="primary" @click="importSettings">{{ translate('uart.import') }}</el-button>
<el-button type="warning" @click="exportSettings">{{ translate('uart.export') }}</el-button>
<el-tooltip
effect="light"
placement="top"
:show-after="500"
>
<template #content>
<p>{{ translate('uart.resetTooltip') }}</p>
</template>
<el-button type="info" @click="resetSettings">{{ translate('uart.reset') }}</el-button>
</el-tooltip>
<el-tooltip
effect="light"
placement="top"
:show-after="500"
>
<template #content>
<p>{{ translate('uart.saveToLocalTooltip') }}</p>
</template>
<el-checkbox border v-model="store.autoSaveSettings">{{ translate('uart.saveToLocal') }}</el-checkbox>
</el-tooltip>
</div>
<div class="flex items-center mb-2 flex-wrap gap-2">
<el-button type="primary" @click="() => {
store.macroData.push({
value: '',
label: translate('uart.send'),
id: store.macroId,
})
store.macroId++;
}">{{ translate('uart.add') }}
</el-button>
<el-checkbox v-model="editMode" border>{{ translate('uart.edit') }}</el-checkbox>
<el-checkbox v-model="draggableEnabled" border>{{ translate('uart.drag') }}</el-checkbox>
</div>
<div>
<el-alert v-if="store.ipChangeAlert" @close="store.ipChangeAlert=false">{{ translate('uart.ipChangeAlert') }}</el-alert>
</div>
<VueDraggable v-model="store.macroData" handle=".sort-target"
:animation="150" class="break-input">
<div v-for="(item, index) in store.macroData" :key="item.id" class="w-full text-xs flex items-center py-0.5"
:class="editMode ? 'macroButtons' : ''">
<el-tag size="large" type="success" v-if="draggableEnabled" class="sort-target mr-1">
=
</el-tag>
<el-input v-model="item.value" class="font-mono">
<template #append>
<el-input v-if="editMode" v-model="item.label"></el-input>
<el-button v-else @click="onSendClick(item.value)" type="primary">{{ item.label }}</el-button>
</template>
</el-input>
<el-link :underline="false" @click="store.macroData.splice(index, 1);">
<el-tag size="large" type="danger" v-if="editMode" class="ml-1">
x
</el-tag>
</el-link>
</div>
</VueDraggable>
</template>
<script setup lang="ts">
import {VueDraggable} from "vue-draggable-plus";
import {onMounted, ref} from "vue";
import {globalNotify, globalNotifyRightSide} from "@/composables/notification";
import {useDataViewerStore} from "@/stores/dataViewerStore";
import {translate} from "@/locales";
const editMode = ref(false);
const draggableEnabled = ref(true);
const store = useDataViewerStore();
const emit = defineEmits(['winSizeRefresh'])
function onSendClick(val: string) {
if (!val && !store.hasAddedText) {
globalNotify("无帧头帧尾、发送框无数据发送")
return;
}
if (store.acceptIncomingData) {
store.addString(val, false, true);
} else {
store.addString(val, false, true, 1);
}
}
function importSettings() {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'application/json';
input.onchange = (event: Event) => {
const target = event.target as HTMLInputElement;
if (!target.files) return;
const file = target.files[0];
const reader = new FileReader();
reader.onload = (e: ProgressEvent<FileReader>) => {
const text = e.target?.result;
if (typeof text !== 'string') return;
try {
store.loadSettings(text);
emit('winSizeRefresh', '');
} catch (error) {
globalNotifyRightSide('导入失败', "error");
console.log("error", error);
}
};
reader.readAsText(file);
};
input.click();
}
function exportSettings() {
let obj = {
version: "v0.1.0",
/* Macro Window */
...store.settings
};
const dataStr = JSON.stringify(obj, null, 2);
const blob = new Blob([dataStr], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = "settingsBackup.json";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
function resetSettings() {
localStorage.clear();
}
onMounted(() => {
store.loadSettings();
});
</script>
<style scoped>
.sortable-chosen {
background-color: var(--el-color-primary-light-7);
}
.sort-target {
cursor: move;
}
.macroButtons :deep(.el-input-group__append) {
padding: 0;
}
.break-input :deep(.el-input-group__append) {
background-color: unset;
border-color: unset;
color: unset;
}
.break-input :deep(.el-input-group__append button.el-button) {
background-color: var(--el-color-primary-light-9);
border-color: var(--el-border-color);
color: unset;
border-radius: 0 5px 5px 0;
}
.break-input :deep(.el-input-group__append button.el-button):hover {
background-color: var(--el-color-primary-light-7);
}
</style>

View File

@ -0,0 +1,457 @@
<template>
<div class="flex min-h-7 overflow-auto">
<el-popover
placement="bottom"
trigger="click"
:hide-after="0"
transition="none"
width="300"
>
<div v-if="showDataConfig" class="h-[40vh] overflow-auto">
<text-data-config></text-data-config>
</div>
<template #reference>
<el-link v-show="showDataConfig" type="primary">
<InlineSvg name="arrow_drop_down" class="h-6 mb-1 px-2"></InlineSvg>
</el-link>
</template>
</el-popover>
<div class="flex">
<el-checkbox size="small" v-model="store.forceToBottom" :label="translate('uart.autoScrollToBottom')" border/>
<el-tooltip
class="box-item"
effect="light"
placement="top"
>
<template #content>
<p>{{ translate('uart.clearTooltip') }}</p>
</template>
<el-button size="small" @click="store.clearFilteredBuff">
<InlineSvg class="h-5" name="trash"></InlineSvg>
{{ $t('uart.clearScreen') }}
</el-button>
</el-tooltip>
<el-tooltip
class="box-item"
effect="light"
placement="top"
>
<template #content>
<p>{{ translate('uart.clearTooltip') }}</p>
</template>
<el-button size="small" @click="store.refreshFilteredBuff">
{{ $t('page.update') }}
</el-button>
</el-tooltip>
<el-tooltip
class="box-item"
effect="light"
placement="top"
>
<template #content>
<p>{{ translate('uart.autoUpdateTooltip') }}</p>
</template>
<el-checkbox size="small" border v-model="store.dataFilterAutoUpdate">
{{ $t('uart.autoUpdate') }}
</el-checkbox>
</el-tooltip>
</div>
</div>
<div class="flex flex-grow overflow-hidden border-2 rounded scroll-m-2">
<v-virtual-scroll
v-if="store.showVirtualScroll"
:items="store.dataFiltered"
id="myScrollerID"
ref="vuetifyVirtualScrollRef"
class="font-mono break-all text-sm"
:class="[store.enableLineWrap ? 'break-all' : 'text-nowrap']"
>
<template v-slot:default="{ item, }">
<div class="">
<div class="flex" :class="[store.enableLineWrap ? 'whitespace-pre-wrap' : 'whitespace-pre']">
<p class="text-nowrap text-sm text-lime-500" v-if="item.isRX" type="success" v-show="store.showTimestamp">
<span>{{ item.time }}</span>-RX|</p>
<p class="text-nowrap text-sm text-sky-500" v-else-if="item.type === 0" type="primary" v-show="store.showTimestamp">
<span>{{ item.time }}</span>TX-|</p>
<p class="text-nowrap text-sm text-amber-800" v-else type="primary" v-show="store.showTimestamp">
<span>{{ item.time }}</span>NS-|</p>
<p v-show="store.showText"
v-html="item.str"></p>
</div>
<div class="flex">
<p v-show="store.showHex" class="">{{ item.hex }}</p>
</div>
<div class="flex whitespace-pre">
<p v-show="store.showHexdump"
class="text-nowrap"
:style="{ 'background-color': item.isRX ? store.RxHexdumpColor : store.TxHexdumpColor }"
v-html="item.hexdump"
></p>
</div>
</div>
</template>
</v-virtual-scroll>
<v-virtual-scroll
v-else
:items="store.dataFiltered"
id="myScrollerID"
ref="vuetifyVirtualScrollRef2"
class="font-mono break-all text-sm"
:class="[store.enableLineWrap ? 'break-all' : 'text-nowrap']"
>
<template v-slot:default="{ item, }">
<div>
<div class="flex" :class="[store.enableLineWrap ? 'whitespace-pre-wrap' : 'whitespace-pre']">
<p class="text-nowrap text-sm text-lime-500" v-if="item.isRX" type="success" v-show="store.showTimestamp">
<span>{{ item.time }}</span>-RX|</p>
<p class="text-nowrap text-sm text-sky-500" v-else-if="item.type === 0" type="primary" v-show="store.showTimestamp">
<span>{{ item.time }}</span>TX-|</p>
<p class="text-nowrap text-sm text-amber-800" v-else type="primary" v-show="store.showTimestamp">
<span>{{ item.time }}</span>NS-|</p>
<p v-show="store.showText"
v-html="item.str"></p>
</div>
<div class="flex">
<p v-show="store.showHex" class="">{{ item.hex }}</p>
</div>
<div class="flex whitespace-pre">
<p v-show="store.showHexdump"
class="text-nowrap"
:style="{ 'background-color': item.isRX ? store.RxHexdumpColor : store.TxHexdumpColor }"
v-html="item.hexdump"
></p>
</div>
</div>
</template>
</v-virtual-scroll>
</div>
<div class="shrink-0 flex h-8 mt-0.5 text-xs">
<div class="flex shrink-0">
<el-tooltip :content="translate('uart.tempDisplayTooltip')" effect="light">
<InlineSvg name="help" class="w-3.5 h-3.5 text-gray-500 cursor-help"></InlineSvg>
</el-tooltip>
<p></p>
</div>
<div ref="RxHexDumpRef" class="p-0.5 border-2 rounded w-full overflow-y-scroll font-mono text-nowrap">
<p v-html="store.RxRemainHexdump"></p>
</div>
</div>
<div class="shrink-0 min-h-6 flex gap-2 justify-between overflow-auto">
<div class="flex gap-2">
<el-link @click="clearSendInput">
<el-tag class="font-mono" size="small">
<div class="flex ">
<InlineSvg class="h-5" name="trash"></InlineSvg>
<span class="content-end text-xs"></span>
</div>
</el-tag>
</el-link>
<el-tooltip :content="translate('uart.loopSendTooltip')" placement="right" effect="light" :show-after="1000">
<div class="flex align-center">
<el-checkbox v-model="store.enableLoopSend" class="font-mono font-bold max-h-5" size="small" border>
{{ translate('uart.loopSend') }}(ms)
</el-checkbox>
<el-input-number
v-model="store.loopSendFreq"
class="h-5"
size="small"
:step="10"
:min="1"
>
</el-input-number>
</div>
</el-tooltip>
<el-link @click="store.isSendTextFormat = !store.isSendTextFormat">
<el-tag class="font-mono font-bold" size="small">{{ translate('uart.sendFormat') }}{{ store.isSendTextFormat ? translate("uart.text") : "HEX" }}</el-tag>
</el-link>
</div>
<div class="flex gap-2">
<el-link @click="store.clearTxCounter()">
<el-tag class="font-mono font-bold" size="small">
{{ `TX(B):${store.TxByteCount}/ ${store.TxTotalByteCount}` }}
</el-tag>
</el-link>
<el-link type="success" @click="store.clearRxCounter()">
<el-tag class="font-mono font-bold" size="small" type="success">
{{ `RX(B):${store.RxByteCount}/ ${store.RxTotalByteCount}` }}
</el-tag>
</el-link>
<div class="flex align-center">
<el-tag class="font-mono font-bold" size="small" type="info">
<el-link class="flex" @click="store.clearDataBuff" type="warning">
<InlineSvg class="h-5" name="trash"></InlineSvg>
</el-link>
<span class="align-text-bottom">{{ translate('uart.cachedFrame') }}: {{ store.dataBufLength }}/30000</span>
</el-tag>
</div>
</div>
</div>
<div class="flex flex-row font-mono">
<el-input type="textarea" :autosize="{ minRows: 1, maxRows: 6}" v-model="store.uartInputTextBox" clearable
:placeholder="store.isSendTextFormat ?
translate('uart.textAndEscape') :
'HEX'"
@keydown="handleTextboxKeydown"
></el-input>
<el-tooltip content="Ctrl+Enter" placement="top" :auto-close="500">
<el-button type="primary"
@click="onSendClick">
{{ (store.isSendTextFormat || store.isHexStringValid) ? translate("uart.send") : translate("格式化") }}
</el-button>
</el-tooltip>
</div>
</template>
<script setup lang="ts">
import {nextTick, onMounted, onUnmounted, ref, watch} from "vue";
import {useDataViewerStore} from "@/stores/dataViewerStore";
import InlineSvg from "@/components/InlineSvg.vue";
import TextDataConfig from "@/views/text-data-viewer/textDataConfig.vue";
import {debouncedWatch} from "@vueuse/core";
import {globalNotify} from "@/composables/notification";
import {translate} from "@/locales";
import type { DraggableComponent } from '@/types/grid'
defineOptions({
name: 'TextDataViewer',
widgetIconName: 'text-data'
})
withDefaults(defineProps<{
editCell?: boolean
showDataConfig?: boolean
}>(), {
editCell: false,
showDataConfig: true,
})
const modelValue = defineModel<DraggableComponent>({ required: false })
const count = ref(0);
const vuetifyVirtualScrollBarRef = ref(document.body);
const vuetifyVirtualScrollContainerRef = ref(document.body);
const store = useDataViewerStore();
const RxHexDumpRef = ref(document.body);
let lastScrollHeight = 0;
const mutationObserver = new MutationObserver(() => {
if (store.forceToBottom) {
lastScrollHeight = vuetifyVirtualScrollBarRef.value.scrollTop;
scrollToBottom();
}
});
function attachScroll() {
const parent = document.getElementById('myScrollerID') || document.body;
// used to scroll to bottom
vuetifyVirtualScrollBarRef.value = parent || document.body;
// used to monitor height changes, so that one new Items are rendered, the height change -> scroll to bottom
vuetifyVirtualScrollContainerRef.value = parent.querySelector('.v-virtual-scroll__container') || document.body;
vuetifyVirtualScrollBarRef.value.onscroll = handleScroll;
if (vuetifyVirtualScrollContainerRef.value) {
const config = {childList: true, subtree: true, attributes: true};
mutationObserver.observe(vuetifyVirtualScrollBarRef.value, config)
}
if (store.forceToBottom) {
scrollToBottom();
}
}
onMounted(() => {
attachScroll();
})
onUnmounted(() => {
mutationObserver.disconnect();
if (!store.isHexStringValid) {
store.uartInputTextBox = formatHexInput(store.uartInputTextBox);
}
});
debouncedWatch(() => store.showVirtualScroll, () => {
lastScrollHeight = 0;
mutationObserver.disconnect();
attachScroll();
}, {debounce: 80});
function addItem(nr: number) {
let rawText = "";
let maxcount = count.value + nr;
for (; count.value < maxcount; count.value++) {
let text = "";
if ((count.value & 3) === 3) {
text = `<p class="border-4">${count.value}inputasdf<br/>${count.value}asdf <br/>${count.value}asdfasdf <br/>${count.value}asdf </p>`;
text += text;
} else if ((count.value & 2) === 2) {
text = `<p class="border-4">${count.value}inputas df2<br/>${count.value} asdf asd <br/>${count.value}fasdf asdf </p>`;
text += text;
} else if ((count.value & 1) === 1) {
text = `<p class="border-4">${count.value}inputas df2asdf asd <br/>${count.value}fasdf asdf ${count.value} </p>`;
text += text;
} else {
text = `<p class="border-4">${count.value}inputasa<br/>jdhfklasjdhfklasdhflasidfhilasdfhlasdiufhlasdkfhuasnlfcyerhfcibnkuaweghnfctiklaweuyrchnlaweirtucgnawertkcgyawertcnawelcrvnawgervcawencrgf${count.value} </p>`;
}
rawText = count.value + "<p class=\"border-4\"> 666666666b\n6666 666\x1b[33m6666666666666666666666666</p>b\n"
const encoder = new TextEncoder();
const arr = encoder.encode(rawText);
store.addItem(arr, false, false, 1);
}
}
function scrollToBottom() {
nextTick(() => {
const scrollerElement = vuetifyVirtualScrollBarRef.value; // Adjust according to your setup
scrollerElement.scrollTop = scrollerElement.scrollHeight;
});
}
function formatHexInput(input: string) {
// Split the input string on spaces to process each segment separately
let str;
// Remove any "0x" prefix and handle uppercase conversion
str = input.replace(/^0x/i, ' ').toUpperCase();
// Remove any non-hexadecimal characters
str = str.replace(/[^0-9A-F]/gi, ' ');
let segments = str.split(/\s+/);
let output: string[] = [];
segments.forEach(segment => {
// Check if segment length is odd and needs padding
if (segment.length % 2 !== 0) {
segment = '0' + segment; // Prepend '0' to make the length even
}
// Split segment into array of two-character chunks
let chunked = [];
for (let i = 0; i < segment.length; i += 2) {
chunked.push(segment.substring(i, i + 2));
}
// Concatenate chunked segments and add to output
output.push(chunked.join(' '));
});
// Join all processed segments with a space and return
return output.join(' ');
}
function checkHexTextValid() {
store.isHexStringValid = store.uartInputTextBox.toUpperCase() === formatHexInput(store.uartInputTextBox);
if (!store.isHexStringValid) {
store.enableLoopSend = false;
}
}
watch(() => store.isSendTextFormat, (value) => {
if (!value) {
checkHexTextValid()
}
});
watch(() => store.uartInputTextBox, () => {
if (!store.isSendTextFormat) {
checkHexTextValid()
}
})
/* patch scroll container does not update clear filter */
watch(() => store.filterChanged, (value) => {
if (value && store.forceToBottom) {
scrollToBottom();
}
store.filterChanged = false;
})
watch(() => store.RxRemainHexdump, value => {
if (value) {
RxHexDumpRef.value.scrollTop = RxHexDumpRef.value.scrollHeight;
}
})
watch(() => store.showVirtualScroll, () => {
if (store.forceToBottom) {
scrollToBottom();
}
});
const handleScroll = () => {
if (store.forceToBottom) {
if (vuetifyVirtualScrollBarRef.value.scrollTop - lastScrollHeight < 0) {
store.forceToBottom = false;
} else {
scrollToBottom();
}
} else if ((vuetifyVirtualScrollBarRef.value.scrollHeight -
vuetifyVirtualScrollBarRef.value.scrollTop) <= vuetifyVirtualScrollBarRef.value.clientHeight) {
store.forceToBottom = true;
}
lastScrollHeight = vuetifyVirtualScrollBarRef.value.scrollTop;
};
watch(() => store.forceToBottom, value => {
if (value) {
setTimeout(scrollToBottom, 0);
}
});
function clearSendInput() {
store.uartInputTextBox = ""
}
function handleTextboxKeydown(ev: KeyboardEvent) {
if (ev.ctrlKey && ev.key === 'Enter') {
onSendClick();
}
}
function onSendClick() {
if (!store.uartInputTextBox && !store.hasAddedText) {
globalNotify("无帧头帧尾、发送框无数据发送")
return;
}
if (store.acceptIncomingData) {
if (store.isSendTextFormat) {
store.addString(store.uartInputTextBox, false, true);
} else if (!store.isHexStringValid) {
store.uartInputTextBox = formatHexInput(store.uartInputTextBox);
} else {
store.addHexString(store.uartInputTextBox, false, true);
}
} else {
if (store.isSendTextFormat) {
store.addString(store.uartInputTextBox, false, true, 1);
} else if (!store.isHexStringValid) {
store.uartInputTextBox = formatHexInput(store.uartInputTextBox);
} else {
store.addHexString(store.uartInputTextBox, false, true, 1);
}
}
}
</script>

View File

@ -0,0 +1,69 @@
<template>
<div ref="rootEl" class="p-1 bg-white text-xs border-b-2">
<div v-if="props.editCell">
<div>
<div class="mb-1">
<el-input v-model="model.label" placeholder="Label" size="small">
<template #prepend>Label</template>
</el-input>
</div>
<div class="mb-1">
<el-input v-model="model.command" placeholder="UART Command" size="small">
<template #prepend>Command</template>
</el-input>
</div>
</div>
</div>
<div v-else-if="width < 250" class="">
<div class="truncate flex justify-between">
<el-text class="font-bold">{{ model.label }}</el-text>
<p class="ml-1 font-mono text-gray-500 text-[10px]">{{ model.command }}</p>
</div>
<div>
<strong>R:</strong>
<el-text class="ml-1 font-mono">{{ model.response }}</el-text>
</div>
</div>
<div v-else class="flex text-gray-700">
<div class="truncate min-h-8 w-32">
<p class="font-bold">{{ model.label }}</p>
<p class="font-mono text-gray-500 text-[10px]">{{ model.command }}</p>
</div>
<div class="pt-0.5">
<p class="font-mono text-sm">{{ model.response }}</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { defineModel, type PropType, ref } from 'vue'
import { ElInput, ElText } from 'element-plus'
import { useElementSize } from '@vueuse/core'
interface UartCommandData {
command: string
label: string
response: string
group?: string
}
const props = defineProps({
editCell: {
type: Boolean,
default: false
}
})
const model = defineModel<UartCommandData>({ required: true })
const rootEl = ref(null)
const { width } = useElementSize(rootEl)
</script>
<style scoped>
.el-input :deep(.el-input-group__prepend) {
padding: 0 5px;
font-size: 10px;
}
</style>

View File

@ -0,0 +1,233 @@
<script setup lang="ts">
import { VueDraggable } from 'vue-draggable-plus'
import type { DraggableComponent, WidgetItem } from '../../types/grid'
import { ElButton, ElIcon } from 'element-plus'
import { markRaw, ref, watch, onMounted, onUnmounted } from 'vue'
import { debouncedWatch } from '@vueuse/core'
import type { UartCommandData } from '@/types/grid'
import UartAtCommand from '@/views/widgets/uartAtCommand.vue'
import { useWsStore } from '@/stores/websocket'
import { globalNotify } from '@/composables/notification'
import { isDevMode } from '@/composables/buildMode'
import { useSequentialUart } from '@/composables/useSequentialUart'
import { useCommandLoopManager } from '@/composables/useCommandLoopManager'
import { translate } from '@/locales'
/* ---------------- props & model ----------------------------------- */
const modelValue = defineModel<WidgetItem>({ required: true })
const editCell = defineModel<boolean>('editCell', { required: true })
defineOptions({
name: 'WidgetLoop',
widgetIconName: 'repeat'
})
const intervalMS = ref(0)
const active = ref(false)
// Get command loop manager
const commandLoopManager = useCommandLoopManager()
const { sendCommands } = useSequentialUart()
const widgetId = ref(`widget-${modelValue.value.i}`)
// This watcher handles enabling or disabling the recurring task.
watch(active, (isActive) => {
if (isActive) {
// When activated, register the loop if the interval is valid.
const intervalValue = typeof intervalMS.value === 'string' ? parseInt(intervalMS.value, 10) : intervalMS.value;
if (intervalValue > 0) {
commandLoopManager.registerLoop(widgetId.value, intervalValue, runCommands);
}
} else {
// When deactivated, always unregister the loop.
commandLoopManager.unregisterLoop(widgetId.value);
}
});
// This watcher handles changes to the interval, but only if the loop is active.
debouncedWatch(intervalMS, (newInterval) => {
// If the loop isn't active, do nothing. The `active` watcher handles state.
if (!active.value) {
// If the interval is cleared while inactive, ensure it's unregistered.
if (!newInterval || newInterval <= 0) {
commandLoopManager.unregisterLoop(widgetId.value);
}
return;
}
const intervalValue = typeof newInterval === 'string' ? parseInt(newInterval, 10) : newInterval;
// The registerLoop function internally handles unregistering the old task.
// It will also handle unregistering if the new interval is invalid (e.g., 0).
commandLoopManager.registerLoop(widgetId.value, intervalValue, runCommands);
}, { debounce: 500 });
const handleAddItem = () => {
const newId =
Math.max(
0,
...modelValue.value.widgetProps.map((item) => item.id)
) + 1
const newItem: DraggableComponent<UartCommandData> = {
id: newId,
componentType: markRaw(UartAtCommand),
props: {
label: 'New Command',
command: 'AT+CMD',
response: ''
}
}
modelValue.value.widgetProps.push(newItem)
}
function deleteItem(id: number) {
const index = modelValue.value.widgetProps.findIndex((item) => item.id === id)
if (index !== -1) {
modelValue.value.widgetProps.splice(index, 1)
}
}
/* optional helper if you still need cloning */
function rawClone(item: DraggableComponent): DraggableComponent {
return { ...item } // already plain in parent
}
function ensureUniqueId(evt: any) {
const arr = modelValue.value.widgetProps
const moved = arr[evt.newIndex] // item that just arrived
const hasDuplicate = arr.filter(i => i.id === moved.id).length > 1
if (hasDuplicate) {
// e.g. give it the next free integer
const max = Math.max(...arr.map(i => i.id))
moved.id = max + 1
}
}
const executeOnce = () => {
// Register a one-time execution with the command loop manager
// Set as highest priority by using a very small interval (1ms)
commandLoopManager.registerLoop(
`${widgetId.value}-once-${Date.now()}`, // Unique ID
1, // 1ms interval (will be executed immediately)
runCommands,
true // oneTime = true
)
}
const runCommands = async () => {
if (useWsStore().state !== 'CONNECTED') {
globalNotify('Device not connected', 'error');
return
}
const commandsToRun = modelValue.value.widgetProps
if (!commandsToRun || commandsToRun.length === 0) return
// Extract command strings
const commandStrings = commandsToRun.map(cmd => cmd.props.command)
if (isDevMode()) {
console.log('Running commands:', commandStrings)
}
// Execute all commands at once
const responses = await sendCommands(commandStrings)
// Update responses in UI
commandsToRun.forEach((command, index) => {
if (index < responses.length) {
command.props.response = responses[index] || 'No response'
}
})
}
// Cleanup on unmount
onUnmounted(() => {
commandLoopManager.unregisterLoop(widgetId.value)
})
// Initialize on mount
onMounted(() => {
// On component mount, only register if it's explicitly set to active and has an interval.
if (active.value && intervalMS.value > 0) {
commandLoopManager.registerLoop(
widgetId.value,
intervalMS.value,
runCommands
)
}
})
</script>
<template>
<div class="flex flex-col h-full p-1">
<VueDraggable
v-model="modelValue.widgetProps"
item-key="id"
class="flex-1 min-h-0 overflow-y-auto"
group="people"
:clone="rawClone"
:animation="100"
direction="vertical"
handle=".drag-handle"
@add="ensureUniqueId"
>
<div v-for="row in modelValue.widgetProps" :key="row.id" class="flex flex-row items-center">
<el-tag v-if="editCell" size="large" type="success" class="drag-handle cursor-move">
=
</el-tag>
<component
:is="row.componentType"
v-model:modelValue="row.props"
:editCell="editCell"
class="flex-1"
/>
<el-button
v-if="editCell"
type="danger"
size="small"
@click="deleteItem(row.id)"
circle
>
<InlineSvg name="trash" width="20"></InlineSvg>
</el-button>
</div>
</VueDraggable>
<div v-if="editCell" class="bg-gray-50 flex gap-1">
<el-button type="primary" size="small" @click="handleAddItem">{{ translate('widget.addCommand') }}</el-button>
<div>
<el-popover
placement="top-start"
trigger="hover"
:show-after="1000"
:content="translate('widget.loopInterval')"
>
<template #reference>
<el-input
v-model="intervalMS"
size="small"
type="number"
:min="0"
:max="2147483647"
>
<template #prepend>
<InlineSvg name="repeat" width="20"></InlineSvg>
</template>
</el-input>
</template>
</el-popover>
</div>
</div>
</div>
<teleport defer :to="`#tp-widget-before-${modelValue.i}`">
<el-button plain size="small" @click="active = !active" :type="active ? 'success' : 'info'">
{{ intervalMS }}ms
</el-button>
</teleport>
<teleport defer :to="`#tp-widget-${modelValue.i}`">
<el-button text bg size="small" @click="executeOnce">
<InlineSvg name="play" width="20"></InlineSvg>
</el-button>
</teleport>
</template>

View File

@ -0,0 +1,37 @@
<!-- CommandWidget.vue -->
<script setup lang="ts">
import { VueDraggable } from 'vue-draggable-plus'
import type { DraggableComponent } from '../../types/grid'
/* ---------------- props & model ----------------------------------- */
const modelValue = defineModel<DraggableComponent[]>({ required: true })
defineProps<{
editCell: boolean
}>()
/* optional helper if you still need cloning */
function rawClone(item: DraggableComponent): DraggableComponent {
return { ...item } // already plain in parent
}
</script>
<template>
<VueDraggable
v-model="modelValue"
item-key="id"
class="h-full flex flex-col"
group="people"
:clone="rawClone"
:animation="100"
direction="vertical"
handle=".drag-handle"
>
<component
v-for="row in modelValue"
:key="row.id"
:is="row.componentType"
v-model:modelValue="row.props"
:is-editing-cell="editCell"
/>
</VueDraggable>
</template>

View File

@ -7,7 +7,7 @@ import vue from '@vitejs/plugin-vue'
import svgLoader from "vite-svg-loader";
import cssInjectedByJsPlugin from "vite-plugin-css-injected-by-js";
import { viteSingleFile } from 'vite-plugin-singlefile'
import vuetify from 'vite-plugin-vuetify'
// https://vitejs.dev/config/
export default ({mode}: ConfigEnv) => {
@ -25,6 +25,7 @@ export default ({mode}: ConfigEnv) => {
svgLoader(),
cssInjectedByJsPlugin(),
viteSingleFile(),
vuetify(),
],
define: {},
resolve: {