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
This commit is contained in:
kerms 2025-06-09 17:48:20 +02:00
parent 12b56e80d6
commit fc9648ea8b
31 changed files with 1951 additions and 195 deletions

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,18 +1,87 @@
# 允斯无线透传器的内嵌网页版上位机
# Web UI for wireless-esp32-tools
此项目使用`NPM`包管理, 需要先安装`node`工具。
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
1. `npm install`
2. `npm run dev`,或用 `npm run devh` 则可以用其他设备访问,如手机调试移动界面。
3. 根据显示的地址,使用浏览器打开,默认地址为`localhost:5173` 或者其他设备访问`192.168.X.X:5173`
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.
##### 发布至esp32
From this interface, you can build a customized dashboard to monitor and interact with your target device.
1. `npm install`
2. `npm run build` -> 会在`dist/`生成`index.html`和`ws.sharedworker.js`
3. 在`dist/`里执行`gzip *` -> -> 会在`dist/`生成`index.html.gz`和`ws.sharedworker.js.gz`
4. 至此可以使用这两个文件覆盖ESP32目录中的`project_components/html`里相对应的文件了。
### 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

Width:  |  Height:  |  Size: 103 KiB

BIN
assets/widgetPannel.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  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>

187
package-lock.json generated
View File

@ -13,7 +13,7 @@
"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",
@ -78,10 +78,29 @@
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz",
"integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.24.1",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.1.tgz",
"integrity": "sha512-Zo9c7N3xdOIQrNip7Lc9wvRPzlRtovHVE4lkz8WEDr7uYh/GMQhSiIgFxGIArRHYdJE5kxtZjAf8rT0xhdLCzg==",
"version": "7.27.5",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.5.tgz",
"integrity": "sha512-OsQd175SxWkGlzbny8J3K8TnnDD0N3lrIUtB92xwyRpzaenGZhxDvxN/JgU00U3CDZNj9tPuDJ5H0WS4Nt3vKg==",
"dependencies": {
"@babel/types": "^7.27.3"
},
"bin": {
"parser": "bin/babel-parser.js"
},
@ -89,6 +108,18 @@
"node": ">=6.0.0"
}
},
"node_modules/@babel/types": {
"version": "7.27.6",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.6.tgz",
"integrity": "sha512-ETyHEk2VHHvl9b9jZP5IHPavHYk57EhanlRRuae9XCpb/j5bDCbPPMOBfCWhnl/7EDJz0jEMCi/RhccCE8r1+Q==",
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
"@babel/helper-validator-identifier": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@ctrl/tinycolor": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz",
@ -499,9 +530,9 @@
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.4.15",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz",
"integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg=="
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz",
"integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.25",
@ -1102,49 +1133,49 @@
}
},
"node_modules/@vue/compiler-core": {
"version": "3.4.21",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.4.21.tgz",
"integrity": "sha512-MjXawxZf2SbZszLPYxaFCjxfibYrzr3eYbKxwpLR9EQN+oaziSu3qKVbwBERj1IFIB8OLUewxB5m/BFzi613og==",
"version": "3.5.16",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.16.tgz",
"integrity": "sha512-AOQS2eaQOaaZQoL1u+2rCJIKDruNXVBZSiUD3chnUrsoX5ZTQMaCvXlWNIfxBJuU15r1o7+mpo5223KVtIhAgQ==",
"dependencies": {
"@babel/parser": "^7.23.9",
"@vue/shared": "3.4.21",
"@babel/parser": "^7.27.2",
"@vue/shared": "3.5.16",
"entities": "^4.5.0",
"estree-walker": "^2.0.2",
"source-map-js": "^1.0.2"
"source-map-js": "^1.2.1"
}
},
"node_modules/@vue/compiler-dom": {
"version": "3.4.21",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.4.21.tgz",
"integrity": "sha512-IZC6FKowtT1sl0CR5DpXSiEB5ayw75oT2bma1BEhV7RRR1+cfwLrxc2Z8Zq/RGFzJ8w5r9QtCOvTjQgdn0IKmA==",
"version": "3.5.16",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.16.tgz",
"integrity": "sha512-SSJIhBr/teipXiXjmWOVWLnxjNGo65Oj/8wTEQz0nqwQeP75jWZ0n4sF24Zxoht1cuJoWopwj0J0exYwCJ0dCQ==",
"dependencies": {
"@vue/compiler-core": "3.4.21",
"@vue/shared": "3.4.21"
"@vue/compiler-core": "3.5.16",
"@vue/shared": "3.5.16"
}
},
"node_modules/@vue/compiler-sfc": {
"version": "3.4.21",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.4.21.tgz",
"integrity": "sha512-me7epoTxYlY+2CUM7hy9PCDdpMPfIwrOvAXud2Upk10g4YLv9UBW7kL798TvMeDhPthkZ0CONNrK2GoeI1ODiQ==",
"version": "3.5.16",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.16.tgz",
"integrity": "sha512-rQR6VSFNpiinDy/DVUE0vHoIDUF++6p910cgcZoaAUm3POxgNOOdS/xgoll3rNdKYTYPnnbARDCZOyZ+QSe6Pw==",
"dependencies": {
"@babel/parser": "^7.23.9",
"@vue/compiler-core": "3.4.21",
"@vue/compiler-dom": "3.4.21",
"@vue/compiler-ssr": "3.4.21",
"@vue/shared": "3.4.21",
"@babel/parser": "^7.27.2",
"@vue/compiler-core": "3.5.16",
"@vue/compiler-dom": "3.5.16",
"@vue/compiler-ssr": "3.5.16",
"@vue/shared": "3.5.16",
"estree-walker": "^2.0.2",
"magic-string": "^0.30.7",
"postcss": "^8.4.35",
"source-map-js": "^1.0.2"
"magic-string": "^0.30.17",
"postcss": "^8.5.3",
"source-map-js": "^1.2.1"
}
},
"node_modules/@vue/compiler-ssr": {
"version": "3.4.21",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.4.21.tgz",
"integrity": "sha512-M5+9nI2lPpAsgXOGQobnIueVqc9sisBFexh5yMIMRAPYLa7+5wEJs8iqOZc1WAa9WQbx9GR2twgznU8LTIiZ4Q==",
"version": "3.5.16",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.16.tgz",
"integrity": "sha512-d2V7kfxbdsjrDSGlJE7my1ZzCXViEcqN6w14DOsDrUCHEA6vbnVCpRFfrc4ryCP/lCKzX2eS1YtnLE/BuC9f/A==",
"dependencies": {
"@vue/compiler-dom": "3.4.21",
"@vue/shared": "3.4.21"
"@vue/compiler-dom": "3.5.16",
"@vue/shared": "3.5.16"
}
},
"node_modules/@vue/compiler-vue2": {
@ -1225,48 +1256,49 @@
}
},
"node_modules/@vue/reactivity": {
"version": "3.4.21",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.4.21.tgz",
"integrity": "sha512-UhenImdc0L0/4ahGCyEzc/pZNwVgcglGy9HVzJ1Bq2Mm9qXOpP8RyNTjookw/gOCUlXSEtuZ2fUg5nrHcoqJcw==",
"version": "3.5.16",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.16.tgz",
"integrity": "sha512-FG5Q5ee/kxhIm1p2bykPpPwqiUBV3kFySsHEQha5BJvjXdZTUfmya7wP7zC39dFuZAcf/PD5S4Lni55vGLMhvA==",
"dependencies": {
"@vue/shared": "3.4.21"
"@vue/shared": "3.5.16"
}
},
"node_modules/@vue/runtime-core": {
"version": "3.4.21",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.4.21.tgz",
"integrity": "sha512-pQthsuYzE1XcGZznTKn73G0s14eCJcjaLvp3/DKeYWoFacD9glJoqlNBxt3W2c5S40t6CCcpPf+jG01N3ULyrA==",
"version": "3.5.16",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.16.tgz",
"integrity": "sha512-bw5Ykq6+JFHYxrQa7Tjr+VSzw7Dj4ldR/udyBZbq73fCdJmyy5MPIFR9IX/M5Qs+TtTjuyUTCnmK3lWWwpAcFQ==",
"dependencies": {
"@vue/reactivity": "3.4.21",
"@vue/shared": "3.4.21"
"@vue/reactivity": "3.5.16",
"@vue/shared": "3.5.16"
}
},
"node_modules/@vue/runtime-dom": {
"version": "3.4.21",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.4.21.tgz",
"integrity": "sha512-gvf+C9cFpevsQxbkRBS1NpU8CqxKw0ebqMvLwcGQrNpx6gqRDodqKqA+A2VZZpQ9RpK2f9yfg8VbW/EpdFUOJw==",
"version": "3.5.16",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.16.tgz",
"integrity": "sha512-T1qqYJsG2xMGhImRUV9y/RseB9d0eCYZQ4CWca9ztCuiPj/XWNNN+lkNBuzVbia5z4/cgxdL28NoQCvC0Xcfww==",
"dependencies": {
"@vue/runtime-core": "3.4.21",
"@vue/shared": "3.4.21",
"@vue/reactivity": "3.5.16",
"@vue/runtime-core": "3.5.16",
"@vue/shared": "3.5.16",
"csstype": "^3.1.3"
}
},
"node_modules/@vue/server-renderer": {
"version": "3.4.21",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.4.21.tgz",
"integrity": "sha512-aV1gXyKSN6Rz+6kZ6kr5+Ll14YzmIbeuWe7ryJl5muJ4uwSwY/aStXTixx76TwkZFJLm1aAlA/HSWEJ4EyiMkg==",
"version": "3.5.16",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.16.tgz",
"integrity": "sha512-BrX0qLiv/WugguGsnQUJiYOE0Fe5mZTwi6b7X/ybGB0vfrPH9z0gD/Y6WOR1sGCgX4gc25L1RYS5eYQKDMoNIg==",
"dependencies": {
"@vue/compiler-ssr": "3.4.21",
"@vue/shared": "3.4.21"
"@vue/compiler-ssr": "3.5.16",
"@vue/shared": "3.5.16"
},
"peerDependencies": {
"vue": "3.4.21"
"vue": "3.5.16"
}
},
"node_modules/@vue/shared": {
"version": "3.4.21",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.4.21.tgz",
"integrity": "sha512-PuJe7vDIi6VYSinuEbUIQgMIRZGgM8e4R+G+/dQTk0X1NEdvgvvgv7m+rfmDH1gZzyA1OjjoWskvHlfRNfQf3g=="
"version": "3.5.16",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.16.tgz",
"integrity": "sha512-c/0fWy3Jw6Z8L9FmTyYfkpM5zklnqqa9+a6dz3DvONRKW2NEbh46BP0FHuLFSWi2TnQEtp91Z6zOWNrU6QiyPg=="
},
"node_modules/@vue/tsconfig": {
"version": "0.5.1",
@ -3328,14 +3360,11 @@
}
},
"node_modules/magic-string": {
"version": "0.30.8",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.8.tgz",
"integrity": "sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==",
"version": "0.30.17",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz",
"integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.4.15"
},
"engines": {
"node": ">=12"
"@jridgewell/sourcemap-codec": "^1.5.0"
}
},
"node_modules/mdn-data": {
@ -3445,9 +3474,9 @@
}
},
"node_modules/nanoid": {
"version": "3.3.7",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
"integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"funding": [
{
"type": "github",
@ -3936,9 +3965,9 @@
}
},
"node_modules/postcss": {
"version": "8.4.49",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz",
"integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==",
"version": "8.5.4",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.4.tgz",
"integrity": "sha512-QSa9EBe+uwlGTFmHsPKokv3B/oEMQZxfqW0QqNCyhpa6mB1afzulwn8hihglqAb2pOw+BJgNlmXQ8la2VeHB7w==",
"funding": [
{
"type": "opencollective",
@ -3954,7 +3983,7 @@
}
],
"dependencies": {
"nanoid": "^3.3.7",
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@ -5168,15 +5197,15 @@
"dev": true
},
"node_modules/vue": {
"version": "3.4.21",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.4.21.tgz",
"integrity": "sha512-5hjyV/jLEIKD/jYl4cavMcnzKwjMKohureP8ejn3hhEjwhWIhWeuzL2kJAjzl/WyVsgPY56Sy4Z40C3lVshxXA==",
"version": "3.5.16",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.16.tgz",
"integrity": "sha512-rjOV2ecxMd5SiAmof2xzh2WxntRcigkX/He4YFJ6WdRvVUrbt6DxC1Iujh10XLl8xCDRDtGKMeO3D+pRQ1PP9w==",
"dependencies": {
"@vue/compiler-dom": "3.4.21",
"@vue/compiler-sfc": "3.4.21",
"@vue/runtime-dom": "3.4.21",
"@vue/server-renderer": "3.4.21",
"@vue/shared": "3.4.21"
"@vue/compiler-dom": "3.5.16",
"@vue/compiler-sfc": "3.5.16",
"@vue/runtime-dom": "3.5.16",
"@vue/server-renderer": "3.5.16",
"@vue/shared": "3.5.16"
},
"peerDependencies": {
"typescript": "*"

View File

@ -21,7 +21,7 @@
"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",

View File

@ -1,75 +1,81 @@
<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 {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 { 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);
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: string;
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);
websocketService.getSocketStatus();
changeFavicon();
if (import.meta.env.VITE_DISABLE_CONNECTION !== 'true') {
websocketService = getWebsocketService()
websocketService.init(host, onServerMsg, onClientCtrl)
websocketService.getSocketStatus()
}
changeFavicon()
useSystemModule();
useDataFlowModule();
useSystemModule()
useDataFlowModule()
if (isOTAEnabled()) {
useUpdateModule();
useUpdateModule()
}
if (isTrialMode()) {
ElMessageBox.alert(getTrialMsg(), getTrialDate(), {
confirmButtonText: '好的',
});
confirmButtonText: translate('common.ok')
})
}
});
})
onUnmounted(() => {
});
onUnmounted(() => {})
</script>
<template>

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

Width:  |  Height:  |  Size: 222 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

Width:  |  Height:  |  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

Width:  |  Height:  |  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

Width:  |  Height:  |  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

Width:  |  Height:  |  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

Width:  |  Height:  |  Size: 370 B

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

@ -37,7 +37,7 @@ export default {
wifi: "Wi-Fi",
about: "About",
uart: "Uart",
at: "AT Command",
widget: "Widget",
feedback: "Feedback",
close: "Close",
update: "Update",
@ -200,6 +200,36 @@ export default {
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",
},
};

View File

@ -37,7 +37,7 @@ export default {
wifi: "Wi-Fi",
about: "À propos",
uart: "Uart",
at: "Commande AT",
widget: "Widget",
feedback: "Feedback",
close: "Fermer",
update: "Mise à jour",
@ -201,5 +201,36 @@ export default {
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

@ -37,7 +37,7 @@ export default {
wifi: "Wi-Fi",
about: "关于",
uart: "UART",
at: "AT命令",
widget: "组件",
feedback: "反馈",
close: "关闭",
update: "更新",
@ -143,7 +143,7 @@ export default {
autoUpdate: "自动刷新",
tempDisplayTooltip: "未满足断帧规则的数据未超时暂时实时显示在此区域。超过8192字节自动断帧",
loopSend: "循环发送",
loopSendTooltip: "实际频率受界面刷新率影响,如需要更精确,可以尝试关闭‘自动刷新’",
loopSendTooltip: "实际频率受界面刷新率影响,如需要更精确,可以尝试关闭'自动刷新'",
sendFormat: "发送格式",
cachedFrame: "缓存帧数",
format: "格式化",
@ -204,5 +204,36 @@ export default {
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

@ -1,18 +1,18 @@
import {createRouter, createWebHistory, type RouteLocationNormalizedLoaded} from 'vue-router'
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 AtCommand from '@/views/AtCommand.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({
currentLanguage: getLang(), // Get the current language from your i18n setup
lang: getLang()
});
interface AppRouteMeta {
@ -37,14 +37,14 @@ function updateDocumentTitle(route: RouteLocationNormalizedLoaded) {
}
// Watch for language changes to update the titles dynamically
watch(() => languageState.currentLanguage, () => {
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: '/',
@ -68,9 +68,9 @@ const router = createRouter({
meta: { titleKey: 'page.uart' },
component: Uart,
}, {
path: '/at:ext(.*)',
meta: { titleKey: 'page.at' },
component: AtCommand,
path: '/widget:ext(.*)',
meta: { titleKey: 'page.widget' },
component: WidgetPannel,
}, {
path: '/feedback:ext(.*)',
meta: { titleKey: 'page.feedback' },

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,14 +0,0 @@
<script setup lang="ts">
import { GridLayout, GridItem } from 'vue-grid-layout-v3'
</script>
<template>
<div class="">
</div>
</template>
<style scoped>
</style>

View File

@ -12,7 +12,7 @@
<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></textDataViewer>
<textDataViewer :showDataConfig="store.winLeft.show"></textDataViewer>
</div>
<div v-show="winDataView.show && store.winRight.show" ref="thirdWinResizeRef"></div>
@ -82,17 +82,18 @@ import {
/* 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'
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";
const store = useDataViewerStore()
const wsStore = useWsStore()
@ -422,11 +423,7 @@ function handleWinSizeRefresh() {
}
onMounted(() => {
registerModule(api.WtModuleID.UART, {
ctrlCallback: onClientCtrl,
serverJsonMsgCallback: onUartJsonMsg,
serverBinMsgCallback: onUartBinaryMsg,
});
useUartModule()
firstWinResizeRef.value.style.borderWidth = store.winLeft.borderSize + "px";
thirdWinResizeRef.value.style.borderWidth = store.winRight.borderSize + "px";

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

@ -3,22 +3,17 @@
<div class="flex">
<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>
<!-- <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 id="nav-right-slot"></div>
<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>
@ -200,8 +195,8 @@ const sideBarItems: ComputedRef<Item[]> = computed(() => {
name: translate("page.uart"),
href: "/uart",
}, {
name: translate("page.at"),
href: "/at",
name: translate("page.widget"),
href: "/widget",
}, {
name: translate("page.about"),
href: "/about",

View File

@ -7,11 +7,11 @@
transition="none"
width="300"
>
<div v-if="!store.winLeft.show" class="h-[40vh] overflow-auto">
<div v-if="showDataConfig" class="h-[40vh] overflow-auto">
<text-data-config></text-data-config>
</div>
<template #reference>
<el-link v-show="!store.winLeft.show" type="primary">
<el-link v-show="showDataConfig" type="primary">
<InlineSvg name="arrow_drop_down" class="h-6 mb-1 px-2"></InlineSvg>
</el-link>
</template>
@ -218,6 +218,23 @@ 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);

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>