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
|
@ -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元素。
|
||||
|
||||
## 界面截图
|
||||
|
||||
**组件面板**
|
||||

|
||||
|
||||
**UART数据显示**
|
||||

|
||||
|
||||
## 使用说明
|
||||
|
||||
### 最终用户
|
||||
|
||||
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` 文件。
|
93
README.md
|
@ -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**
|
||||

|
||||
|
||||
**UART Data Viewer**
|
||||

|
||||
|
||||
## 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.
|
||||
|
|
After Width: | Height: | Size: 103 KiB |
After Width: | Height: | Size: 38 KiB |
|
@ -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>
|
||||
|
|
|
@ -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": "*"
|
||||
|
|
|
@ -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",
|
||||
|
|
96
src/App.vue
|
@ -1,83 +1,89 @@
|
|||
<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);
|
||||
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: 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>
|
||||
<div class="flex flex-col wt-h-100">
|
||||
<header>
|
||||
<nav-bar/>
|
||||
<nav-bar />
|
||||
</header>
|
||||
<RouterView/>
|
||||
<RouterView />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
|
@ -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 |
|
@ -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 |
|
@ -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 |
|
@ -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 |
|
@ -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 |
|
@ -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 |
|
@ -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 }
|
||||
}
|
|
@ -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 }
|
||||
}
|
|
@ -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
|
||||
})
|
||||
}
|
|
@ -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",
|
||||
},
|
||||
};
|
|
@ -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',
|
||||
},
|
||||
};
|
|
@ -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: '走,去码头整点薯条',
|
||||
},
|
||||
}
|
|
@ -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' },
|
||||
|
|
|
@ -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
|
||||
}
|
||||
})
|
|
@ -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[]
|
||||
}
|
|
@ -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>
|
|
@ -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";
|
||||
|
|
|
@ -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>
|
||||
|
|
@ -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",
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|