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`
|
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.
|
||||||
2. `npm run dev`,或用 `npm run devh` 则可以用其他设备访问,如手机调试移动界面。
|
|
||||||
3. 根据显示的地址,使用浏览器打开,默认地址为`localhost:5173`, 或者其他设备访问`192.168.X.X:5173`
|
|
||||||
|
|
||||||
##### 发布至esp32:
|
From this interface, you can build a customized dashboard to monitor and interact with your target device.
|
||||||
|
|
||||||
1. `npm install`
|
### Key Features
|
||||||
2. `npm run build` -> 会在`dist/`生成`index.html`和`ws.sharedworker.js`
|
|
||||||
3. 在`dist/`里执行`gzip *` -> -> 会在`dist/`生成`index.html.gz`和`ws.sharedworker.js.gz`
|
* **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.
|
||||||
4. 至此,可以使用这两个文件覆盖ESP32目录中的`project_components/html`里相对应的文件了。
|
* **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">
|
<meta charset="UTF-8">
|
||||||
<link id="favicon" rel="icon" href="data:,">
|
<link id="favicon" rel="icon" href="data:,">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Vite App</title>
|
<title>yunsi.studio project</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|
|
@ -13,7 +13,7 @@
|
||||||
"element-plus": "^2.8.1",
|
"element-plus": "^2.8.1",
|
||||||
"mitt": "^3.0.1",
|
"mitt": "^3.0.1",
|
||||||
"pinia": "^2.1.7",
|
"pinia": "^2.1.7",
|
||||||
"vue": "^3.4.21",
|
"vue": "^3.5.16",
|
||||||
"vue-draggable-plus": "^0.4.1",
|
"vue-draggable-plus": "^0.4.1",
|
||||||
"vue-grid-layout-v3": "^3.1.2",
|
"vue-grid-layout-v3": "^3.1.2",
|
||||||
"vue-i18n": "^9.10.2",
|
"vue-i18n": "^9.10.2",
|
||||||
|
@ -78,10 +78,29 @@
|
||||||
"url": "https://github.com/sponsors/antfu"
|
"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": {
|
"node_modules/@babel/parser": {
|
||||||
"version": "7.24.1",
|
"version": "7.27.5",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.5.tgz",
|
||||||
"integrity": "sha512-Zo9c7N3xdOIQrNip7Lc9wvRPzlRtovHVE4lkz8WEDr7uYh/GMQhSiIgFxGIArRHYdJE5kxtZjAf8rT0xhdLCzg==",
|
"integrity": "sha512-OsQd175SxWkGlzbny8J3K8TnnDD0N3lrIUtB92xwyRpzaenGZhxDvxN/JgU00U3CDZNj9tPuDJ5H0WS4Nt3vKg==",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/types": "^7.27.3"
|
||||||
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"parser": "bin/babel-parser.js"
|
"parser": "bin/babel-parser.js"
|
||||||
},
|
},
|
||||||
|
@ -89,6 +108,18 @@
|
||||||
"node": ">=6.0.0"
|
"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": {
|
"node_modules/@ctrl/tinycolor": {
|
||||||
"version": "3.6.1",
|
"version": "3.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz",
|
||||||
|
@ -499,9 +530,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@jridgewell/sourcemap-codec": {
|
"node_modules/@jridgewell/sourcemap-codec": {
|
||||||
"version": "1.4.15",
|
"version": "1.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz",
|
||||||
"integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg=="
|
"integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="
|
||||||
},
|
},
|
||||||
"node_modules/@jridgewell/trace-mapping": {
|
"node_modules/@jridgewell/trace-mapping": {
|
||||||
"version": "0.3.25",
|
"version": "0.3.25",
|
||||||
|
@ -1102,49 +1133,49 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/compiler-core": {
|
"node_modules/@vue/compiler-core": {
|
||||||
"version": "3.4.21",
|
"version": "3.5.16",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.4.21.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.16.tgz",
|
||||||
"integrity": "sha512-MjXawxZf2SbZszLPYxaFCjxfibYrzr3eYbKxwpLR9EQN+oaziSu3qKVbwBERj1IFIB8OLUewxB5m/BFzi613og==",
|
"integrity": "sha512-AOQS2eaQOaaZQoL1u+2rCJIKDruNXVBZSiUD3chnUrsoX5ZTQMaCvXlWNIfxBJuU15r1o7+mpo5223KVtIhAgQ==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/parser": "^7.23.9",
|
"@babel/parser": "^7.27.2",
|
||||||
"@vue/shared": "3.4.21",
|
"@vue/shared": "3.5.16",
|
||||||
"entities": "^4.5.0",
|
"entities": "^4.5.0",
|
||||||
"estree-walker": "^2.0.2",
|
"estree-walker": "^2.0.2",
|
||||||
"source-map-js": "^1.0.2"
|
"source-map-js": "^1.2.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/compiler-dom": {
|
"node_modules/@vue/compiler-dom": {
|
||||||
"version": "3.4.21",
|
"version": "3.5.16",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.4.21.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.16.tgz",
|
||||||
"integrity": "sha512-IZC6FKowtT1sl0CR5DpXSiEB5ayw75oT2bma1BEhV7RRR1+cfwLrxc2Z8Zq/RGFzJ8w5r9QtCOvTjQgdn0IKmA==",
|
"integrity": "sha512-SSJIhBr/teipXiXjmWOVWLnxjNGo65Oj/8wTEQz0nqwQeP75jWZ0n4sF24Zxoht1cuJoWopwj0J0exYwCJ0dCQ==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-core": "3.4.21",
|
"@vue/compiler-core": "3.5.16",
|
||||||
"@vue/shared": "3.4.21"
|
"@vue/shared": "3.5.16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/compiler-sfc": {
|
"node_modules/@vue/compiler-sfc": {
|
||||||
"version": "3.4.21",
|
"version": "3.5.16",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.4.21.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.16.tgz",
|
||||||
"integrity": "sha512-me7epoTxYlY+2CUM7hy9PCDdpMPfIwrOvAXud2Upk10g4YLv9UBW7kL798TvMeDhPthkZ0CONNrK2GoeI1ODiQ==",
|
"integrity": "sha512-rQR6VSFNpiinDy/DVUE0vHoIDUF++6p910cgcZoaAUm3POxgNOOdS/xgoll3rNdKYTYPnnbARDCZOyZ+QSe6Pw==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/parser": "^7.23.9",
|
"@babel/parser": "^7.27.2",
|
||||||
"@vue/compiler-core": "3.4.21",
|
"@vue/compiler-core": "3.5.16",
|
||||||
"@vue/compiler-dom": "3.4.21",
|
"@vue/compiler-dom": "3.5.16",
|
||||||
"@vue/compiler-ssr": "3.4.21",
|
"@vue/compiler-ssr": "3.5.16",
|
||||||
"@vue/shared": "3.4.21",
|
"@vue/shared": "3.5.16",
|
||||||
"estree-walker": "^2.0.2",
|
"estree-walker": "^2.0.2",
|
||||||
"magic-string": "^0.30.7",
|
"magic-string": "^0.30.17",
|
||||||
"postcss": "^8.4.35",
|
"postcss": "^8.5.3",
|
||||||
"source-map-js": "^1.0.2"
|
"source-map-js": "^1.2.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/compiler-ssr": {
|
"node_modules/@vue/compiler-ssr": {
|
||||||
"version": "3.4.21",
|
"version": "3.5.16",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.4.21.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.16.tgz",
|
||||||
"integrity": "sha512-M5+9nI2lPpAsgXOGQobnIueVqc9sisBFexh5yMIMRAPYLa7+5wEJs8iqOZc1WAa9WQbx9GR2twgznU8LTIiZ4Q==",
|
"integrity": "sha512-d2V7kfxbdsjrDSGlJE7my1ZzCXViEcqN6w14DOsDrUCHEA6vbnVCpRFfrc4ryCP/lCKzX2eS1YtnLE/BuC9f/A==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-dom": "3.4.21",
|
"@vue/compiler-dom": "3.5.16",
|
||||||
"@vue/shared": "3.4.21"
|
"@vue/shared": "3.5.16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/compiler-vue2": {
|
"node_modules/@vue/compiler-vue2": {
|
||||||
|
@ -1225,48 +1256,49 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/reactivity": {
|
"node_modules/@vue/reactivity": {
|
||||||
"version": "3.4.21",
|
"version": "3.5.16",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.4.21.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.16.tgz",
|
||||||
"integrity": "sha512-UhenImdc0L0/4ahGCyEzc/pZNwVgcglGy9HVzJ1Bq2Mm9qXOpP8RyNTjookw/gOCUlXSEtuZ2fUg5nrHcoqJcw==",
|
"integrity": "sha512-FG5Q5ee/kxhIm1p2bykPpPwqiUBV3kFySsHEQha5BJvjXdZTUfmya7wP7zC39dFuZAcf/PD5S4Lni55vGLMhvA==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/shared": "3.4.21"
|
"@vue/shared": "3.5.16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/runtime-core": {
|
"node_modules/@vue/runtime-core": {
|
||||||
"version": "3.4.21",
|
"version": "3.5.16",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.4.21.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.16.tgz",
|
||||||
"integrity": "sha512-pQthsuYzE1XcGZznTKn73G0s14eCJcjaLvp3/DKeYWoFacD9glJoqlNBxt3W2c5S40t6CCcpPf+jG01N3ULyrA==",
|
"integrity": "sha512-bw5Ykq6+JFHYxrQa7Tjr+VSzw7Dj4ldR/udyBZbq73fCdJmyy5MPIFR9IX/M5Qs+TtTjuyUTCnmK3lWWwpAcFQ==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/reactivity": "3.4.21",
|
"@vue/reactivity": "3.5.16",
|
||||||
"@vue/shared": "3.4.21"
|
"@vue/shared": "3.5.16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/runtime-dom": {
|
"node_modules/@vue/runtime-dom": {
|
||||||
"version": "3.4.21",
|
"version": "3.5.16",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.4.21.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.16.tgz",
|
||||||
"integrity": "sha512-gvf+C9cFpevsQxbkRBS1NpU8CqxKw0ebqMvLwcGQrNpx6gqRDodqKqA+A2VZZpQ9RpK2f9yfg8VbW/EpdFUOJw==",
|
"integrity": "sha512-T1qqYJsG2xMGhImRUV9y/RseB9d0eCYZQ4CWca9ztCuiPj/XWNNN+lkNBuzVbia5z4/cgxdL28NoQCvC0Xcfww==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/runtime-core": "3.4.21",
|
"@vue/reactivity": "3.5.16",
|
||||||
"@vue/shared": "3.4.21",
|
"@vue/runtime-core": "3.5.16",
|
||||||
|
"@vue/shared": "3.5.16",
|
||||||
"csstype": "^3.1.3"
|
"csstype": "^3.1.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/server-renderer": {
|
"node_modules/@vue/server-renderer": {
|
||||||
"version": "3.4.21",
|
"version": "3.5.16",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.4.21.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.16.tgz",
|
||||||
"integrity": "sha512-aV1gXyKSN6Rz+6kZ6kr5+Ll14YzmIbeuWe7ryJl5muJ4uwSwY/aStXTixx76TwkZFJLm1aAlA/HSWEJ4EyiMkg==",
|
"integrity": "sha512-BrX0qLiv/WugguGsnQUJiYOE0Fe5mZTwi6b7X/ybGB0vfrPH9z0gD/Y6WOR1sGCgX4gc25L1RYS5eYQKDMoNIg==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-ssr": "3.4.21",
|
"@vue/compiler-ssr": "3.5.16",
|
||||||
"@vue/shared": "3.4.21"
|
"@vue/shared": "3.5.16"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"vue": "3.4.21"
|
"vue": "3.5.16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/shared": {
|
"node_modules/@vue/shared": {
|
||||||
"version": "3.4.21",
|
"version": "3.5.16",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.4.21.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.16.tgz",
|
||||||
"integrity": "sha512-PuJe7vDIi6VYSinuEbUIQgMIRZGgM8e4R+G+/dQTk0X1NEdvgvvgv7m+rfmDH1gZzyA1OjjoWskvHlfRNfQf3g=="
|
"integrity": "sha512-c/0fWy3Jw6Z8L9FmTyYfkpM5zklnqqa9+a6dz3DvONRKW2NEbh46BP0FHuLFSWi2TnQEtp91Z6zOWNrU6QiyPg=="
|
||||||
},
|
},
|
||||||
"node_modules/@vue/tsconfig": {
|
"node_modules/@vue/tsconfig": {
|
||||||
"version": "0.5.1",
|
"version": "0.5.1",
|
||||||
|
@ -3328,14 +3360,11 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/magic-string": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.8",
|
"version": "0.30.17",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.8.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz",
|
||||||
"integrity": "sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==",
|
"integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/sourcemap-codec": "^1.4.15"
|
"@jridgewell/sourcemap-codec": "^1.5.0"
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/mdn-data": {
|
"node_modules/mdn-data": {
|
||||||
|
@ -3445,9 +3474,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.7",
|
"version": "3.3.11",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||||
"integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
|
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
|
@ -3936,9 +3965,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.4.49",
|
"version": "8.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.4.tgz",
|
||||||
"integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==",
|
"integrity": "sha512-QSa9EBe+uwlGTFmHsPKokv3B/oEMQZxfqW0QqNCyhpa6mB1afzulwn8hihglqAb2pOw+BJgNlmXQ8la2VeHB7w==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
|
@ -3954,7 +3983,7 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.7",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
"source-map-js": "^1.2.1"
|
"source-map-js": "^1.2.1"
|
||||||
},
|
},
|
||||||
|
@ -5168,15 +5197,15 @@
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/vue": {
|
"node_modules/vue": {
|
||||||
"version": "3.4.21",
|
"version": "3.5.16",
|
||||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.4.21.tgz",
|
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.16.tgz",
|
||||||
"integrity": "sha512-5hjyV/jLEIKD/jYl4cavMcnzKwjMKohureP8ejn3hhEjwhWIhWeuzL2kJAjzl/WyVsgPY56Sy4Z40C3lVshxXA==",
|
"integrity": "sha512-rjOV2ecxMd5SiAmof2xzh2WxntRcigkX/He4YFJ6WdRvVUrbt6DxC1Iujh10XLl8xCDRDtGKMeO3D+pRQ1PP9w==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-dom": "3.4.21",
|
"@vue/compiler-dom": "3.5.16",
|
||||||
"@vue/compiler-sfc": "3.4.21",
|
"@vue/compiler-sfc": "3.5.16",
|
||||||
"@vue/runtime-dom": "3.4.21",
|
"@vue/runtime-dom": "3.5.16",
|
||||||
"@vue/server-renderer": "3.4.21",
|
"@vue/server-renderer": "3.5.16",
|
||||||
"@vue/shared": "3.4.21"
|
"@vue/shared": "3.5.16"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": "*"
|
"typescript": "*"
|
||||||
|
|
|
@ -21,7 +21,7 @@
|
||||||
"element-plus": "^2.8.1",
|
"element-plus": "^2.8.1",
|
||||||
"mitt": "^3.0.1",
|
"mitt": "^3.0.1",
|
||||||
"pinia": "^2.1.7",
|
"pinia": "^2.1.7",
|
||||||
"vue": "^3.4.21",
|
"vue": "^3.5.16",
|
||||||
"vue-draggable-plus": "^0.4.1",
|
"vue-draggable-plus": "^0.4.1",
|
||||||
"vue-grid-layout-v3": "^3.1.2",
|
"vue-grid-layout-v3": "^3.1.2",
|
||||||
"vue-i18n": "^9.10.2",
|
"vue-i18n": "^9.10.2",
|
||||||
|
|
96
src/App.vue
|
@ -1,83 +1,89 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {useWsStore} from "@/stores/websocket";
|
import { useWsStore } from '@/stores/websocket'
|
||||||
import type {IWebsocketService} from "@/composables/websocket/websocketService";
|
import type { IWebsocketService } from '@/composables/websocket/websocketService'
|
||||||
import {getWebsocketService} from "@/composables/websocket/websocketService";
|
import { getWebsocketService } from '@/composables/websocket/websocketService'
|
||||||
import {onMounted, onUnmounted} from "vue";
|
import { onMounted, onUnmounted } from 'vue'
|
||||||
import {changeFavicon} from "@/composables/importFavicon";
|
import { changeFavicon } from '@/composables/importFavicon'
|
||||||
import {logHelloMessage} from "@/composables/logConsoleMsg";
|
import { logHelloMessage } from '@/composables/logConsoleMsg'
|
||||||
import NavBar from "@/views/navigation/NavBar.vue";
|
import NavBar from '@/views/navigation/NavBar.vue'
|
||||||
import type {ControlMsg, ServerMsg} from "@/api";
|
import type { ControlMsg, ServerMsg } from '@/api'
|
||||||
import {ControlEvent, ControlMsgType} from "@/api";
|
import { ControlEvent, ControlMsgType } from '@/api'
|
||||||
import {routeCtrlMsg, routeModuleServerMsg} from "@/router/msgRouter";
|
import { routeCtrlMsg, routeModuleServerMsg } from '@/router/msgRouter'
|
||||||
import {globalNotify} from "@/composables/notification";
|
import { globalNotify } from '@/composables/notification'
|
||||||
import {getTrialDate, getTrialMsg, isDevMode, isOTAEnabled, isTrialMode} from "@/composables/buildMode";
|
import {
|
||||||
import {useSystemModule} from "@/composables/useSystemModule";
|
getTrialDate,
|
||||||
import {useDataFlowModule} from "@/composables/useDataFlowModule";
|
getTrialMsg,
|
||||||
import {useUpdateModule} from "@/composables/useUpdateModule";
|
isDevMode,
|
||||||
import {ElMessageBox} from "element-plus";
|
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) => {
|
const onClientCtrl = (msg: ControlMsg) => {
|
||||||
if (isDevMode()) {
|
if (isDevMode()) {
|
||||||
console.log("App.vue:", msg);
|
console.log('App.vue:', msg)
|
||||||
}
|
}
|
||||||
if (msg.type === ControlMsgType.WS_EVENT) {
|
if (msg.type === ControlMsgType.WS_EVENT) {
|
||||||
wsState.$patch({state: msg.data as ControlEvent})
|
wsState.$patch({ state: msg.data as ControlEvent })
|
||||||
routeCtrlMsg(msg);
|
routeCtrlMsg(msg)
|
||||||
if (msg.data === ControlEvent.CONNECTED) {
|
if (msg.data === ControlEvent.CONNECTED) {
|
||||||
globalNotify("调试器已连接", "success");
|
globalNotify(translate('common.debuggerConnected'), 'success')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const onServerMsg = (msg: ServerMsg) => {
|
const onServerMsg = (msg: ServerMsg) => {
|
||||||
if (isDevMode()) {
|
if (isDevMode()) {
|
||||||
console.log("App.vue:", msg);
|
console.log('App.vue:', msg)
|
||||||
}
|
}
|
||||||
routeModuleServerMsg(msg);
|
routeModuleServerMsg(msg)
|
||||||
};
|
}
|
||||||
|
|
||||||
let websocketService: IWebsocketService;
|
let websocketService: IWebsocketService
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
logHelloMessage()
|
||||||
logHelloMessage();
|
let host: string
|
||||||
let host: string;
|
|
||||||
if (isDevMode()) {
|
if (isDevMode()) {
|
||||||
host = import.meta.env.VITE_DEVICE_HOST_NAME || "dap.local";
|
host = import.meta.env.VITE_DEVICE_HOST_NAME || 'dap.local'
|
||||||
} else {
|
} else {
|
||||||
host = window.location.host
|
host = window.location.host
|
||||||
}
|
}
|
||||||
websocketService = getWebsocketService();
|
if (import.meta.env.VITE_DISABLE_CONNECTION !== 'true') {
|
||||||
websocketService.init(host, onServerMsg, onClientCtrl);
|
websocketService = getWebsocketService()
|
||||||
websocketService.getSocketStatus();
|
websocketService.init(host, onServerMsg, onClientCtrl)
|
||||||
changeFavicon();
|
websocketService.getSocketStatus()
|
||||||
|
}
|
||||||
|
changeFavicon()
|
||||||
|
|
||||||
useSystemModule();
|
useSystemModule()
|
||||||
useDataFlowModule();
|
useDataFlowModule()
|
||||||
|
|
||||||
if (isOTAEnabled()) {
|
if (isOTAEnabled()) {
|
||||||
useUpdateModule();
|
useUpdateModule()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isTrialMode()) {
|
if (isTrialMode()) {
|
||||||
ElMessageBox.alert(getTrialMsg(), getTrialDate(), {
|
ElMessageBox.alert(getTrialMsg(), getTrialDate(), {
|
||||||
confirmButtonText: '好的',
|
confirmButtonText: translate('common.ok')
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {})
|
||||||
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col wt-h-100">
|
<div class="flex flex-col wt-h-100">
|
||||||
<header>
|
<header>
|
||||||
<nav-bar/>
|
<nav-bar />
|
||||||
</header>
|
</header>
|
||||||
<RouterView/>
|
<RouterView />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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",
|
wifi: "Wi-Fi",
|
||||||
about: "About",
|
about: "About",
|
||||||
uart: "Uart",
|
uart: "Uart",
|
||||||
at: "AT Command",
|
widget: "Widget",
|
||||||
feedback: "Feedback",
|
feedback: "Feedback",
|
||||||
close: "Close",
|
close: "Close",
|
||||||
update: "Update",
|
update: "Update",
|
||||||
|
@ -200,6 +200,36 @@ export default {
|
||||||
connectionSuccess: "Connection Successful",
|
connectionSuccess: "Connection Successful",
|
||||||
enterAPName: "Entre the AP name",
|
enterAPName: "Entre the AP name",
|
||||||
debuggerNotConnected: "Debugger not connected",
|
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",
|
wifi: "Wi-Fi",
|
||||||
about: "À propos",
|
about: "À propos",
|
||||||
uart: "Uart",
|
uart: "Uart",
|
||||||
at: "Commande AT",
|
widget: "Widget",
|
||||||
feedback: "Feedback",
|
feedback: "Feedback",
|
||||||
close: "Fermer",
|
close: "Fermer",
|
||||||
update: "Mise à jour",
|
update: "Mise à jour",
|
||||||
|
@ -201,5 +201,36 @@ export default {
|
||||||
connectionSuccess: "Connexion Réussie",
|
connectionSuccess: "Connexion Réussie",
|
||||||
enterAPName: "Entrez le nom du AP",
|
enterAPName: "Entrez le nom du AP",
|
||||||
debuggerNotConnected: "Debugger non connecté",
|
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",
|
wifi: "Wi-Fi",
|
||||||
about: "关于",
|
about: "关于",
|
||||||
uart: "UART",
|
uart: "UART",
|
||||||
at: "AT命令",
|
widget: "组件",
|
||||||
feedback: "反馈",
|
feedback: "反馈",
|
||||||
close: "关闭",
|
close: "关闭",
|
||||||
update: "更新",
|
update: "更新",
|
||||||
|
@ -143,7 +143,7 @@ export default {
|
||||||
autoUpdate: "自动刷新",
|
autoUpdate: "自动刷新",
|
||||||
tempDisplayTooltip: "未满足断帧规则的数据(如:未超时),暂时实时显示在此区域。超过8192字节,自动断帧;",
|
tempDisplayTooltip: "未满足断帧规则的数据(如:未超时),暂时实时显示在此区域。超过8192字节,自动断帧;",
|
||||||
loopSend: "循环发送",
|
loopSend: "循环发送",
|
||||||
loopSendTooltip: "实际频率受界面刷新率影响,如需要更精确,可以尝试关闭‘自动刷新’",
|
loopSendTooltip: "实际频率受界面刷新率影响,如需要更精确,可以尝试关闭'自动刷新'",
|
||||||
sendFormat: "发送格式",
|
sendFormat: "发送格式",
|
||||||
cachedFrame: "缓存帧数",
|
cachedFrame: "缓存帧数",
|
||||||
format: "格式化",
|
format: "格式化",
|
||||||
|
@ -204,5 +204,36 @@ export default {
|
||||||
connectionSuccess: "连接成功",
|
connectionSuccess: "连接成功",
|
||||||
enterAPName: "请输入AP名",
|
enterAPName: "请输入AP名",
|
||||||
debuggerNotConnected: "调试器未连接",
|
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 Wifi from '@/views/Wifi.vue'
|
||||||
import Feedback from '@/views/Feedback.vue'
|
import Feedback from '@/views/Feedback.vue'
|
||||||
import About from '@/views/About.vue'
|
import About from '@/views/About.vue'
|
||||||
import Uart from '@/views/Uart.vue'
|
import Uart from '@/views/Uart.vue'
|
||||||
import Page404 from '@/views/404.vue'
|
import Page404 from '@/views/404.vue'
|
||||||
import Update from '@/views/Update.vue'
|
import Update from '@/views/Update.vue'
|
||||||
import AtCommand from '@/views/AtCommand.vue'
|
import WidgetPannel from '@/views/WidgetPannel.vue'
|
||||||
import {translate} from "@/locales";
|
import {translate} from "@/locales";
|
||||||
import {isOTAEnabled} from "@/composables/buildMode";
|
import {isOTAEnabled} from "@/composables/buildMode";
|
||||||
import {reactive, watch} from "vue";
|
import {reactive, watch} from "vue";
|
||||||
import {getLang} from "@/i18n";
|
import {getLang} from "@/i18n";
|
||||||
|
|
||||||
const languageState = reactive({
|
const languageState = reactive({
|
||||||
currentLanguage: getLang(), // Get the current language from your i18n setup
|
lang: getLang()
|
||||||
});
|
});
|
||||||
|
|
||||||
interface AppRouteMeta {
|
interface AppRouteMeta {
|
||||||
|
@ -37,14 +37,14 @@ function updateDocumentTitle(route: RouteLocationNormalizedLoaded) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Watch for language changes to update the titles dynamically
|
// Watch for language changes to update the titles dynamically
|
||||||
watch(() => languageState.currentLanguage, () => {
|
watch(() => languageState.lang, () => {
|
||||||
// Recompute all route meta titles
|
// Recompute all route meta titles
|
||||||
updateMetaTitles();
|
updateMetaTitles();
|
||||||
updateDocumentTitle(router.currentRoute.value);
|
updateDocumentTitle(router.currentRoute.value);
|
||||||
}, {deep: true});
|
}, {deep: true});
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHashHistory(import.meta.env.BASE_URL),
|
||||||
routes: [
|
routes: [
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
|
@ -68,9 +68,9 @@ const router = createRouter({
|
||||||
meta: { titleKey: 'page.uart' },
|
meta: { titleKey: 'page.uart' },
|
||||||
component: Uart,
|
component: Uart,
|
||||||
}, {
|
}, {
|
||||||
path: '/at:ext(.*)',
|
path: '/widget:ext(.*)',
|
||||||
meta: { titleKey: 'page.at' },
|
meta: { titleKey: 'page.widget' },
|
||||||
component: AtCommand,
|
component: WidgetPannel,
|
||||||
}, {
|
}, {
|
||||||
path: '/feedback:ext(.*)',
|
path: '/feedback:ext(.*)',
|
||||||
meta: { titleKey: 'page.feedback' },
|
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="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">
|
<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>
|
||||||
|
|
||||||
<div v-show="winDataView.show && store.winRight.show" ref="thirdWinResizeRef"></div>
|
<div v-show="winDataView.show && store.winRight.show" ref="thirdWinResizeRef"></div>
|
||||||
|
@ -82,17 +82,18 @@ import {
|
||||||
|
|
||||||
/* TODO: use https://antoniandre.github.io/splitpanes/ */
|
/* 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 store = useDataViewerStore()
|
||||||
const wsStore = useWsStore()
|
const wsStore = useWsStore()
|
||||||
|
@ -422,11 +423,7 @@ function handleWinSizeRefresh() {
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
registerModule(api.WtModuleID.UART, {
|
useUartModule()
|
||||||
ctrlCallback: onClientCtrl,
|
|
||||||
serverJsonMsgCallback: onUartJsonMsg,
|
|
||||||
serverBinMsgCallback: onUartBinaryMsg,
|
|
||||||
});
|
|
||||||
|
|
||||||
firstWinResizeRef.value.style.borderWidth = store.winLeft.borderSize + "px";
|
firstWinResizeRef.value.style.borderWidth = store.winLeft.borderSize + "px";
|
||||||
thirdWinResizeRef.value.style.borderWidth = store.winRight.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">
|
<div class="flex">
|
||||||
<button @click.prevent="sideMenuOpen=true" class="flex items-center hover:text-blue-600 pl-1 mx-2 sm:mx-4">
|
<button @click.prevent="sideMenuOpen=true" class="flex items-center hover:text-blue-600 pl-1 mx-2 sm:mx-4">
|
||||||
<svg class="block h-3 lg:h-4 lg:w-4 fill-current" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
|
<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>
|
<path d="M0 3h20v2H0V3zm0 6h20v2H0V9zm0 6h20v2H0v-2z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
<el-badge v-if="updateStore.canUpdate" is-dot></el-badge>
|
<el-badge v-if="updateStore.canUpdate" is-dot></el-badge>
|
||||||
</button>
|
</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>
|
<InlineSvg name="favicon" class="h-5 lg:h-8"></InlineSvg>
|
||||||
</router-link>
|
</router-link>
|
||||||
|
|
||||||
|
<div id="nav-right-slot"></div>
|
||||||
<!-- <a class="text-3xl px-4 font-bold leading-none" href="/">-->
|
|
||||||
<!-- <InlineSvg name="home" class="h-10"></InlineSvg>-->
|
|
||||||
<!-- </a>-->
|
|
||||||
<!-- <router-link to="/" class="flex items-center text-sm text-blue-600 font-bold">主页</router-link>-->
|
|
||||||
<!-- <a class="flex items-center text-sm text-blue-600 font-bold" href="/">主页6</a>-->
|
|
||||||
|
|
||||||
<div class="flex pt-0.5 sm:pt-1 ml-4 text-xs items-center sm:hidden">
|
<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>
|
<router-link :to="route.fullPath">{{ $route.meta.title }}</router-link>
|
||||||
|
@ -200,8 +195,8 @@ const sideBarItems: ComputedRef<Item[]> = computed(() => {
|
||||||
name: translate("page.uart"),
|
name: translate("page.uart"),
|
||||||
href: "/uart",
|
href: "/uart",
|
||||||
}, {
|
}, {
|
||||||
name: translate("page.at"),
|
name: translate("page.widget"),
|
||||||
href: "/at",
|
href: "/widget",
|
||||||
}, {
|
}, {
|
||||||
name: translate("page.about"),
|
name: translate("page.about"),
|
||||||
href: "/about",
|
href: "/about",
|
||||||
|
|
|
@ -7,11 +7,11 @@
|
||||||
transition="none"
|
transition="none"
|
||||||
width="300"
|
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>
|
<text-data-config></text-data-config>
|
||||||
</div>
|
</div>
|
||||||
<template #reference>
|
<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>
|
<InlineSvg name="arrow_drop_down" class="h-6 mb-1 px-2"></InlineSvg>
|
||||||
</el-link>
|
</el-link>
|
||||||
</template>
|
</template>
|
||||||
|
@ -218,6 +218,23 @@ import TextDataConfig from "@/views/text-data-viewer/textDataConfig.vue";
|
||||||
import {debouncedWatch} from "@vueuse/core";
|
import {debouncedWatch} from "@vueuse/core";
|
||||||
import {globalNotify} from "@/composables/notification";
|
import {globalNotify} from "@/composables/notification";
|
||||||
import {translate} from "@/locales";
|
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 count = ref(0);
|
||||||
const vuetifyVirtualScrollBarRef = ref(document.body);
|
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>
|