Compare commits
21 Commits
455833c9d4
...
v1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 330a9a48fd | |||
| e233911d5e | |||
| 821b895d04 | |||
| 6a60b4daf4 | |||
| e491dd78bf | |||
| 7ec0ad36ba | |||
| 0828edf6b1 | |||
| 7eb62ca37f | |||
| cd2f926ba7 | |||
| 515fc2158b | |||
| a1106d4ce6 | |||
| b23cf4d854 | |||
| 089bfaacf7 | |||
| 21683cb790 | |||
| 2682c0a1d6 | |||
| 4b01ae9898 | |||
| ededa0dc19 | |||
| 1483712c93 | |||
| e4122162ba | |||
| 974dc89292 | |||
| 611cb0c6d7 |
@@ -0,0 +1,108 @@
|
||||
# frost-mqtt-client
|
||||
|
||||
一个使用 AI 开发的多功能 MQTT 桌面客户端,基于 Electron + Vue 3 + Naive UI 构建。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- **多服务器管理**:支持添加、编辑、删除多个 MQTT 服务器配置,数据持久化到本地。
|
||||
- **灵活连接**:支持 `mqtt://`、`mqtts://`、`ws://`、`wss://` 多种协议,可配置用户名、密码、客户端 ID、Keep Alive、Clean Session 等。
|
||||
- **主题订阅**:支持 QoS 0/1/2 多级订阅,通配符主题(如 `sensor/+/temperature`),可单个或批量订阅/取消订阅。
|
||||
- **消息发布**:向指定主题发布消息,支持设置 QoS 和 Retain 标志,内置 JSON 格式化。
|
||||
- **消息中心**:实时显示接收和发送的消息,支持按主题过滤、清空日志,自动 JSON 美化。
|
||||
- **仪表盘概览**:展示服务器总数、连接数、订阅主题数、消息总数等关键指标,以及当前服务器状态。
|
||||
- **本地持久化**:服务器配置、订阅主题、消息记录、发布历史均自动保存到本地用户数据目录。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- [Electron](https://www.electronjs.org/)
|
||||
- [Vue 3](https://vuejs.org/)
|
||||
- [Vite](https://vitejs.dev/)
|
||||
- [electron-vite](https://electron-vite.org/)
|
||||
- [Naive UI](https://www.naiveui.com/)
|
||||
- [MQTT.js](https://github.com/mqttjs/MQTT.js)
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
frost-mqtt-client/
|
||||
├── app/ # Electron 应用源码
|
||||
│ ├── src/
|
||||
│ │ ├── main/ # 主进程
|
||||
│ │ │ ├── index.js # 应用入口
|
||||
│ │ │ ├── ipc-handlers.js # IPC 通信处理
|
||||
│ │ │ └── mqtt-manager.js # MQTT 连接与数据管理
|
||||
│ │ ├── preload/ # 预加载脚本
|
||||
│ │ └── renderer/ # 渲染进程
|
||||
│ │ └── src/
|
||||
│ │ ├── components/ # 公共组件
|
||||
│ │ ├── stores/ # Pinia 状态管理
|
||||
│ │ ├── views/ # 页面视图
|
||||
│ │ └── App.vue # 根组件
|
||||
│ ├── package.json
|
||||
│ └── electron.vite.config.mjs
|
||||
├── design/ # 设计稿页面
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 环境要求
|
||||
|
||||
- [Node.js](https://nodejs.org/) 18+
|
||||
- [pnpm](https://pnpm.io/) 8+
|
||||
|
||||
## 安装依赖
|
||||
|
||||
```bash
|
||||
$ cd app
|
||||
$ pnpm install
|
||||
```
|
||||
|
||||
## 开发运行
|
||||
|
||||
```bash
|
||||
$ cd app
|
||||
$ pnpm dev
|
||||
```
|
||||
|
||||
## 构建打包
|
||||
|
||||
```bash
|
||||
$ cd app
|
||||
|
||||
# Windows
|
||||
$ pnpm build:win
|
||||
|
||||
# macOS
|
||||
$ pnpm build:mac
|
||||
|
||||
# Linux
|
||||
$ pnpm build:linux
|
||||
```
|
||||
|
||||
打包后的安装包会输出到 `app/dist` 目录。
|
||||
|
||||
## 默认配置
|
||||
|
||||
首次启动时,应用会自动创建一个示例服务器:
|
||||
|
||||
- **名称**:EMQX 公共测试
|
||||
- **地址**:`broker.emqx.io`
|
||||
- **端口**:`1883`
|
||||
- **协议**:`mqtt`
|
||||
|
||||
你也可以在左侧服务器列表中点击「添加服务器」按钮,配置自己的 MQTT Broker。
|
||||
|
||||
## 使用说明
|
||||
|
||||
1. 在左侧边栏选择或添加 MQTT 服务器。
|
||||
2. 在「仪表盘」页面点击「连接服务器」。
|
||||
3. 切换到「主题订阅」页面,添加并订阅感兴趣的主题。
|
||||
4. 在「消息中心」页面可发布消息并实时查看收发日志。
|
||||
5. 所有配置和消息记录会自动保存,下次打开即可继续使用。
|
||||
|
||||
## 数据存储
|
||||
|
||||
应用数据(服务器配置、订阅主题、消息记录等)存储在系统的用户数据目录下:
|
||||
|
||||
- **Windows**:`%APPDATA%\frost-mqtt-client\frost-mqtt-data.json`
|
||||
- **macOS**:`~/Library/Application Support/frost-mqtt-client/frost-mqtt-data.json`
|
||||
- **Linux**:`~/.config/frost-mqtt-client/frost-mqtt-data.json`
|
||||
@@ -1,30 +0,0 @@
|
||||
# frost-mqtt-client
|
||||
|
||||
An Electron application with Vue
|
||||
|
||||
## Project Setup
|
||||
|
||||
### Install
|
||||
|
||||
```bash
|
||||
$ pnpm install
|
||||
```
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
$ pnpm dev
|
||||
```
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
# For windows
|
||||
$ pnpm build:win
|
||||
|
||||
# For macOS
|
||||
$ pnpm build:mac
|
||||
|
||||
# For Linux
|
||||
$ pnpm build:linux
|
||||
```
|
||||
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 121 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 16 KiB |
@@ -1,5 +1,5 @@
|
||||
appId: com.electron.app
|
||||
productName: frost-mqtt-client
|
||||
appId: top.frost-zx.frost-mqtt-client
|
||||
productName: Frost MQTT Client
|
||||
directories:
|
||||
buildResources: build
|
||||
files:
|
||||
@@ -13,10 +13,15 @@ asarUnpack:
|
||||
win:
|
||||
executableName: frost-mqtt-client
|
||||
nsis:
|
||||
artifactName: ${name}-${version}-setup.${ext}
|
||||
allowToChangeInstallationDirectory: true
|
||||
artifactName: ${name}-${version}-Windows-${arch}-Setup.${ext}
|
||||
createDesktopShortcut: always
|
||||
createStartMenuShortcut: true
|
||||
oneClick: false
|
||||
perMachine: false
|
||||
runAfterFinish: false
|
||||
shortcutName: ${productName}
|
||||
uninstallDisplayName: ${productName}
|
||||
createDesktopShortcut: always
|
||||
mac:
|
||||
entitlementsInherit: build/entitlements.mac.plist
|
||||
extendInfo:
|
||||
@@ -26,16 +31,17 @@ mac:
|
||||
- NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder.
|
||||
notarize: false
|
||||
dmg:
|
||||
artifactName: ${name}-${version}.${ext}
|
||||
artifactName: ${name}-${version}-macOS-${arch}.${ext}
|
||||
linux:
|
||||
target:
|
||||
- AppImage
|
||||
- snap
|
||||
- deb
|
||||
maintainer: electronjs.org
|
||||
category: Utility
|
||||
artifactName: ${name}-${version}-Linux-${arch}.${ext}
|
||||
maintainer: frost-zx.github.io
|
||||
target:
|
||||
# - AppImage
|
||||
# - snap
|
||||
- deb
|
||||
appImage:
|
||||
artifactName: ${name}-${version}.${ext}
|
||||
artifactName: ${name}-${version}-Linux-${arch}.${ext}
|
||||
npmRebuild: false
|
||||
publish:
|
||||
provider: generic
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowJs": true,
|
||||
"checkJs": false,
|
||||
"noEmit": true,
|
||||
"strict": false,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@types/*": ["types/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"src/**/*",
|
||||
"types/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"out"
|
||||
]
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"name": "frost-mqtt-client",
|
||||
"version": "1.0.0",
|
||||
"description": "An Electron application with Vue",
|
||||
"description": "一个使用 AI 开发的多功能 MQTT 桌面客户端,基于 Electron + Vue 3 + Naive UI 构建。",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "example.com",
|
||||
"homepage": "https://electron-vite.org",
|
||||
"author": "Frost-ZX",
|
||||
"homepage": "https://frost-zx.github.io/",
|
||||
"scripts": {
|
||||
"lint": "eslint --cache .",
|
||||
"start": "electron-vite preview",
|
||||
@@ -28,6 +28,7 @@
|
||||
"electron-builder": "^26.0.12",
|
||||
"electron-vite": "^5.0.0",
|
||||
"eslint": "^9.39.1",
|
||||
"less": "^4.7.0",
|
||||
"eslint-plugin-vue": "^10.6.2",
|
||||
"naive-ui": "^2.44.1",
|
||||
"vite": "^7.2.6",
|
||||
|
||||
@@ -23,7 +23,7 @@ importers:
|
||||
version: 2.1.0(eslint@9.39.5(jiti@2.7.0))
|
||||
'@vitejs/plugin-vue':
|
||||
specifier: ^6.0.2
|
||||
version: 6.0.8(vite@7.3.6(@types/node@26.1.1)(jiti@2.7.0))(vue@3.5.39)
|
||||
version: 6.0.8(vite@7.3.6(@types/node@26.1.1)(jiti@2.7.0)(less@4.7.0))(vue@3.5.39)
|
||||
electron:
|
||||
specifier: ^39.2.6
|
||||
version: 39.8.10
|
||||
@@ -32,19 +32,22 @@ importers:
|
||||
version: 26.15.3(electron-builder-squirrel-windows@26.15.3)
|
||||
electron-vite:
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.0(vite@7.3.6(@types/node@26.1.1)(jiti@2.7.0))
|
||||
version: 5.0.0(vite@7.3.6(@types/node@26.1.1)(jiti@2.7.0)(less@4.7.0))
|
||||
eslint:
|
||||
specifier: ^9.39.1
|
||||
version: 9.39.5(jiti@2.7.0)
|
||||
eslint-plugin-vue:
|
||||
specifier: ^10.6.2
|
||||
version: 10.9.2(eslint@9.39.5(jiti@2.7.0))(vue-eslint-parser@10.4.1(eslint@9.39.5(jiti@2.7.0)))
|
||||
less:
|
||||
specifier: ^4.7.0
|
||||
version: 4.7.0
|
||||
naive-ui:
|
||||
specifier: ^2.44.1
|
||||
version: 2.44.1(vue@3.5.39)
|
||||
vite:
|
||||
specifier: ^7.2.6
|
||||
version: 7.3.6(@types/node@26.1.1)(jiti@2.7.0)
|
||||
version: 7.3.6(@types/node@26.1.1)(jiti@2.7.0)(less@4.7.0)
|
||||
vue:
|
||||
specifier: ^3.5.25
|
||||
version: 3.5.39
|
||||
@@ -1088,6 +1091,10 @@ packages:
|
||||
convert-source-map@2.0.0:
|
||||
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
||||
|
||||
copy-anything@3.0.5:
|
||||
resolution: {integrity: sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==}
|
||||
engines: {node: '>=12.13'}
|
||||
|
||||
core-util-is@1.0.3:
|
||||
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
|
||||
|
||||
@@ -1120,6 +1127,22 @@ packages:
|
||||
date-fns@4.4.0:
|
||||
resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==}
|
||||
|
||||
debug@2.6.9:
|
||||
resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==}
|
||||
peerDependencies:
|
||||
supports-color: '*'
|
||||
peerDependenciesMeta:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
debug@3.2.7:
|
||||
resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==}
|
||||
peerDependencies:
|
||||
supports-color: '*'
|
||||
peerDependenciesMeta:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
debug@4.4.3:
|
||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||
engines: {node: '>=6.0'}
|
||||
@@ -1232,6 +1255,10 @@ packages:
|
||||
err-code@2.0.3:
|
||||
resolution: {integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==}
|
||||
|
||||
errno@0.1.8:
|
||||
resolution: {integrity: sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==}
|
||||
hasBin: true
|
||||
|
||||
es-define-property@1.0.1:
|
||||
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -1544,6 +1571,14 @@ packages:
|
||||
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
iconv-lite@0.4.24:
|
||||
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
iconv-lite@0.6.3:
|
||||
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
ieee754@1.2.1:
|
||||
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
|
||||
|
||||
@@ -1582,6 +1617,10 @@ packages:
|
||||
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
is-what@4.1.16:
|
||||
resolution: {integrity: sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==}
|
||||
engines: {node: '>=12.13'}
|
||||
|
||||
isarray@1.0.0:
|
||||
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
|
||||
|
||||
@@ -1660,6 +1699,11 @@ packages:
|
||||
lazy-val@1.0.5:
|
||||
resolution: {integrity: sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==}
|
||||
|
||||
less@4.7.0:
|
||||
resolution: {integrity: sha512-i7dAlT3+boO0mMh1G4cex0vz1lLAScmBbikm1VEDNv+cy0ore1CUo2UtX8m3N9QLE5WYDr4ISbiCRzHNGyFkrA==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
levn@0.4.1:
|
||||
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -1694,6 +1738,10 @@ packages:
|
||||
magic-string@0.30.21:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
|
||||
make-dir@5.1.0:
|
||||
resolution: {integrity: sha512-IfpFq6UM39dUNiphpA6uDezNx/AvWyhwfICWPR3t1VspkgkMZrL+Rk1RbN1bx+aeNYwOrqGJgEgV3yotk+ZUVw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
matcher@3.0.0:
|
||||
resolution: {integrity: sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -1710,6 +1758,11 @@ packages:
|
||||
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
mime@1.6.0:
|
||||
resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==}
|
||||
engines: {node: '>=4'}
|
||||
hasBin: true
|
||||
|
||||
mime@2.6.0:
|
||||
resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==}
|
||||
engines: {node: '>=4.0.0'}
|
||||
@@ -1761,6 +1814,9 @@ packages:
|
||||
engines: {node: '>=16.0.0'}
|
||||
hasBin: true
|
||||
|
||||
ms@2.0.0:
|
||||
resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==}
|
||||
|
||||
ms@2.1.3:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
@@ -1778,6 +1834,16 @@ packages:
|
||||
natural-compare@1.4.0:
|
||||
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
|
||||
|
||||
needle@2.9.1:
|
||||
resolution: {integrity: sha512-6R9fqJ5Zcmf+uYaFgdIHmLwNldn5HbK8L5ybn7Uz+ylX/rnOsSp1AHcvQSrCaFN+qNM1wpymHqD7mVasEOlHGQ==}
|
||||
engines: {node: '>= 4.4.x'}
|
||||
hasBin: true
|
||||
|
||||
needle@3.5.0:
|
||||
resolution: {integrity: sha512-jaQyPKKk2YokHrEg+vFDYxXIHTCBgiZwSHOoVx/8V3GIBS8/VN6NdVRmg8q1ERtPkMvmOvebsgga4sAj5hls/w==}
|
||||
engines: {node: '>= 4.4.x'}
|
||||
hasBin: true
|
||||
|
||||
node-abi@4.33.0:
|
||||
resolution: {integrity: sha512-vLBWCKb+7LWsX+TbfzWOkw0W81m377tyx3hOweBTjO43CXZnRGS1/JPWs20fr0PgZyDXk6ROYrylsEycK8raDA==}
|
||||
engines: {node: '>=22.12.0'}
|
||||
@@ -1839,6 +1905,10 @@ packages:
|
||||
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
parse-node-version@1.0.1:
|
||||
resolution: {integrity: sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
||||
path-exists@4.0.0:
|
||||
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -1890,6 +1960,9 @@ packages:
|
||||
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
||||
probe-image-size@7.3.0:
|
||||
resolution: {integrity: sha512-7CaDeBwiAbh6ohXsvLbAZhO7wzsZAmaevfxe39qvCwRh8LyaZfDlBGGLU1CCTgrTLtCOdwBBhjOrIHaIIimHfQ==}
|
||||
|
||||
proc-log@6.1.0:
|
||||
resolution: {integrity: sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==}
|
||||
engines: {node: ^20.17.0 || >=22.9.0}
|
||||
@@ -1912,6 +1985,9 @@ packages:
|
||||
proper-lockfile@4.1.2:
|
||||
resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==}
|
||||
|
||||
prr@1.0.1:
|
||||
resolution: {integrity: sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==}
|
||||
|
||||
pump@3.0.4:
|
||||
resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==}
|
||||
|
||||
@@ -1994,6 +2070,9 @@ packages:
|
||||
safe-buffer@5.2.1:
|
||||
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
|
||||
|
||||
safer-buffer@2.1.2:
|
||||
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
|
||||
|
||||
sanitize-filename@1.6.4:
|
||||
resolution: {integrity: sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg==}
|
||||
|
||||
@@ -2074,6 +2153,9 @@ packages:
|
||||
resolution: {integrity: sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
stream-parser@0.3.1:
|
||||
resolution: {integrity: sha512-bJ/HgKq41nlKvlhccD5kaCr/P+Hu0wPNKPJOH7en+YrJu/9EgqUF+88w5Jb6KNcjOFMhfX4B2asfeAtIGuHObQ==}
|
||||
|
||||
string-width@4.2.3:
|
||||
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -2993,10 +3075,10 @@ snapshots:
|
||||
'@types/node': 22.20.1
|
||||
optional: true
|
||||
|
||||
'@vitejs/plugin-vue@6.0.8(vite@7.3.6(@types/node@26.1.1)(jiti@2.7.0))(vue@3.5.39)':
|
||||
'@vitejs/plugin-vue@6.0.8(vite@7.3.6(@types/node@26.1.1)(jiti@2.7.0)(less@4.7.0))(vue@3.5.39)':
|
||||
dependencies:
|
||||
'@rolldown/pluginutils': 1.0.1
|
||||
vite: 7.3.6(@types/node@26.1.1)(jiti@2.7.0)
|
||||
vite: 7.3.6(@types/node@26.1.1)(jiti@2.7.0)(less@4.7.0)
|
||||
vue: 3.5.39
|
||||
|
||||
'@vue/compiler-core@3.5.39':
|
||||
@@ -3320,6 +3402,10 @@ snapshots:
|
||||
|
||||
convert-source-map@2.0.0: {}
|
||||
|
||||
copy-anything@3.0.5:
|
||||
dependencies:
|
||||
is-what: 4.1.16
|
||||
|
||||
core-util-is@1.0.3: {}
|
||||
|
||||
cross-dirname@0.1.0:
|
||||
@@ -3348,6 +3434,16 @@ snapshots:
|
||||
|
||||
date-fns@4.4.0: {}
|
||||
|
||||
debug@2.6.9:
|
||||
dependencies:
|
||||
ms: 2.0.0
|
||||
optional: true
|
||||
|
||||
debug@3.2.7:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
optional: true
|
||||
|
||||
debug@4.4.3:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
@@ -3455,7 +3551,7 @@ snapshots:
|
||||
|
||||
electron-to-chromium@1.5.392: {}
|
||||
|
||||
electron-vite@5.0.0(vite@7.3.6(@types/node@26.1.1)(jiti@2.7.0)):
|
||||
electron-vite@5.0.0(vite@7.3.6(@types/node@26.1.1)(jiti@2.7.0)(less@4.7.0)):
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7
|
||||
'@babel/plugin-transform-arrow-functions': 7.29.7(@babel/core@7.29.7)
|
||||
@@ -3463,7 +3559,7 @@ snapshots:
|
||||
esbuild: 0.25.12
|
||||
magic-string: 0.30.21
|
||||
picocolors: 1.1.1
|
||||
vite: 7.3.6(@types/node@26.1.1)(jiti@2.7.0)
|
||||
vite: 7.3.6(@types/node@26.1.1)(jiti@2.7.0)(less@4.7.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -3499,6 +3595,11 @@ snapshots:
|
||||
|
||||
err-code@2.0.3: {}
|
||||
|
||||
errno@0.1.8:
|
||||
dependencies:
|
||||
prr: 1.0.1
|
||||
optional: true
|
||||
|
||||
es-define-property@1.0.1: {}
|
||||
|
||||
es-errors@1.3.0: {}
|
||||
@@ -3909,6 +4010,16 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
iconv-lite@0.4.24:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
optional: true
|
||||
|
||||
iconv-lite@0.6.3:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
optional: true
|
||||
|
||||
ieee754@1.2.1: {}
|
||||
|
||||
ignore@5.3.2: {}
|
||||
@@ -3937,6 +4048,8 @@ snapshots:
|
||||
dependencies:
|
||||
is-extglob: 2.1.1
|
||||
|
||||
is-what@4.1.16: {}
|
||||
|
||||
isarray@1.0.0: {}
|
||||
|
||||
isbinaryfile@4.0.10: {}
|
||||
@@ -3996,6 +4109,21 @@ snapshots:
|
||||
|
||||
lazy-val@1.0.5: {}
|
||||
|
||||
less@4.7.0:
|
||||
dependencies:
|
||||
copy-anything: 3.0.5
|
||||
parse-node-version: 1.0.1
|
||||
optionalDependencies:
|
||||
errno: 0.1.8
|
||||
graceful-fs: 4.2.11
|
||||
make-dir: 5.1.0
|
||||
mime: 1.6.0
|
||||
needle: 3.5.0
|
||||
probe-image-size: 7.3.0
|
||||
source-map: 0.6.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
levn@0.4.1:
|
||||
dependencies:
|
||||
prelude-ls: 1.2.1
|
||||
@@ -4027,6 +4155,9 @@ snapshots:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
make-dir@5.1.0:
|
||||
optional: true
|
||||
|
||||
matcher@3.0.0:
|
||||
dependencies:
|
||||
escape-string-regexp: 4.0.0
|
||||
@@ -4040,6 +4171,9 @@ snapshots:
|
||||
dependencies:
|
||||
mime-db: 1.52.0
|
||||
|
||||
mime@1.6.0:
|
||||
optional: true
|
||||
|
||||
mime@2.6.0: {}
|
||||
|
||||
mimic-response@1.0.1: {}
|
||||
@@ -4105,6 +4239,9 @@ snapshots:
|
||||
- supports-color
|
||||
- utf-8-validate
|
||||
|
||||
ms@2.0.0:
|
||||
optional: true
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
naive-ui@2.44.1(vue@3.5.39):
|
||||
@@ -4133,6 +4270,21 @@ snapshots:
|
||||
|
||||
natural-compare@1.4.0: {}
|
||||
|
||||
needle@2.9.1:
|
||||
dependencies:
|
||||
debug: 3.2.7
|
||||
iconv-lite: 0.4.24
|
||||
sax: 1.6.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
optional: true
|
||||
|
||||
needle@3.5.0:
|
||||
dependencies:
|
||||
iconv-lite: 0.6.3
|
||||
sax: 1.6.0
|
||||
optional: true
|
||||
|
||||
node-abi@4.33.0:
|
||||
dependencies:
|
||||
semver: 7.7.4
|
||||
@@ -4205,6 +4357,8 @@ snapshots:
|
||||
dependencies:
|
||||
callsites: 3.1.0
|
||||
|
||||
parse-node-version@1.0.1: {}
|
||||
|
||||
path-exists@4.0.0: {}
|
||||
|
||||
path-is-absolute@1.0.1: {}
|
||||
@@ -4252,6 +4406,15 @@ snapshots:
|
||||
|
||||
prelude-ls@1.2.1: {}
|
||||
|
||||
probe-image-size@7.3.0:
|
||||
dependencies:
|
||||
lodash.merge: 4.6.2
|
||||
needle: 2.9.1
|
||||
stream-parser: 0.3.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
optional: true
|
||||
|
||||
proc-log@6.1.0: {}
|
||||
|
||||
process-nextick-args@2.0.1: {}
|
||||
@@ -4271,6 +4434,9 @@ snapshots:
|
||||
retry: 0.12.0
|
||||
signal-exit: 3.0.7
|
||||
|
||||
prr@1.0.1:
|
||||
optional: true
|
||||
|
||||
pump@3.0.4:
|
||||
dependencies:
|
||||
end-of-stream: 1.4.5
|
||||
@@ -4385,6 +4551,9 @@ snapshots:
|
||||
|
||||
safe-buffer@5.2.1: {}
|
||||
|
||||
safer-buffer@2.1.2:
|
||||
optional: true
|
||||
|
||||
sanitize-filename@1.6.4:
|
||||
dependencies:
|
||||
truncate-utf8-bytes: 1.0.2
|
||||
@@ -4444,6 +4613,13 @@ snapshots:
|
||||
|
||||
stat-mode@1.0.0: {}
|
||||
|
||||
stream-parser@0.3.1:
|
||||
dependencies:
|
||||
debug: 2.6.9
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
optional: true
|
||||
|
||||
string-width@4.2.3:
|
||||
dependencies:
|
||||
emoji-regex: 8.0.0
|
||||
@@ -4561,7 +4737,7 @@ snapshots:
|
||||
evtd: 0.2.4
|
||||
vue: 3.5.39
|
||||
|
||||
vite@7.3.6(@types/node@26.1.1)(jiti@2.7.0):
|
||||
vite@7.3.6(@types/node@26.1.1)(jiti@2.7.0)(less@4.7.0):
|
||||
dependencies:
|
||||
esbuild: 0.28.1
|
||||
fdir: 6.5.0(picomatch@4.0.5)
|
||||
@@ -4573,6 +4749,7 @@ snapshots:
|
||||
'@types/node': 26.1.1
|
||||
fsevents: 2.3.3
|
||||
jiti: 2.7.0
|
||||
less: 4.7.0
|
||||
|
||||
vooks@0.2.12(vue@3.5.39):
|
||||
dependencies:
|
||||
|
||||
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1 @@
|
||||
<svg width="304" height="303" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" overflow="hidden"><g transform="translate(-774 -198)"><path d="M775 248.334C775 220.535 797.535 198 825.334 198L1026.67 198C1054.46 198 1077 220.535 1077 248.334L1077 449.666C1077 477.465 1054.46 500 1026.67 500L825.334 500C797.535 500 775 477.465 775 449.666Z" fill="#FFFFFF" fill-rule="evenodd"/><path d="M926.5 226.5 1039.5 283 1039.5 416 926.5 472.5 813.5 416 813.5 283Z" stroke="#43D9D0" stroke-width="10.6667" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" fill="none" fill-rule="evenodd"/><path d="M926.5 255.5 1013.5 299 1013.5 401 926.5 444.5 839.5 401 839.5 299Z" stroke="#43D9D0" stroke-width="10.6667" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" fill="none" fill-rule="evenodd"/><path d="M869.5 321.834C869.5 315.574 874.574 310.5 880.834 310.5L888.5 310.5 888.5 310.5 917 310.5 972.167 310.5C978.426 310.5 983.5 315.574 983.5 321.834L983.5 350.167 983.5 350.167 983.5 367.167 983.5 367.167C983.5 373.426 978.426 378.5 972.167 378.5L917 378.5 889.947 398.75 888.5 378.5 880.834 378.5C874.574 378.5 869.5 373.426 869.5 367.167L869.5 367.167 869.5 350.167 869.5 350.167Z" stroke="#43D9D0" stroke-width="10.6667" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" fill="none" fill-rule="evenodd"/><path d="M892.5 334.5 960.532 334.5" stroke="#43D9D0" stroke-width="10.6667" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" fill="none" fill-rule="evenodd"/><path d="M892.5 355.5 930.295 355.5" stroke="#43D9D0" stroke-width="10.6667" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" fill="none" fill-rule="evenodd"/><path d="M926.5 406.5 926.5 444.274" stroke="#43D9D0" stroke-width="10.6667" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" fill="none" fill-rule="evenodd"/><path d="M994.5 391.5 1013.27 400.998" stroke="#43D9D0" stroke-width="10.6667" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" fill="none" fill-rule="evenodd"/><path d="M0 0 15.2633 10.8059" stroke="#43D9D0" stroke-width="10.6667" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" fill="none" fill-rule="evenodd" transform="matrix(-1 0 0 1 854.763 389.5)"/><path d="M0 0 0.000104987 34.0158" stroke="#43D9D0" stroke-width="10.6667" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" fill="none" fill-rule="evenodd" transform="matrix(1 0 0 -1 926.5 289.516)"/></g></svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -5,9 +5,13 @@ import { registerIpcHandlers } from './ipc-handlers.js';
|
||||
|
||||
import icon from '../../resources/icon.png?asset';
|
||||
|
||||
/**
|
||||
* 创建主窗口
|
||||
* @returns {BrowserWindow} 主窗口实例
|
||||
*/
|
||||
function createWindow() {
|
||||
|
||||
// Create the browser window.
|
||||
// 创建浏览器窗口
|
||||
const mainWindow = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
@@ -22,17 +26,19 @@ function createWindow() {
|
||||
},
|
||||
});
|
||||
|
||||
// 窗口准备就绪后显示,避免白屏
|
||||
mainWindow.on('ready-to-show', () => {
|
||||
mainWindow.show();
|
||||
});
|
||||
|
||||
// 拦截新窗口打开请求,使用系统默认浏览器打开外部链接
|
||||
mainWindow.webContents.setWindowOpenHandler((details) => {
|
||||
shell.openExternal(details.url);
|
||||
return { action: 'deny' };
|
||||
});
|
||||
|
||||
// HMR for renderer base on electron-vite cli.
|
||||
// Load the remote URL for development or the local html file for production.
|
||||
// 基于 electron-vite cli 的热更新
|
||||
// 开发环境加载远程 URL,生产环境加载本地 HTML 文件
|
||||
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
||||
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']);
|
||||
} else {
|
||||
@@ -41,17 +47,16 @@ function createWindow() {
|
||||
|
||||
}
|
||||
|
||||
// This method will be called when Electron has finished
|
||||
// initialization and is ready to create browser windows.
|
||||
// Some APIs can only be used after this event occurs.
|
||||
// 当 Electron 完成初始化并准备好创建浏览器窗口时调用此方法
|
||||
// 某些 API 只能在此事件触发后使用
|
||||
app.whenReady().then(() => {
|
||||
|
||||
// Set app user model id for windows
|
||||
// 为 Windows 设置应用用户模型 ID
|
||||
electronApp.setAppUserModelId('com.electron');
|
||||
|
||||
// Default open or close DevTools by F12 in development
|
||||
// and ignore CommandOrControl + R in production.
|
||||
// see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
|
||||
// 开发环境通过 F12 打开/关闭 DevTools
|
||||
// 生产环境忽略 CommandOrControl + R 刷新
|
||||
// 详见 https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
|
||||
app.on('browser-window-created', (_, window) => {
|
||||
optimizer.watchWindowShortcuts(window);
|
||||
});
|
||||
@@ -62,8 +67,7 @@ app.whenReady().then(() => {
|
||||
createWindow();
|
||||
|
||||
app.on('activate', function () {
|
||||
// On macOS it's common to re-create a window in the app when the
|
||||
// dock icon is clicked and there are no other windows open.
|
||||
// 在 macOS 上,当点击应用图标且没有其他窗口打开时,通常会重新创建一个窗口
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
}
|
||||
@@ -71,14 +75,13 @@ app.whenReady().then(() => {
|
||||
|
||||
});
|
||||
|
||||
// Quit when all windows are closed, except on macOS. There, it's common
|
||||
// for applications and their menu bar to stay active until the user quits
|
||||
// explicitly with Cmd + Q.
|
||||
// 当所有窗口关闭时退出应用,macOS 除外
|
||||
// 在 macOS 上,应用及其菜单栏通常会保持活动状态,直到用户通过 Cmd + Q 显式退出
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
// In this file you can include the rest of your app's specific main process
|
||||
// code. You can also put them in separate files and require them here.
|
||||
// 在这个文件中,你可以包含应用特定的主进程代码
|
||||
// 也可以将它们放到单独的文件中,然后在这里引入
|
||||
|
||||
@@ -2,15 +2,26 @@ import { ipcMain, BrowserWindow } from 'electron';
|
||||
|
||||
import mqttManager from './mqtt-manager.js';
|
||||
|
||||
/**
|
||||
* 注册所有 IPC 处理器
|
||||
* 将渲染进程的请求转发给 MQTT 管理器,并把 MQTT 事件推送给渲染进程
|
||||
*/
|
||||
export function registerIpcHandlers() {
|
||||
|
||||
// 获取主窗口(用于推送事件)
|
||||
/**
|
||||
* 获取当前所有窗口中的第一个主窗口
|
||||
* @returns {BrowserWindow|null} 主窗口实例或 null
|
||||
*/
|
||||
function getMainWindow() {
|
||||
const windows = BrowserWindow.getAllWindows();
|
||||
return windows.length > 0 ? windows[0] : null;
|
||||
}
|
||||
|
||||
// 向渲染进程发送事件
|
||||
/**
|
||||
* 向渲染进程发送事件
|
||||
* @param {string} channel - IPC 通道名称
|
||||
* @param {*} data - 要发送的数据
|
||||
*/
|
||||
function sendToRenderer(channel, data) {
|
||||
const win = getMainWindow();
|
||||
if (win && !win.isDestroyed()) {
|
||||
@@ -58,6 +69,14 @@ export function registerIpcHandlers() {
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.handle('mqtt:export-servers', () => {
|
||||
return mqttManager.exportServers();
|
||||
});
|
||||
|
||||
ipcMain.handle('mqtt:import-servers', (_, serverList) => {
|
||||
return mqttManager.importServers(serverList);
|
||||
});
|
||||
|
||||
// ==================== 连接管理 ====================
|
||||
|
||||
ipcMain.handle('mqtt:connect', (_, id) => {
|
||||
@@ -71,8 +90,8 @@ export function registerIpcHandlers() {
|
||||
|
||||
// ==================== 主题管理 ====================
|
||||
|
||||
ipcMain.handle('mqtt:add-topic', (_, serverId, topic, qos) => {
|
||||
return mqttManager.addTopic(serverId, topic, qos);
|
||||
ipcMain.handle('mqtt:add-topic', (_, serverId, topic, qos, comment) => {
|
||||
return mqttManager.addTopic(serverId, topic, qos, comment);
|
||||
});
|
||||
|
||||
ipcMain.handle('mqtt:remove-topic', (_, serverId, topic) => {
|
||||
@@ -91,8 +110,8 @@ export function registerIpcHandlers() {
|
||||
return mqttManager.subscribeAll(serverId);
|
||||
});
|
||||
|
||||
ipcMain.handle('mqtt:update-topic', (_, serverId, topicId, topic, qos) => {
|
||||
return mqttManager.updateTopic(serverId, topicId, topic, qos);
|
||||
ipcMain.handle('mqtt:update-topic', (_, serverId, topicId, topic, qos, comment) => {
|
||||
return mqttManager.updateTopic(serverId, topicId, topic, qos, comment);
|
||||
});
|
||||
|
||||
// ==================== 消息发布 ====================
|
||||
|
||||
@@ -4,6 +4,27 @@ import { join } from 'path';
|
||||
|
||||
import mqtt from 'mqtt';
|
||||
|
||||
// ==================== 类型定义 ====================
|
||||
|
||||
/**
|
||||
* 从公共类型定义文件引入 MQTT 相关类型
|
||||
* @typedef {import('@types/jsdoc').MqttQoS} MqttQoS
|
||||
* @typedef {import('@types/jsdoc').MqttServerStatus} MqttServerStatus
|
||||
* @typedef {import('@types/jsdoc').MqttTopic} MqttTopic
|
||||
* @typedef {import('@types/jsdoc').TopicConfig} TopicConfig
|
||||
* @typedef {import('@types/jsdoc').ServerConfig} ServerConfig
|
||||
* @typedef {import('@types/jsdoc').MqttServer} MqttServer
|
||||
* @typedef {import('@types/jsdoc').PublishOptions} PublishOptions
|
||||
* @typedef {import('@types/jsdoc').MqttMessage} MqttMessage
|
||||
* @typedef {import('@types/jsdoc').PublishRecord} PublishRecord
|
||||
*/
|
||||
|
||||
// ==================== 类定义 ====================
|
||||
|
||||
/**
|
||||
* MQTT 管理器
|
||||
* 负责 MQTT 服务器配置持久化、连接管理、主题订阅/发布、消息存储等核心逻辑
|
||||
*/
|
||||
class MqttManager {
|
||||
|
||||
constructor() {
|
||||
@@ -17,6 +38,10 @@ class MqttManager {
|
||||
this.loadData();
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成唯一消息 id
|
||||
* @returns {string} 消息唯一标识
|
||||
*/
|
||||
nextMsgId() {
|
||||
this.msgIdCounter = (this.msgIdCounter + 1) % Number.MAX_SAFE_INTEGER;
|
||||
return 'msg_' + Date.now() + '_' + this.msgIdCounter;
|
||||
@@ -24,6 +49,10 @@ class MqttManager {
|
||||
|
||||
// ==================== 数据持久化 ====================
|
||||
|
||||
/**
|
||||
* 获取用户数据目录,若不存在则创建
|
||||
* @returns {string} 用户数据目录路径
|
||||
*/
|
||||
getDataDir() {
|
||||
|
||||
let dir = app.getPath('userData');
|
||||
@@ -36,6 +65,10 @@ class MqttManager {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 从本地文件加载服务器、消息和发布历史数据
|
||||
* 文件不存在或解析失败时初始化默认服务器
|
||||
*/
|
||||
loadData() {
|
||||
try {
|
||||
if (existsSync(this.dataPath)) {
|
||||
@@ -49,6 +82,7 @@ class MqttManager {
|
||||
s.topics = (s.topics || []).map((t) => ({
|
||||
...t,
|
||||
id: t.id || 'topic_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6),
|
||||
comment: t.comment ?? '',
|
||||
subscribed: false,
|
||||
}));
|
||||
this.servers.set(s.id, s);
|
||||
@@ -76,6 +110,9 @@ class MqttManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将当前服务器、消息和发布历史保存到本地文件
|
||||
*/
|
||||
saveData() {
|
||||
try {
|
||||
|
||||
@@ -96,6 +133,9 @@ class MqttManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化默认示例服务器
|
||||
*/
|
||||
initDefaultServers() {
|
||||
|
||||
let defaults = [
|
||||
@@ -110,10 +150,13 @@ class MqttManager {
|
||||
password: '',
|
||||
keepAlive: 60,
|
||||
cleanSession: true,
|
||||
connectTimeout: 10000,
|
||||
reconnect: true,
|
||||
reconnectInterval: 5000,
|
||||
status: 'disconnected',
|
||||
topics: [
|
||||
{ id: 'topic_default_001', topic: 'sensor/+/temperature', qos: 1, subscribed: false },
|
||||
{ id: 'topic_default_002', topic: 'device/status', qos: 2, subscribed: false },
|
||||
{ id: 'topic_default_001', topic: 'sensor/+/temperature', qos: 1, comment: '', subscribed: false },
|
||||
{ id: 'topic_default_002', topic: 'device/status', qos: 2, comment: '', subscribed: false },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -130,14 +173,27 @@ class MqttManager {
|
||||
|
||||
// ==================== 监听器 ====================
|
||||
|
||||
/**
|
||||
* 添加 MQTT 事件监听器
|
||||
* @param {Function} callback - 回调函数,接收 (event, data) 参数
|
||||
*/
|
||||
addListener(callback) {
|
||||
this.listeners.add(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除 MQTT 事件监听器
|
||||
* @param {Function} callback - 要移除的回调函数
|
||||
*/
|
||||
removeListener(callback) {
|
||||
this.listeners.delete(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知所有监听器
|
||||
* @param {string} event - 事件名称
|
||||
* @param {*} data - 事件数据
|
||||
*/
|
||||
notify(event, data) {
|
||||
this.listeners.forEach((cb) => {
|
||||
try {
|
||||
@@ -150,31 +206,89 @@ class MqttManager {
|
||||
|
||||
// ==================== 服务器管理 ====================
|
||||
|
||||
/**
|
||||
* 获取所有服务器列表
|
||||
* @returns {MqttServer[]} 服务器配置数组
|
||||
*/
|
||||
getServers() {
|
||||
return Array.from(this.servers.values()).map((s) => ({
|
||||
...s,
|
||||
password: s.password ? '******' : '',
|
||||
password: s.password || '',
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 id 获取服务器配置
|
||||
* @param {string} id - 服务器 id
|
||||
* @returns {MqttServer | null} 服务器配置或 null
|
||||
*/
|
||||
getServer(id) {
|
||||
return this.servers.get(id) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取服务器配置字段 schema
|
||||
* @returns {Record<string, Function>} 字段名到处理函数的映射
|
||||
*/
|
||||
getServerConfigSchema() {
|
||||
return {
|
||||
name: (c, e) => c.name ?? e?.name,
|
||||
host: (c, e) => c.host ?? e?.host,
|
||||
port: (c, e) => c.port || e?.port || 1883,
|
||||
protocol: (c, e) => c.protocol || e?.protocol || 'mqtt',
|
||||
clientId: (c, e) => c.clientId || e?.clientId || 'frost_' + Math.random().toString(36).slice(2, 10),
|
||||
username: (c, e) => c.username ?? e?.username ?? '',
|
||||
password: (c, e, opts) => c.password ?? (opts.keepExistingPassword ? e?.password : '') ?? '',
|
||||
keepAlive: (c, e) => c.keepAlive || e?.keepAlive || 60,
|
||||
cleanSession: (c) => c.cleanSession !== false,
|
||||
connectTimeout: (c, e) => c.connectTimeout ?? e?.connectTimeout ?? 10000,
|
||||
reconnect: (c) => c.reconnect !== false,
|
||||
reconnectInterval: (c, e) => c.reconnectInterval ?? e?.reconnectInterval ?? 5000,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据配置构建服务器字段
|
||||
* @param {ServerConfig} config - 新配置
|
||||
* @param {MqttServer | null} existing - 已有服务器配置
|
||||
* @param {{ keepExistingPassword?: boolean }} options - 可选项
|
||||
* @returns {ServerConfig} 处理后的字段对象
|
||||
*/
|
||||
buildServerFields(config, existing = null, options = {}) {
|
||||
let schema = this.getServerConfigSchema();
|
||||
let fields = {};
|
||||
for (let key of Object.keys(schema)) {
|
||||
fields[key] = schema[key](config, existing, options);
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建主题对象
|
||||
* @param {TopicConfig} t - 原始主题数据
|
||||
* @returns {MqttTopic} 标准化后的主题对象
|
||||
*/
|
||||
buildTopic(t) {
|
||||
return {
|
||||
id: t.id || 'topic_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6),
|
||||
topic: t.topic,
|
||||
qos: t.qos ?? 1,
|
||||
comment: t.comment ?? '',
|
||||
subscribed: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加服务器
|
||||
* @param {ServerConfig} config - 服务器配置
|
||||
* @returns {MqttServer} 创建后的服务器对象
|
||||
*/
|
||||
addServer(config) {
|
||||
|
||||
let id = 'srv_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6);
|
||||
let server = {
|
||||
id,
|
||||
name: config.name,
|
||||
host: config.host,
|
||||
port: config.port || 1883,
|
||||
protocol: config.protocol || 'mqtt',
|
||||
clientId: config.clientId || 'frost_' + Math.random().toString(36).slice(2, 10),
|
||||
username: config.username || '',
|
||||
password: config.password || '',
|
||||
keepAlive: config.keepAlive || 60,
|
||||
cleanSession: config.cleanSession !== false,
|
||||
...this.buildServerFields(config),
|
||||
status: 'disconnected',
|
||||
topics: [],
|
||||
};
|
||||
@@ -188,6 +302,12 @@ class MqttManager {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新服务器配置
|
||||
* @param {string} id - 服务器 id
|
||||
* @param {ServerConfig} config - 新的服务器配置
|
||||
* @returns {MqttServer | null} 更新后的服务器对象或 null
|
||||
*/
|
||||
updateServer(id, config) {
|
||||
|
||||
let server = this.servers.get(id);
|
||||
@@ -196,20 +316,7 @@ class MqttManager {
|
||||
return null;
|
||||
}
|
||||
|
||||
Object.assign(server, {
|
||||
name: config.name,
|
||||
host: config.host,
|
||||
port: config.port || 1883,
|
||||
protocol: config.protocol || 'mqtt',
|
||||
clientId: config.clientId || server.clientId,
|
||||
username: config.username || '',
|
||||
keepAlive: config.keepAlive || 60,
|
||||
cleanSession: config.cleanSession !== false,
|
||||
});
|
||||
|
||||
if (config.password) {
|
||||
server.password = config.password;
|
||||
}
|
||||
Object.assign(server, this.buildServerFields(config, server, { keepExistingPassword: true }));
|
||||
|
||||
this.saveData();
|
||||
|
||||
@@ -217,6 +324,10 @@ class MqttManager {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除服务器,并断开其连接
|
||||
* @param {string} id - 服务器 id
|
||||
*/
|
||||
deleteServer(id) {
|
||||
// 先断开连接
|
||||
this.disconnect(id);
|
||||
@@ -226,8 +337,85 @@ class MqttManager {
|
||||
this.saveData();
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出所有服务器配置(不含运行时状态)
|
||||
* @returns {ServerConfig[]} 服务器配置数组
|
||||
*/
|
||||
exportServers() {
|
||||
let schemaKeys = Object.keys(this.getServerConfigSchema());
|
||||
return Array.from(this.servers.values()).map((s) => {
|
||||
let exported = {};
|
||||
for (let key of schemaKeys) {
|
||||
exported[key] = s[key];
|
||||
}
|
||||
exported.topics = (s.topics || []).map((t) => this.buildTopicExport(t));
|
||||
return exported;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建导出用的主题对象
|
||||
* @param {MqttTopic} t - 主题对象
|
||||
* @returns {TopicConfig} 导出主题对象
|
||||
*/
|
||||
buildTopicExport(t) {
|
||||
return {
|
||||
id: t.id,
|
||||
topic: t.topic,
|
||||
qos: t.qos,
|
||||
comment: t.comment ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入服务器配置列表,会覆盖现有配置
|
||||
* @param {ServerConfig[]} serverList - 服务器配置数组
|
||||
* @returns {MqttServer[]} 导入后的服务器列表
|
||||
*/
|
||||
importServers(serverList) {
|
||||
if (!Array.isArray(serverList)) {
|
||||
throw new Error('导入格式错误:必须是服务器配置数组');
|
||||
}
|
||||
|
||||
// 断开所有现有连接
|
||||
this.clients.forEach((client, id) => {
|
||||
this.disconnect(id);
|
||||
});
|
||||
this.clients.clear();
|
||||
|
||||
this.servers.clear();
|
||||
this.messages.clear();
|
||||
this.publishHistory.clear();
|
||||
|
||||
serverList.forEach((config) => {
|
||||
if (!config.name || !config.host) {
|
||||
return;
|
||||
}
|
||||
|
||||
let id = 'srv_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6);
|
||||
let server = {
|
||||
id,
|
||||
...this.buildServerFields(config),
|
||||
status: 'disconnected',
|
||||
topics: (config.topics || []).map((t) => this.buildTopic(t)),
|
||||
};
|
||||
|
||||
this.servers.set(id, server);
|
||||
this.messages.set(id, []);
|
||||
this.publishHistory.set(id, []);
|
||||
});
|
||||
|
||||
this.saveData();
|
||||
return this.getServers();
|
||||
}
|
||||
|
||||
// ==================== 连接管理 ====================
|
||||
|
||||
/**
|
||||
* 连接指定服务器
|
||||
* @param {string} id - 服务器 id
|
||||
* @returns {boolean} 是否成功发起连接
|
||||
*/
|
||||
connect(id) {
|
||||
|
||||
let server = this.servers.get(id);
|
||||
@@ -254,8 +442,8 @@ class MqttManager {
|
||||
password: server.password || undefined,
|
||||
keepalive: server.keepAlive,
|
||||
clean: server.cleanSession,
|
||||
reconnectPeriod: 5000,
|
||||
connectTimeout: 10000,
|
||||
reconnectPeriod: 0,
|
||||
connectTimeout: server.connectTimeout,
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -265,6 +453,8 @@ class MqttManager {
|
||||
client.on('connect', () => {
|
||||
server.status = 'connected';
|
||||
this.notify('status-change', { id, status: 'connected' });
|
||||
// 只在首次连接成功后,再根据配置启用自动重连
|
||||
client.options.reconnectPeriod = server.reconnect ? server.reconnectInterval : 0;
|
||||
// 自动订阅已有主题
|
||||
server.topics.forEach((t) => {
|
||||
this._doSubscribe(id, t.topic, { qos: t.qos });
|
||||
@@ -323,6 +513,10 @@ class MqttManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 断开指定服务器的连接
|
||||
* @param {string} id - 服务器 id
|
||||
*/
|
||||
disconnect(id) {
|
||||
|
||||
let client = this.clients.get(id);
|
||||
@@ -345,6 +539,14 @@ class MqttManager {
|
||||
}
|
||||
|
||||
// ==================== 订阅管理 ====================
|
||||
|
||||
/**
|
||||
* 执行订阅操作(内部方法)
|
||||
* @param {string} serverId - 服务器 id
|
||||
* @param {string} topic - 主题名称
|
||||
* @param {PublishOptions} opts - 订阅选项
|
||||
* @returns {boolean} 是否发起订阅
|
||||
*/
|
||||
_doSubscribe(serverId, topic, opts) {
|
||||
|
||||
let client = this.clients.get(serverId);
|
||||
@@ -371,18 +573,27 @@ class MqttManager {
|
||||
|
||||
}
|
||||
|
||||
addTopic(serverId, topic, qos = 1) {
|
||||
/**
|
||||
* 为主题列表添加新主题
|
||||
* @param {string} serverId - 服务器 id
|
||||
* @param {string} topic - 主题名称
|
||||
* @param {MqttQoS} qos - QoS 等级
|
||||
* @param {string} comment - 主题备注
|
||||
* @returns {MqttTopic | null} 添加后的主题对象或 null
|
||||
*/
|
||||
addTopic(serverId, topic, qos = 1, comment = '') {
|
||||
let server = this.servers.get(serverId);
|
||||
if (!server) {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
if (server.topics.find((t) => t.topic === topic)) {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
let topicId = 'topic_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6);
|
||||
|
||||
server.topics.push({ id: topicId, topic, qos, subscribed: false });
|
||||
let newTopic = { id: topicId, topic, qos, comment, subscribed: false };
|
||||
server.topics.push(newTopic);
|
||||
|
||||
this.saveData();
|
||||
|
||||
@@ -391,10 +602,16 @@ class MqttManager {
|
||||
this._doSubscribe(serverId, topic, { qos });
|
||||
}
|
||||
|
||||
return true;
|
||||
return newTopic;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除主题并取消订阅
|
||||
* @param {string} serverId - 服务器 id
|
||||
* @param {string} topic - 主题名称
|
||||
* @returns {boolean} 是否移除成功
|
||||
*/
|
||||
removeTopic(serverId, topic) {
|
||||
|
||||
let server = this.servers.get(serverId);
|
||||
@@ -414,6 +631,12 @@ class MqttManager {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅指定主题
|
||||
* @param {string} serverId - 服务器 id
|
||||
* @param {string} topic - 主题名称
|
||||
* @returns {boolean} 是否发起订阅
|
||||
*/
|
||||
subscribe(serverId, topic) {
|
||||
|
||||
let server = this.servers.get(serverId);
|
||||
@@ -432,6 +655,12 @@ class MqttManager {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消订阅指定主题
|
||||
* @param {string} serverId - 服务器 id
|
||||
* @param {string} topic - 主题名称
|
||||
* @returns {boolean} 是否发起取消订阅
|
||||
*/
|
||||
unsubscribe(serverId, topic) {
|
||||
|
||||
let client = this.clients.get(serverId);
|
||||
@@ -455,7 +684,16 @@ class MqttManager {
|
||||
|
||||
}
|
||||
|
||||
updateTopic(serverId, topicId, newTopic, newQos) {
|
||||
/**
|
||||
* 更新主题信息,必要时取消旧主题并重新订阅新主题
|
||||
* @param {string} serverId - 服务器 id
|
||||
* @param {string} topicId - 主题 id
|
||||
* @param {string} newTopic - 新的主题名称
|
||||
* @param {number} newQos - 新的 QoS 等级
|
||||
* @param {string} comment - 新的主题备注
|
||||
* @returns {boolean} 是否更新成功
|
||||
*/
|
||||
updateTopic(serverId, topicId, newTopic, newQos, comment = '') {
|
||||
|
||||
let server = this.servers.get(serverId);
|
||||
|
||||
@@ -488,6 +726,7 @@ class MqttManager {
|
||||
|
||||
t.topic = newTopic;
|
||||
t.qos = newQos;
|
||||
t.comment = comment;
|
||||
|
||||
// 如果已连接且此前已订阅,重新订阅新主题
|
||||
if (server.status === 'connected' && wasSubscribed) {
|
||||
@@ -500,12 +739,18 @@ class MqttManager {
|
||||
topic: newTopic,
|
||||
subscribed: t.subscribed,
|
||||
topicId,
|
||||
comment: t.comment,
|
||||
});
|
||||
|
||||
return true;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量订阅服务器下所有未订阅的主题
|
||||
* @param {string} serverId - 服务器 id
|
||||
* @returns {boolean} 是否发起批量订阅
|
||||
*/
|
||||
subscribeAll(serverId) {
|
||||
|
||||
let client = this.clients.get(serverId);
|
||||
@@ -548,6 +793,14 @@ class MqttManager {
|
||||
|
||||
// ==================== 消息发布 ====================
|
||||
|
||||
/**
|
||||
* 向指定主题发布消息
|
||||
* @param {string} serverId - 服务器 id
|
||||
* @param {string} topic - 目标主题
|
||||
* @param {string} payload - 消息内容
|
||||
* @param {PublishOptions} opts - 发布选项
|
||||
* @returns {boolean} 是否发起发布
|
||||
*/
|
||||
publish(serverId, topic, payload, opts = {}) {
|
||||
|
||||
let client = this.clients.get(serverId);
|
||||
@@ -623,19 +876,37 @@ class MqttManager {
|
||||
|
||||
// ==================== 消息管理 ====================
|
||||
|
||||
/**
|
||||
* 获取指定服务器的消息列表
|
||||
* @param {string} serverId - 服务器 id
|
||||
* @returns {MqttMessage[]} 消息数组
|
||||
*/
|
||||
getMessages(serverId) {
|
||||
return this.messages.get(serverId) || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定服务器的发布历史
|
||||
* @param {string} serverId - 服务器 id
|
||||
* @returns {PublishRecord[]} 发布历史数组
|
||||
*/
|
||||
getPublishHistory(serverId) {
|
||||
return this.publishHistory.get(serverId) || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空指定服务器的消息列表
|
||||
* @param {string} serverId - 服务器 id
|
||||
*/
|
||||
clearMessages(serverId) {
|
||||
this.messages.set(serverId, []);
|
||||
this.saveData();
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空指定服务器的发布历史
|
||||
* @param {string} serverId - 服务器 id
|
||||
*/
|
||||
clearPublishHistory(serverId) {
|
||||
this.publishHistory.set(serverId, []);
|
||||
this.saveData();
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron';
|
||||
import { electronAPI } from '@electron-toolkit/preload';
|
||||
|
||||
// Custom APIs for renderer
|
||||
/**
|
||||
* 渲染进程可调用的自定义 API
|
||||
* 封装了所有与主进程通信的 IPC 调用及事件监听
|
||||
*/
|
||||
const api = {
|
||||
|
||||
// 服务器管理
|
||||
@@ -9,19 +12,22 @@ const api = {
|
||||
addServer: (config) => ipcRenderer.invoke('mqtt:add-server', config),
|
||||
updateServer: (id, config) => ipcRenderer.invoke('mqtt:update-server', id, config),
|
||||
deleteServer: (id) => ipcRenderer.invoke('mqtt:delete-server', id),
|
||||
exportServers: () => ipcRenderer.invoke('mqtt:export-servers'),
|
||||
importServers: (serverList) => ipcRenderer.invoke('mqtt:import-servers', serverList),
|
||||
|
||||
// 连接管理
|
||||
connect: (id) => ipcRenderer.invoke('mqtt:connect', id),
|
||||
disconnect: (id) => ipcRenderer.invoke('mqtt:disconnect', id),
|
||||
|
||||
// 主题管理
|
||||
addTopic: (serverId, topic, qos) => ipcRenderer.invoke('mqtt:add-topic', serverId, topic, qos),
|
||||
addTopic: (serverId, topic, qos, comment) =>
|
||||
ipcRenderer.invoke('mqtt:add-topic', serverId, topic, qos, comment),
|
||||
removeTopic: (serverId, topic) => ipcRenderer.invoke('mqtt:remove-topic', serverId, topic),
|
||||
subscribe: (serverId, topic) => ipcRenderer.invoke('mqtt:subscribe', serverId, topic),
|
||||
unsubscribe: (serverId, topic) => ipcRenderer.invoke('mqtt:unsubscribe', serverId, topic),
|
||||
subscribeAll: (serverId) => ipcRenderer.invoke('mqtt:subscribe-all', serverId),
|
||||
updateTopic: (serverId, topicId, topic, qos) =>
|
||||
ipcRenderer.invoke('mqtt:update-topic', serverId, topicId, topic, qos),
|
||||
updateTopic: (serverId, topicId, topic, qos, comment) =>
|
||||
ipcRenderer.invoke('mqtt:update-topic', serverId, topicId, topic, qos, comment),
|
||||
|
||||
// 消息发布
|
||||
publish: (serverId, topic, payload, opts) =>
|
||||
@@ -62,9 +68,7 @@ const api = {
|
||||
|
||||
};
|
||||
|
||||
// Use `contextBridge` APIs to expose Electron APIs to
|
||||
// renderer only if context isolation is enabled, otherwise
|
||||
// just add to the DOM global.
|
||||
// 根据是否启用上下文隔离,使用 contextBridge 暴露 API 或挂载到 window
|
||||
if (process.contextIsolated) {
|
||||
try {
|
||||
contextBridge.exposeInMainWorld('electron', electronAPI);
|
||||
|
||||
@@ -9,9 +9,12 @@ import Dashboard from './views/Dashboard.vue';
|
||||
import Subscribe from './views/Subscribe.vue';
|
||||
import Publish from './views/Publish.vue';
|
||||
import Messages from './views/Messages.vue';
|
||||
import QuickSend from './views/QuickSend.vue';
|
||||
|
||||
// 全局状态
|
||||
const store = useMqttStore();
|
||||
|
||||
// 主题覆盖配置
|
||||
const themeOverrides = {
|
||||
common: {
|
||||
primaryColor: '#0ea5a0',
|
||||
@@ -37,28 +40,46 @@ const themeOverrides = {
|
||||
},
|
||||
};
|
||||
|
||||
// 当前激活的标签页
|
||||
const activeTab = ref('dashboard');
|
||||
// 是否显示服务器编辑弹窗
|
||||
const showServerModal = ref(false);
|
||||
// 当前编辑的服务器数据,null 表示新增
|
||||
const editServerData = ref(null);
|
||||
|
||||
// 组件挂载时初始化 Store
|
||||
onMounted(async () => {
|
||||
await store.init();
|
||||
});
|
||||
|
||||
/**
|
||||
* 打开新增服务器弹窗
|
||||
*/
|
||||
function handleAddServer() {
|
||||
editServerData.value = null;
|
||||
showServerModal.value = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开编辑服务器弹窗
|
||||
* @param {import('@types/jsdoc').MqttServer} server - 要编辑的服务器对象
|
||||
*/
|
||||
function handleEditServer(server) {
|
||||
editServerData.value = server;
|
||||
showServerModal.value = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 服务器保存后的回调
|
||||
*/
|
||||
function handleServerSaved() {
|
||||
// 刷新数据
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换标签页
|
||||
* @param {string} tab - 标签页名称
|
||||
*/
|
||||
function handleSwitchTab(tab) {
|
||||
activeTab.value = tab;
|
||||
}
|
||||
@@ -128,6 +149,14 @@ function handleSwitchTab(tab) {
|
||||
</div>
|
||||
</div>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane
|
||||
name="quick-send"
|
||||
tab="快速发送"
|
||||
>
|
||||
<div class="tab-content">
|
||||
<QuickSend />
|
||||
</div>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</n-layout>
|
||||
|
||||
@@ -143,7 +172,38 @@ function handleSwitchTab(tab) {
|
||||
</n-config-provider>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
<style lang="less">
|
||||
:root {
|
||||
--frost-primary-color: #0ea5a0;
|
||||
--frost-primary-color-hover: #0b8c87;
|
||||
--frost-primary-color-pressed: #0b8c87;
|
||||
--frost-primary-color-suppl: #0ea5a0;
|
||||
--frost-success-color: #22c55e;
|
||||
--frost-success-color-hover: #16a34a;
|
||||
--frost-success-color-pressed: #15803d;
|
||||
--frost-success-color-suppl: #22c55e;
|
||||
--frost-warning-color: #f59e0b;
|
||||
--frost-warning-color-hover: #d97706;
|
||||
--frost-warning-color-pressed: #b45309;
|
||||
--frost-warning-color-suppl: #f59e0b;
|
||||
--frost-error-color: #ef4444;
|
||||
--frost-error-color-hover: #dc2626;
|
||||
--frost-error-color-pressed: #b91c1c;
|
||||
--frost-error-color-suppl: #ef4444;
|
||||
--frost-info-color: #3b82f6;
|
||||
--frost-info-color-hover: #2563eb;
|
||||
--frost-info-color-pressed: #1d4ed8;
|
||||
--frost-info-color-suppl: #3b82f6;
|
||||
--frost-color-text-tertiary: #999;
|
||||
--frost-color-topic: #d25a00;
|
||||
--frost-color-bg-light: #f5f7fa;
|
||||
--frost-color-bg-item: #fafafa;
|
||||
--frost-shadow-black-4: rgba(0, 0, 0, 0.04);
|
||||
--frost-shadow-black-6: rgba(0, 0, 0, 0.06);
|
||||
--frost-shadow-black-8: rgba(0, 0, 0, 0.08);
|
||||
--frost-shadow-black-12: rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
html, body, #app {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
@@ -160,12 +220,12 @@ html, body, #app {
|
||||
padding: 0 0 16px 0;
|
||||
height: calc(100vh - 50px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dashboard-content {
|
||||
&.dashboard-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-scroll-wrapper {
|
||||
@@ -181,29 +241,27 @@ html, body, #app {
|
||||
gap: 12px;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.publish-panel,
|
||||
.messages-panel {
|
||||
.publish-panel,
|
||||
.messages-panel {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.publish-panel {
|
||||
.publish-panel {
|
||||
box-sizing: border-box;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.messages-panel {
|
||||
.messages-panel {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.message-center-layout {
|
||||
@media (max-width: 900px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -213,22 +271,27 @@ html, body, #app {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(144, 147, 153, 0.5) transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
|
||||
&-track {
|
||||
background-color: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
}
|
||||
|
||||
&-thumb {
|
||||
background-color: rgba(144, 147, 153, 0.5);
|
||||
border-radius: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(144, 147, 153, 0.8);
|
||||
}
|
||||
::-webkit-scrollbar-corner {
|
||||
}
|
||||
}
|
||||
|
||||
&-corner {
|
||||
display: none;
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import { NModal, NForm, NFormItem, NGrid, NFormItemGi, NInput, NInputNumber, NSelect, NSwitch, NButton, useMessage } from 'naive-ui';
|
||||
import { NModal, NForm, NGrid, NFormItemGi, NInput, NInputNumber, NSelect, NSwitch, NButton, useMessage } from 'naive-ui';
|
||||
import { useMqttStore } from '../stores/mqtt.js';
|
||||
|
||||
// 弹窗显示状态与编辑数据
|
||||
const props = defineProps({
|
||||
show: Boolean,
|
||||
editServer: Object,
|
||||
});
|
||||
|
||||
// 更新 show、保存完成事件
|
||||
const emit = defineEmits(['update:show', 'saved']);
|
||||
|
||||
const store = useMqttStore();
|
||||
const msg = useMessage();
|
||||
|
||||
// 表单引用与数据
|
||||
const formRef = ref(null);
|
||||
const form = ref({
|
||||
name: '',
|
||||
@@ -24,14 +27,19 @@ const form = ref({
|
||||
password: '',
|
||||
keepAlive: 60,
|
||||
cleanSession: true,
|
||||
connectTimeout: 10000,
|
||||
reconnect: true,
|
||||
reconnectInterval: 5000,
|
||||
});
|
||||
|
||||
// 表单校验规则
|
||||
const rules = {
|
||||
name: { required: true, message: '请输入服务器名称', trigger: 'blur' },
|
||||
host: { required: true, message: '请输入主机地址', trigger: 'blur' },
|
||||
port: { required: true, type: 'number', message: '请输入端口号', trigger: 'blur' },
|
||||
};
|
||||
|
||||
// 协议下拉选项
|
||||
const protocolOptions = [
|
||||
{ label: 'mqtt:// (默认)', value: 'mqtt' },
|
||||
{ label: 'mqtts:// (TLS)', value: 'mqtts' },
|
||||
@@ -39,8 +47,12 @@ const protocolOptions = [
|
||||
{ label: 'wss:// (WebSocket TLS)', value: 'wss' },
|
||||
];
|
||||
|
||||
// 是否为编辑模式
|
||||
const isEdit = ref(false);
|
||||
|
||||
/**
|
||||
* 监听弹窗显示状态,打开时初始化表单
|
||||
*/
|
||||
watch(
|
||||
() => props.show,
|
||||
(val) => {
|
||||
@@ -54,9 +66,12 @@ watch(
|
||||
protocol: props.editServer.protocol || 'mqtt',
|
||||
clientId: props.editServer.clientId || '',
|
||||
username: props.editServer.username || '',
|
||||
password: '',
|
||||
password: props.editServer.password || '',
|
||||
keepAlive: props.editServer.keepAlive || 60,
|
||||
cleanSession: props.editServer.cleanSession !== false,
|
||||
connectTimeout: props.editServer.connectTimeout ?? 10000,
|
||||
reconnect: props.editServer.reconnect !== false,
|
||||
reconnectInterval: props.editServer.reconnectInterval ?? 5000,
|
||||
};
|
||||
} else {
|
||||
isEdit.value = false;
|
||||
@@ -70,12 +85,18 @@ watch(
|
||||
password: '',
|
||||
keepAlive: 60,
|
||||
cleanSession: true,
|
||||
connectTimeout: 10000,
|
||||
reconnect: true,
|
||||
reconnectInterval: 5000,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 保存服务器配置
|
||||
*/
|
||||
async function handleSave() {
|
||||
|
||||
try {
|
||||
@@ -183,13 +204,47 @@ async function handleSave() {
|
||||
<n-input
|
||||
v-model:value="form.password"
|
||||
type="password"
|
||||
show-password-on="click"
|
||||
placeholder="可选"
|
||||
/>
|
||||
</n-form-item-gi>
|
||||
</n-grid>
|
||||
<n-form-item label="Clean Session">
|
||||
<n-grid
|
||||
:cols="2"
|
||||
:x-gap="12"
|
||||
>
|
||||
<n-form-item-gi label="Clean Session">
|
||||
<n-switch v-model:value="form.cleanSession" />
|
||||
</n-form-item>
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi label="自动重连">
|
||||
<n-switch v-model:value="form.reconnect" />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi
|
||||
label="连接超时 (毫秒)"
|
||||
path="connectTimeout"
|
||||
>
|
||||
<n-input-number
|
||||
v-model:value="form.connectTimeout"
|
||||
:min="1000"
|
||||
:max="120000"
|
||||
:step="1000"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi
|
||||
label="重连间隔 (毫秒)"
|
||||
path="reconnectInterval"
|
||||
>
|
||||
<n-input-number
|
||||
v-model:value="form.reconnectInterval"
|
||||
:min="1000"
|
||||
:max="300000"
|
||||
:step="1000"
|
||||
:disabled="!form.reconnect"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</n-form-item-gi>
|
||||
</n-grid>
|
||||
</n-form>
|
||||
<template #footer>
|
||||
<div style="display: flex; justify-content: flex-end; gap: 8px">
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { NLayoutSider, NButton, NTag, useMessage, useDialog } from 'naive-ui';
|
||||
import { useMqttStore } from '../stores/mqtt.js';
|
||||
|
||||
// 组件事件定义
|
||||
const emit = defineEmits(['add-server', 'edit-server']);
|
||||
|
||||
// 全局状态与消息/对话框 API
|
||||
const store = useMqttStore();
|
||||
const message = useMessage();
|
||||
const dialog = useDialog();
|
||||
|
||||
// 文件输入控件引用,用于导入配置
|
||||
const fileInput = ref(null);
|
||||
|
||||
/**
|
||||
* 切换服务器连接状态
|
||||
* @param {import('@types/jsdoc').MqttServer} server - 服务器对象
|
||||
*/
|
||||
async function handleConnect(server) {
|
||||
if (server.status === 'connected') {
|
||||
await store.disconnectServer(server.id);
|
||||
@@ -16,10 +27,18 @@ async function handleConnect(server) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑服务器
|
||||
* @param {import('@types/jsdoc').MqttServer} server - 服务器对象
|
||||
*/
|
||||
function handleEdit(server) {
|
||||
emit('edit-server', server);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除服务器,弹出确认对话框
|
||||
* @param {import('@types/jsdoc').MqttServer} server - 服务器对象
|
||||
*/
|
||||
function handleDelete(server) {
|
||||
dialog.warning({
|
||||
title: '确认删除',
|
||||
@@ -32,6 +51,50 @@ function handleDelete(server) {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出服务器配置到本地 JSON 文件
|
||||
*/
|
||||
async function handleExport() {
|
||||
try {
|
||||
let data = await store.exportServers();
|
||||
let blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||
let url = URL.createObjectURL(blob);
|
||||
let a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `frost-mqtt-servers-${new Date().toISOString().slice(0, 10)}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
message.success('服务器配置已导出');
|
||||
} catch (e) {
|
||||
message.error('导出失败: ' + (e.message || '未知错误'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发文件导入点击
|
||||
*/
|
||||
function handleImportClick() {
|
||||
fileInput.value?.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理导入的 JSON 配置文件
|
||||
* @param {Event} event - 文件选择事件
|
||||
*/
|
||||
async function handleImportFile(event) {
|
||||
let file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
let text = await file.text();
|
||||
let data = JSON.parse(text);
|
||||
await store.importServers(data);
|
||||
message.success('服务器配置已导入');
|
||||
} catch (e) {
|
||||
message.error('导入失败: ' + (e.message || '未知错误'));
|
||||
}
|
||||
event.target.value = '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -50,14 +113,16 @@ function handleDelete(server) {
|
||||
>
|
||||
<div class="sidebar-container">
|
||||
|
||||
<!-- 侧边栏头部 Logo 与标题 -->
|
||||
<div class="sidebar-header">
|
||||
<div class="sidebar-logo">F</div>
|
||||
<div>
|
||||
<div class="sidebar-title">Frost MQTT Client</div>
|
||||
<div class="sidebar-subtitle">多服务器 MQTT 客户端</div>
|
||||
<div class="sidebar-subtitle">多功能 MQTT 客户端</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 服务器列表 -->
|
||||
<div class="server-list">
|
||||
<div
|
||||
v-for="srv in store.servers.value"
|
||||
@@ -119,19 +184,39 @@ function handleDelete(server) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 侧边栏底部操作按钮 -->
|
||||
<div class="sidebar-footer">
|
||||
<n-button
|
||||
type="primary"
|
||||
block
|
||||
@click="$emit('add-server')"
|
||||
> + 添加服务器</n-button>
|
||||
<div class="sidebar-import-export">
|
||||
<n-button
|
||||
size="small"
|
||||
block
|
||||
@click="handleImportClick"
|
||||
>导入配置</n-button>
|
||||
<n-button
|
||||
size="small"
|
||||
block
|
||||
@click="handleExport"
|
||||
>导出配置</n-button>
|
||||
</div>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
style="display: none"
|
||||
@change="handleImportFile"
|
||||
>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</n-layout-sider>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
<style scoped lang="less">
|
||||
:deep(.sidebar-scroll-container) {
|
||||
height: 100%;
|
||||
overflow: hidden !important;
|
||||
@@ -157,7 +242,7 @@ function handleDelete(server) {
|
||||
.sidebar-logo {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: #0ea5a0;
|
||||
background: var(--frost-primary-color);
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -193,15 +278,19 @@ function handleDelete(server) {
|
||||
margin-bottom: 4px;
|
||||
border: 1px solid transparent;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.server-item:hover {
|
||||
&:hover {
|
||||
background: var(--n-color-hover);
|
||||
}
|
||||
|
||||
.server-item.active {
|
||||
.server-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--n-color-pressed);
|
||||
border-color: #0ea5a0;
|
||||
border-color: var(--frost-primary-color);
|
||||
}
|
||||
}
|
||||
|
||||
.server-item-top {
|
||||
@@ -225,23 +314,19 @@ function handleDelete(server) {
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.server-item:hover .server-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.server-url {
|
||||
margin-top: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.server-url :deep(.n-tag) {
|
||||
:deep(.n-tag) {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.server-url :deep(.n-tag__content) {
|
||||
:deep(.n-tag__content) {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.server-bottom {
|
||||
@@ -262,23 +347,23 @@ function handleDelete(server) {
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.status-dot.connected {
|
||||
background: #22c55e;
|
||||
}
|
||||
&.connected {
|
||||
background: var(--frost-success-color);
|
||||
}
|
||||
|
||||
.status-dot.connecting {
|
||||
background: #f59e0b;
|
||||
&.connecting {
|
||||
background: var(--frost-warning-color);
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.status-dot.disconnected {
|
||||
background: #999;
|
||||
}
|
||||
&.disconnected {
|
||||
background: var(--frost-color-text-tertiary);
|
||||
}
|
||||
|
||||
.status-dot.error {
|
||||
background: #ef4444;
|
||||
&.error {
|
||||
background: var(--frost-error-color);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
@@ -297,4 +382,11 @@ function handleDelete(server) {
|
||||
border-top: 1px solid var(--n-border-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-import-export {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,6 +2,9 @@ import { createApp } from 'vue';
|
||||
|
||||
import App from './App.vue';
|
||||
|
||||
/**
|
||||
* 创建并挂载 Vue 应用
|
||||
*/
|
||||
const app = createApp(App);
|
||||
|
||||
app.mount('#app');
|
||||
|
||||
@@ -1,15 +1,129 @@
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
// 全局状态(单例)
|
||||
// ==================== 类型定义 ====================
|
||||
|
||||
/**
|
||||
* 从公共类型定义文件引入 MQTT 相关类型
|
||||
* @typedef {import('@types/jsdoc').MqttQoS} MqttQoS
|
||||
* @typedef {import('@types/jsdoc').MqttServerStatus} MqttServerStatus
|
||||
* @typedef {import('@types/jsdoc').MqttTopic} MqttTopic
|
||||
* @typedef {import('@types/jsdoc').TopicConfig} TopicConfig
|
||||
* @typedef {import('@types/jsdoc').ServerConfig} ServerConfig
|
||||
* @typedef {import('@types/jsdoc').MqttServer} MqttServer
|
||||
* @typedef {import('@types/jsdoc').PublishOptions} PublishOptions
|
||||
* @typedef {import('@types/jsdoc').MqttMessage} MqttMessage
|
||||
* @typedef {import('@types/jsdoc').PublishRecord} PublishRecord
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} StatusChangeData 服务器状态变更事件数据
|
||||
* @property {string} id 服务器 id
|
||||
* @property {MqttServerStatus} status 新状态
|
||||
* @property {string} [error] 错误信息(状态为 error 时存在)
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} MessageEventData 消息接收事件数据
|
||||
* @property {string} serverId 所属服务器 id
|
||||
* @property {MqttMessage} message 消息对象
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} SubscriptionChangeData 订阅状态变更事件数据
|
||||
* @property {string} serverId 所属服务器 id
|
||||
* @property {string} topic 主题名称
|
||||
* @property {string} [topicId] 主题 id
|
||||
* @property {string} [comment] 主题备注
|
||||
* @property {boolean} subscribed 是否已订阅
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} MqttStore
|
||||
* @property {import('vue').Ref<MqttServer[]>} servers 所有服务器列表
|
||||
* @property {import('vue').Ref<string | null>} activeServerId 当前活动服务器 ID
|
||||
* @property {import('vue').ComputedRef<MqttServer | null>} activeServer 当前活动服务器对象
|
||||
* @property {import('vue').Ref<MqttMessage[]>} messages 当前活动服务器消息列表
|
||||
* @property {import('vue').Ref<PublishRecord[]>} publishHistory 当前活动服务器发布历史
|
||||
* @property {import('vue').Ref<boolean>} loading 全局加载状态
|
||||
* @property {import('vue').ComputedRef<MqttServer[]>} connectedServers 已连接服务器列表
|
||||
* @property {import('vue').ComputedRef<number>} totalTopics 所有服务器主题总数
|
||||
* @property {import('vue').ComputedRef<number>} subscribedTopics 已订阅主题总数
|
||||
* @property {import('vue').ComputedRef<number>} totalMessages 当前活动服务器消息总数
|
||||
* @property {() => Promise<void>} init 初始化 Store
|
||||
* @property {(id: string | null) => void} setActiveServer 设置当前活动服务器
|
||||
* @property {(config: ServerConfig) => Promise<MqttServer>} addServer 添加服务器
|
||||
* @property {(id: string, config: ServerConfig) => Promise<MqttServer | null>} updateServer 更新服务器
|
||||
* @property {(id: string) => Promise<void>} deleteServer 删除服务器
|
||||
* @property {() => Promise<ServerConfig[]>} exportServers 导出服务器配置
|
||||
* @property {(serverList: ServerConfig[]) => Promise<MqttServer[]>} importServers 导入服务器配置
|
||||
* @property {(id: string) => Promise<boolean>} connectServer 连接服务器
|
||||
* @property {(id: string) => Promise<void>} disconnectServer 断开服务器
|
||||
* @property {(serverId: string, topic: string, qos: MqttQoS, comment: string) => Promise<MqttTopic | null>} addTopic 添加主题
|
||||
* @property {(serverId: string, topic: string) => Promise<void>} removeTopic 移除主题
|
||||
* @property {(serverId: string, topic: string) => Promise<boolean>} subscribeTopic 订阅主题
|
||||
* @property {(serverId: string, topic: string) => Promise<boolean>} unsubscribeTopic 取消订阅主题
|
||||
* @property {(serverId: string) => Promise<boolean>} subscribeAllTopics 批量订阅所有主题
|
||||
* @property {(serverId: string, topicId: string, topic: string, qos: MqttQoS, comment: string) => Promise<boolean>} updateTopic 更新主题
|
||||
* @property {(serverId: string, topic: string, payload: string, opts: PublishOptions) => Promise<boolean>} publishMessage 发布消息
|
||||
* @property {() => Promise<void>} refreshMessages 刷新消息列表
|
||||
* @property {() => Promise<void>} refreshPublishHistory 刷新发布历史
|
||||
* @property {() => Promise<void>} clearMessages 清空消息列表
|
||||
* @property {() => Promise<void>} clearPublishHistory 清空发布历史
|
||||
*/
|
||||
|
||||
// ==================== 全局状态(单例) ====================
|
||||
|
||||
/**
|
||||
* 所有已配置的服务器列表,按名称排序
|
||||
* @type {import('vue').Ref<MqttServer[]>}
|
||||
*/
|
||||
const servers = ref([]);
|
||||
|
||||
/**
|
||||
* 当前在界面中选中的服务器 ID
|
||||
* @type {import('vue').Ref<string | null>}
|
||||
*/
|
||||
const activeServerId = ref(null);
|
||||
|
||||
/**
|
||||
* 当前活动服务器的消息列表(包含订阅接收与本地发布的消息)
|
||||
* @type {import('vue').Ref<MqttMessage[]>}
|
||||
*/
|
||||
const messages = ref([]);
|
||||
|
||||
/**
|
||||
* 当前活动服务器的发布历史记录
|
||||
* @type {import('vue').Ref<PublishRecord[]>}
|
||||
*/
|
||||
const publishHistory = ref([]);
|
||||
|
||||
/**
|
||||
* Store 初始化或异步操作时的全局加载状态
|
||||
* @type {import('vue').Ref<boolean>}
|
||||
*/
|
||||
const loading = ref(false);
|
||||
|
||||
// 清理函数集合
|
||||
/**
|
||||
* IPC 监听器清理函数集合
|
||||
* @type {Array<() => void>}
|
||||
*/
|
||||
let cleanupFns = [];
|
||||
|
||||
/**
|
||||
* 对指定服务器的主题列表按主题名称升序排序
|
||||
* @param {MqttServer} server - 服务器对象
|
||||
* @returns {void}
|
||||
*/
|
||||
function sortTopics(server) {
|
||||
if (server && server.topics) {
|
||||
server.topics.sort((a, b) => a.topic.localeCompare(b.topic));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置主进程事件监听器
|
||||
* 包括状态变化、消息接收、订阅变化等事件
|
||||
*/
|
||||
function setupListeners() {
|
||||
|
||||
// 清理旧监听器
|
||||
@@ -17,61 +131,106 @@ function setupListeners() {
|
||||
cleanupFns = [];
|
||||
|
||||
cleanupFns.push(
|
||||
window.api.onStatusChange((data) => {
|
||||
const srv = servers.value.find((s) => s.id === data.id);
|
||||
window.api.onStatusChange((/** @type {StatusChangeData} */ data) => {
|
||||
|
||||
let srv = servers.value.find((s) => {
|
||||
return s.id === data.id;
|
||||
});
|
||||
|
||||
if (srv) {
|
||||
|
||||
srv.status = data.status;
|
||||
|
||||
if (data.status === 'disconnected') {
|
||||
srv.topics.forEach((t) => {
|
||||
t.subscribed = false;
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
})
|
||||
);
|
||||
|
||||
cleanupFns.push(
|
||||
window.api.onMessage((data) => {
|
||||
window.api.onMessage((/** @type {MessageEventData} */ data) => {
|
||||
if (data.serverId === activeServerId.value) {
|
||||
|
||||
messages.value.unshift(data.message);
|
||||
|
||||
if (messages.value.length > 500) {
|
||||
messages.value.pop();
|
||||
}
|
||||
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
cleanupFns.push(
|
||||
window.api.onSubscriptionChange((data) => {
|
||||
window.api.onSubscriptionChange((/** @type {SubscriptionChangeData} */ data) => {
|
||||
if (data.serverId === activeServerId.value) {
|
||||
const srv = servers.value.find((s) => s.id === data.serverId);
|
||||
|
||||
let srv = servers.value.find((s) => {
|
||||
return s.id === data.serverId;
|
||||
});
|
||||
|
||||
if (srv) {
|
||||
const t = srv.topics.find((t) => t.topic === data.topic || t.id === data.topicId);
|
||||
|
||||
let t = srv.topics.find((t) => {
|
||||
return t.topic === data.topic || t.id === data.topicId;
|
||||
});
|
||||
|
||||
if (t) {
|
||||
t.subscribed = data.subscribed;
|
||||
if (typeof data.comment === 'string') {
|
||||
t.comment = data.comment;
|
||||
}
|
||||
if (data.topic && data.topic !== t.topic) {
|
||||
t.topic = data.topic;
|
||||
sortTopics(srv);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* MQTT 全局状态管理 Store
|
||||
* @returns {MqttStore} 包含状态、计算属性和方法的对象
|
||||
*/
|
||||
export function useMqttStore() {
|
||||
|
||||
/**
|
||||
* 当前选中的服务器
|
||||
* @type {import('vue').ComputedRef<MqttServer | null>}
|
||||
*/
|
||||
let activeServer = computed(
|
||||
() => servers.value.find((s) => s.id === activeServerId.value) || null
|
||||
);
|
||||
|
||||
/**
|
||||
* 已连接的服务器列表
|
||||
* @type {import('vue').ComputedRef<MqttServer[]>}
|
||||
*/
|
||||
let connectedServers = computed(() => servers.value.filter((s) => s.status === 'connected'));
|
||||
|
||||
/**
|
||||
* 所有服务器主题总数
|
||||
* @type {import('vue').ComputedRef<number>}
|
||||
*/
|
||||
let totalTopics = computed(() =>
|
||||
servers.value.reduce((sum, s) => sum + (s.topics?.length || 0), 0)
|
||||
);
|
||||
|
||||
/**
|
||||
* 已订阅主题总数
|
||||
* @type {import('vue').ComputedRef<number>}
|
||||
*/
|
||||
let subscribedTopics = computed(() =>
|
||||
servers.value.reduce(
|
||||
(sum, s) => sum + (s.topics?.filter((t) => t.subscribed)?.length || 0),
|
||||
@@ -79,9 +238,26 @@ export function useMqttStore() {
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
* 当前活动服务器消息总数
|
||||
* @type {import('vue').ComputedRef<number>}
|
||||
*/
|
||||
let totalMessages = computed(() => messages.value.length);
|
||||
|
||||
/**
|
||||
* 按服务器名称对服务器列表排序(中文环境)
|
||||
* @returns {void}
|
||||
*/
|
||||
function sortServers() {
|
||||
servers.value.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'));
|
||||
}
|
||||
|
||||
// ==================== 初始化 ====================
|
||||
|
||||
/**
|
||||
* 初始化 Store,从主进程加载服务器列表并设置监听
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function init() {
|
||||
|
||||
loading.value = true;
|
||||
@@ -91,6 +267,8 @@ export function useMqttStore() {
|
||||
let data = await window.api.getServers();
|
||||
|
||||
servers.value = data;
|
||||
sortServers();
|
||||
servers.value.forEach((srv) => sortTopics(srv));
|
||||
setupListeners();
|
||||
|
||||
} catch (e) {
|
||||
@@ -102,6 +280,11 @@ export function useMqttStore() {
|
||||
}
|
||||
|
||||
// ==================== 服务器管理 ====================
|
||||
|
||||
/**
|
||||
* 设置当前活动服务器并刷新相关数据
|
||||
* @param {string|null} id - 服务器 id
|
||||
*/
|
||||
function setActiveServer(id) {
|
||||
|
||||
activeServerId.value = id;
|
||||
@@ -112,11 +295,17 @@ export function useMqttStore() {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加服务器
|
||||
* @param {ServerConfig} config - 服务器配置
|
||||
* @returns {Promise<MqttServer>} 添加后的服务器对象
|
||||
*/
|
||||
async function addServer(config) {
|
||||
|
||||
let server = await window.api.addServer(config);
|
||||
|
||||
servers.value.push(server);
|
||||
sortServers();
|
||||
|
||||
if (!activeServerId.value) {
|
||||
activeServerId.value = server.id;
|
||||
@@ -126,6 +315,12 @@ export function useMqttStore() {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新服务器配置
|
||||
* @param {string} id - 服务器 id
|
||||
* @param {ServerConfig} config - 新的服务器配置
|
||||
* @returns {Promise<MqttServer | null>} 更新后的服务器对象或 null
|
||||
*/
|
||||
async function updateServer(id, config) {
|
||||
|
||||
let result = await window.api.updateServer(id, config);
|
||||
@@ -135,17 +330,24 @@ export function useMqttStore() {
|
||||
if (idx !== -1) {
|
||||
servers.value[idx] = { ...servers.value[idx], ...result };
|
||||
}
|
||||
sortServers();
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除服务器
|
||||
* @param {string} id - 服务器 id
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function deleteServer(id) {
|
||||
|
||||
await window.api.deleteServer(id);
|
||||
|
||||
servers.value = servers.value.filter((s) => s.id !== id);
|
||||
sortServers();
|
||||
|
||||
if (activeServerId.value === id) {
|
||||
activeServerId.value = servers.value.length > 0 ? servers.value[0].id : null;
|
||||
@@ -153,23 +355,67 @@ export function useMqttStore() {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出所有服务器配置
|
||||
* @returns {Promise<ServerConfig[]>} 服务器配置数组
|
||||
*/
|
||||
async function exportServers() {
|
||||
return await window.api.exportServers();
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入服务器配置列表
|
||||
* @param {ServerConfig[]} serverList - 服务器配置数组
|
||||
* @returns {Promise<MqttServer[]>} 导入后的服务器列表
|
||||
*/
|
||||
async function importServers(serverList) {
|
||||
let data = await window.api.importServers(serverList);
|
||||
servers.value = data;
|
||||
sortServers();
|
||||
servers.value.forEach((srv) => sortTopics(srv));
|
||||
if (servers.value.length > 0 && !activeServerId.value) {
|
||||
activeServerId.value = servers.value[0].id;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接指定服务器
|
||||
* @param {string} id - 服务器 id
|
||||
* @returns {Promise<boolean>} 是否成功发起连接
|
||||
*/
|
||||
async function connectServer(id) {
|
||||
return await window.api.connect(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 断开指定服务器
|
||||
* @param {string} id - 服务器 id
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function disconnectServer(id) {
|
||||
await window.api.disconnect(id);
|
||||
}
|
||||
|
||||
// ==================== 主题管理 ====================
|
||||
async function addTopic(serverId, topic, qos) {
|
||||
|
||||
let result = await window.api.addTopic(serverId, topic, qos);
|
||||
/**
|
||||
* 为指定服务器添加主题
|
||||
* @param {string} serverId - 服务器 id
|
||||
* @param {string} topic - 主题名称
|
||||
* @param {MqttQoS} qos - QoS 等级
|
||||
* @param {string} comment - 主题备注
|
||||
* @returns {Promise<MqttTopic | null>} 添加后的主题对象或 null
|
||||
*/
|
||||
async function addTopic(serverId, topic, qos, comment) {
|
||||
|
||||
let result = await window.api.addTopic(serverId, topic, qos, comment);
|
||||
|
||||
if (result) {
|
||||
let srv = servers.value.find((s) => s.id === serverId);
|
||||
if (srv) {
|
||||
srv.topics.push({ topic, qos, subscribed: false });
|
||||
srv.topics.push(result);
|
||||
sortTopics(srv);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,6 +423,12 @@ export function useMqttStore() {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除指定服务器的主题
|
||||
* @param {string} serverId - 服务器 id
|
||||
* @param {string} topic - 主题名称
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function removeTopic(serverId, topic) {
|
||||
|
||||
await window.api.removeTopic(serverId, topic);
|
||||
@@ -185,27 +437,63 @@ export function useMqttStore() {
|
||||
|
||||
if (srv) {
|
||||
srv.topics = srv.topics.filter((t) => t.topic !== topic);
|
||||
sortTopics(srv);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅指定主题
|
||||
* @param {string} serverId - 服务器 id
|
||||
* @param {string} topic - 主题名称
|
||||
* @returns {Promise<boolean>} 是否发起订阅
|
||||
*/
|
||||
async function subscribeTopic(serverId, topic) {
|
||||
return await window.api.subscribe(serverId, topic);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消订阅指定主题
|
||||
* @param {string} serverId - 服务器 id
|
||||
* @param {string} topic - 主题名称
|
||||
* @returns {Promise<boolean>} 是否发起取消订阅
|
||||
*/
|
||||
async function unsubscribeTopic(serverId, topic) {
|
||||
return await window.api.unsubscribe(serverId, topic);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量订阅指定服务器下所有未订阅主题
|
||||
* @param {string} serverId - 服务器 id
|
||||
* @returns {Promise<boolean>} 是否发起批量订阅
|
||||
*/
|
||||
async function subscribeAllTopics(serverId) {
|
||||
return await window.api.subscribeAll(serverId);
|
||||
}
|
||||
|
||||
async function updateTopic(serverId, topicId, topic, qos) {
|
||||
return await window.api.updateTopic(serverId, topicId, topic, qos);
|
||||
/**
|
||||
* 更新指定服务器的主题信息
|
||||
* @param {string} serverId - 服务器 id
|
||||
* @param {string} topicId - 主题 id
|
||||
* @param {string} topic - 新的主题名称
|
||||
* @param {MqttQoS} qos - 新的 QoS 等级
|
||||
* @param {string} comment - 新的主题备注
|
||||
* @returns {Promise<boolean>} 是否更新成功
|
||||
*/
|
||||
async function updateTopic(serverId, topicId, topic, qos, comment) {
|
||||
return await window.api.updateTopic(serverId, topicId, topic, qos, comment);
|
||||
}
|
||||
|
||||
// ==================== 消息发布 ====================
|
||||
|
||||
/**
|
||||
* 发布消息到指定主题
|
||||
* @param {string} serverId - 服务器 id
|
||||
* @param {string} topic - 目标主题
|
||||
* @param {string} payload - 消息内容
|
||||
* @param {PublishOptions} opts - 发布选项
|
||||
* @returns {Promise<boolean>} 是否发起发布
|
||||
*/
|
||||
async function publishMessage(serverId, topic, payload, opts) {
|
||||
|
||||
let result = await window.api.publish(serverId, topic, payload, opts);
|
||||
@@ -220,18 +508,30 @@ export function useMqttStore() {
|
||||
|
||||
// ==================== 消息管理 ====================
|
||||
|
||||
/**
|
||||
* 刷新当前活动服务器的消息列表
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function refreshMessages() {
|
||||
if (activeServerId.value) {
|
||||
messages.value = await window.api.getMessages(activeServerId.value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新当前活动服务器的发布历史
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function refreshPublishHistory() {
|
||||
if (activeServerId.value) {
|
||||
publishHistory.value = await window.api.getPublishHistory(activeServerId.value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空当前活动服务器的消息列表
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function clearMessages() {
|
||||
if (activeServerId.value) {
|
||||
await window.api.clearMessages(activeServerId.value);
|
||||
@@ -239,6 +539,10 @@ export function useMqttStore() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空当前活动服务器的发布历史
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function clearPublishHistory() {
|
||||
if (activeServerId.value) {
|
||||
await window.api.clearPublishHistory(activeServerId.value);
|
||||
@@ -265,6 +569,8 @@ export function useMqttStore() {
|
||||
addServer,
|
||||
updateServer,
|
||||
deleteServer,
|
||||
exportServers,
|
||||
importServers,
|
||||
connectServer,
|
||||
disconnectServer,
|
||||
addTopic,
|
||||
|
||||
@@ -2,9 +2,18 @@
|
||||
import { useMqttStore } from '../stores/mqtt.js';
|
||||
import { NGrid, NGridItem, NCard, NStatistic, NTag, NDescriptions, NDescriptionsItem, NSpace, NButton, NList, NListItem, useMessage } from 'naive-ui';
|
||||
|
||||
// 全局状态与消息 API
|
||||
const store = useMqttStore();
|
||||
const msg = useMessage();
|
||||
|
||||
// 组件事件定义
|
||||
const emit = defineEmits(['switch-tab', 'edit-server']);
|
||||
|
||||
/**
|
||||
* 处理快捷操作:切换标签页并选中服务器
|
||||
* @param {import('@types/jsdoc').MqttServer} srv - 服务器对象
|
||||
* @param {string} action - 目标标签页名称
|
||||
*/
|
||||
function handleAction(srv, action) {
|
||||
if (srv.id === store.activeServerId.value) {
|
||||
emit('switch-tab', action);
|
||||
@@ -14,6 +23,10 @@ function handleAction(srv, action) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换当前服务器连接状态
|
||||
* @param {import('@types/jsdoc').MqttServer} srv - 服务器对象
|
||||
*/
|
||||
async function handleToggleConnection(srv) {
|
||||
if (srv.status === 'connected') {
|
||||
await store.disconnectServer(srv.id);
|
||||
@@ -22,8 +35,6 @@ async function handleToggleConnection(srv) {
|
||||
await store.connectServer(srv.id);
|
||||
}
|
||||
}
|
||||
|
||||
const emit = defineEmits(['switch-tab', 'edit-server']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -178,25 +189,27 @@ const emit = defineEmits(['switch-tab', 'edit-server']);
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
<style scoped lang="less">
|
||||
.dashboard {
|
||||
max-width: 900px;
|
||||
}
|
||||
|
||||
.server-descriptions :deep(.n-descriptions-table) {
|
||||
.server-descriptions {
|
||||
:deep(.n-descriptions-table) {
|
||||
table-layout: fixed;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.server-descriptions :deep(.n-descriptions-table-header),
|
||||
.server-descriptions :deep(.n-descriptions-table-content) {
|
||||
:deep(.n-descriptions-table-header),
|
||||
:deep(.n-descriptions-table-content) {
|
||||
width: 25%;
|
||||
}
|
||||
}
|
||||
|
||||
.server-descriptions :deep(.n-descriptions-table-content) {
|
||||
:deep(.n-descriptions-table-content) {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
@@ -204,23 +217,23 @@ const emit = defineEmits(['switch-tab', 'edit-server']);
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.status-dot.connected {
|
||||
background: #22c55e;
|
||||
}
|
||||
&.connected {
|
||||
background: var(--frost-success-color);
|
||||
}
|
||||
|
||||
.status-dot.connecting {
|
||||
background: #f59e0b;
|
||||
&.connecting {
|
||||
background: var(--frost-warning-color);
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.status-dot.disconnected {
|
||||
background: #999;
|
||||
}
|
||||
&.disconnected {
|
||||
background: var(--frost-color-text-tertiary);
|
||||
}
|
||||
|
||||
.status-dot.error {
|
||||
background: #ef4444;
|
||||
&.error {
|
||||
background: var(--frost-error-color);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useMqttStore } from '../stores/mqtt.js';
|
||||
import { NCard, NSpace, NInput, NButton, NEmpty, useMessage } from 'naive-ui';
|
||||
import { NCard, NSelect, NButton, NEmpty, useMessage } from 'naive-ui';
|
||||
|
||||
// 全局状态与消息 API
|
||||
const store = useMqttStore();
|
||||
const message = useMessage();
|
||||
|
||||
const filter = ref('');
|
||||
// 已选中的过滤主题列表
|
||||
const filter = ref([]);
|
||||
|
||||
/**
|
||||
* 复制主题到剪贴板
|
||||
* @param {string} topic - 主题名称
|
||||
*/
|
||||
async function copyTopic(topic) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(topic);
|
||||
@@ -17,24 +23,79 @@ async function copyTopic(topic) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 可过滤的主题选项(来自已配置主题和已接收消息)
|
||||
* @type {import('vue').ComputedRef<Array<{label: string, value: string}>>}
|
||||
*/
|
||||
const topicOptions = computed(() => {
|
||||
|
||||
let server = store.activeServer.value;
|
||||
let serverTopics = server?.topics?.map((t) => t.topic) || [];
|
||||
let messageTopics = store.messages.value.map((m) => m.topic);
|
||||
let all = [...new Set([...serverTopics, ...messageTopics])];
|
||||
|
||||
all.sort((a, b) => a.localeCompare(b));
|
||||
|
||||
return all.map((topic) => ({ label: topic, value: topic }));
|
||||
|
||||
});
|
||||
|
||||
/**
|
||||
* 判断消息主题是否匹配指定的过滤主题(支持 + 单层通配符与 # 多层通配符)
|
||||
* @param {string} filterTopic - 过滤主题,可包含 + 或 #
|
||||
* @param {string} messageTopic - 消息主题
|
||||
* @returns {boolean} 是否匹配
|
||||
*/
|
||||
function topicMatchesFilter(filterTopic, messageTopic) {
|
||||
|
||||
if (filterTopic === '#') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (filterTopic.endsWith('/#')) {
|
||||
let prefix = filterTopic.slice(0, -2);
|
||||
return messageTopic === prefix || messageTopic.startsWith(prefix + '/');
|
||||
}
|
||||
|
||||
let filterParts = filterTopic.split('/');
|
||||
let messageParts = messageTopic.split('/');
|
||||
|
||||
if (filterParts.length !== messageParts.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return filterParts.every((part, index) => part === '+' || part === messageParts[index]);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据选中主题筛选后的消息列表(支持 + / # 通配符)
|
||||
* @type {import('vue').ComputedRef<Array<Object>>}
|
||||
*/
|
||||
const filteredMessages = computed(() => {
|
||||
|
||||
if (!filter.value.trim()) {
|
||||
if (!filter.value || filter.value.length === 0) {
|
||||
return store.messages.value;
|
||||
}
|
||||
|
||||
let f = filter.value.trim().toLowerCase();
|
||||
return store.messages.value.filter((m) =>
|
||||
filter.value.some((f) => topicMatchesFilter(f, m.topic))
|
||||
);
|
||||
|
||||
return store.messages.value.filter((m) => {
|
||||
return m.topic.toLowerCase().includes(f);
|
||||
});
|
||||
|
||||
})
|
||||
});
|
||||
|
||||
/**
|
||||
* 清空当前活动服务器的消息列表
|
||||
*/
|
||||
async function handleClear() {
|
||||
await store.clearMessages();
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化消息 Payload,若可解析为 JSON 则格式化输出
|
||||
* @param {string} payload - 消息内容
|
||||
* @returns {string} 格式化后的内容
|
||||
*/
|
||||
function formatPayload(payload) {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(payload), null, 2);
|
||||
@@ -61,10 +122,8 @@ function formatPayload(payload) {
|
||||
}"
|
||||
>
|
||||
<template #header>
|
||||
<n-space
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
<div
|
||||
style="display: flex; flex-direction: column; gap: 8px; width: 100%"
|
||||
>
|
||||
<div>
|
||||
<span>消息监控</span>
|
||||
@@ -72,20 +131,26 @@ function formatPayload(payload) {
|
||||
style="font-size: 12px; opacity: 0.55; margin-left: 8px"
|
||||
>实时查看接收和发送的 MQTT 消息</span>
|
||||
</div>
|
||||
<n-space>
|
||||
<n-input
|
||||
<div
|
||||
style="display: flex; align-items: center; gap: 8px; width: 100%"
|
||||
>
|
||||
<n-select
|
||||
v-model:value="filter"
|
||||
size="small"
|
||||
placeholder="过滤主题..."
|
||||
style="width: 180px"
|
||||
style="flex: 1 1 auto; max-width: 480px; min-width: 0"
|
||||
multiple
|
||||
filterable
|
||||
clearable
|
||||
max-tag-count="responsive"
|
||||
:options="topicOptions"
|
||||
/>
|
||||
<n-button
|
||||
size="small"
|
||||
@click="handleClear"
|
||||
>清空消息</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<n-empty
|
||||
v-if="filteredMessages.length === 0"
|
||||
@@ -136,29 +201,29 @@ function formatPayload(payload) {
|
||||
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
<style scoped lang="less">
|
||||
.messages-view {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.messages-view :deep(.n-card) {
|
||||
:deep(.n-card) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.messages-view :deep(.n-card__content) {
|
||||
:deep(.n-card__content) {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.msg-log {
|
||||
background: #f5f7fa;
|
||||
background: var(--frost-color-bg-light);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
height: 100%;
|
||||
@@ -174,7 +239,7 @@ function formatPayload(payload) {
|
||||
|
||||
.msg-entry {
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
border-bottom: 1px solid var(--frost-shadow-black-6);
|
||||
}
|
||||
|
||||
.msg-header {
|
||||
@@ -185,45 +250,51 @@ function formatPayload(payload) {
|
||||
}
|
||||
|
||||
.msg-time {
|
||||
color: #999;
|
||||
color: var(--frost-color-text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.msg-dir {
|
||||
flex-shrink: 0;
|
||||
font-weight: 700;
|
||||
width: 28px;
|
||||
|
||||
&.pub {
|
||||
color: var(--frost-info-color);
|
||||
}
|
||||
|
||||
&.sub {
|
||||
color: var(--frost-success-color);
|
||||
}
|
||||
}
|
||||
.msg-dir.pub {
|
||||
color: #3b82f6;
|
||||
}
|
||||
.msg-dir.sub {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.msg-topic {
|
||||
color: #d25a00;
|
||||
color: var(--frost-color-topic);
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.msg-topic:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.msg-payload {
|
||||
margin: 6px 0 0;
|
||||
padding: 8px 10px;
|
||||
border-radius: 4px;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
background: var(--frost-shadow-black-4);
|
||||
color: #333;
|
||||
font: inherit;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.msg-meta {
|
||||
color: #999;
|
||||
color: var(--frost-color-text-tertiary);
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -3,19 +3,26 @@ import { ref, watch } from 'vue';
|
||||
import { useMqttStore } from '../stores/mqtt.js';
|
||||
import { NCard, NSpace, NFormItem, NInput, NSelect, NSwitch, NButton, NEmpty, NTabs, NTabPane, useMessage } from 'naive-ui';
|
||||
|
||||
// 全局状态与消息 API
|
||||
const store = useMqttStore();
|
||||
const msg = useMessage();
|
||||
|
||||
// 发布表单状态
|
||||
const topic = ref('');
|
||||
const payload = ref('');
|
||||
const qos = ref(1);
|
||||
const retain = ref(false);
|
||||
|
||||
// 当前激活的标签页
|
||||
const activePublishTab = ref('publish');
|
||||
|
||||
// localStorage 持久化 key
|
||||
const formStorageKey = 'mqtt-client.publish-form';
|
||||
const publishTabStorageKey = 'mqtt-client.publish-active-tab';
|
||||
|
||||
let formReady = false;
|
||||
|
||||
// 从 localStorage 恢复发布表单
|
||||
try {
|
||||
|
||||
let savedForm = JSON.parse(localStorage.getItem(formStorageKey) || '{}');
|
||||
@@ -40,6 +47,7 @@ try {
|
||||
localStorage.removeItem(formStorageKey);
|
||||
}
|
||||
|
||||
// 从 localStorage 恢复当前标签页
|
||||
try {
|
||||
|
||||
let savedTab = localStorage.getItem(publishTabStorageKey);
|
||||
@@ -52,6 +60,7 @@ try {
|
||||
localStorage.removeItem(publishTabStorageKey);
|
||||
}
|
||||
|
||||
// 表单变化时持久化
|
||||
watch([topic, payload, qos, retain], () => {
|
||||
|
||||
if (!formReady) {
|
||||
@@ -70,18 +79,23 @@ watch([topic, payload, qos, retain], () => {
|
||||
|
||||
});
|
||||
|
||||
// 标签页变化时持久化
|
||||
watch(activePublishTab, (tab) => {
|
||||
localStorage.setItem(publishTabStorageKey, tab);
|
||||
});
|
||||
|
||||
formReady = true;
|
||||
|
||||
// QoS 下拉选项
|
||||
const qosOptions = [
|
||||
{ label: 'QoS 0 - 最多一次', value: 0 },
|
||||
{ label: 'QoS 1 - 至少一次', value: 1 },
|
||||
{ label: 'QoS 2 - 恰好一次', value: 2 },
|
||||
];
|
||||
|
||||
/**
|
||||
* 发布消息到当前服务器
|
||||
*/
|
||||
async function handlePublish() {
|
||||
|
||||
let srv = store.activeServer.value;
|
||||
@@ -111,6 +125,9 @@ async function handlePublish() {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化 Payload 为 JSON
|
||||
*/
|
||||
function handleFormatJson() {
|
||||
try {
|
||||
|
||||
@@ -124,10 +141,17 @@ function handleFormatJson() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空发布历史
|
||||
*/
|
||||
async function handleClearHistory() {
|
||||
await store.clearPublishHistory();
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制主题到剪贴板
|
||||
* @param {string} topic - 主题名称
|
||||
*/
|
||||
async function copyTopic(topic) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(topic);
|
||||
@@ -275,7 +299,7 @@ async function copyTopic(topic) {
|
||||
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
<style scoped lang="less">
|
||||
.publish-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -283,61 +307,61 @@ async function copyTopic(topic) {
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.publish-view :deep(.n-tabs) {
|
||||
:deep(.n-tabs) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.publish-view :deep(.n-tabs-nav) {
|
||||
:deep(.n-tabs-nav) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.publish-view :deep(.n-tabs-pane-wrapper) {
|
||||
:deep(.n-tabs-pane-wrapper) {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.publish-view :deep(.n-tabs-tab-panels),
|
||||
.publish-view :deep(.n-tabs-pane-wrapper) {
|
||||
:deep(.n-tabs-tab-panels),
|
||||
:deep(.n-tabs-pane-wrapper) {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.publish-view :deep(.n-tab-pane) {
|
||||
:deep(.n-tab-pane) {
|
||||
box-sizing: border-box;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.publish-view :deep(.n-tab-pane > .n-card) {
|
||||
:deep(.n-tab-pane > .n-card) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.publish-view :deep(.n-card__content) {
|
||||
:deep(.n-card__content) {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.publish-view :deep(.n-card__content > div) {
|
||||
> div {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.topic-field {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.topic-field :deep(.n-form-item-blank) {
|
||||
:deep(.n-form-item-blank) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.publish-form {
|
||||
@@ -345,34 +369,34 @@ async function copyTopic(topic) {
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.publish-form .payload-field {
|
||||
.payload-field {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.publish-form .payload-field :deep(.n-form-item-blank) {
|
||||
:deep(.n-form-item-blank) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.publish-form .payload-field :deep(.n-input) {
|
||||
:deep(.n-input) {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.publish-form .payload-field :deep(.n-input__textarea-el),
|
||||
.publish-form .payload-field :deep(textarea) {
|
||||
:deep(.n-input__textarea-el),
|
||||
:deep(textarea) {
|
||||
resize: none !important;
|
||||
height: 100% !important;
|
||||
min-height: 0 !important;
|
||||
font-family: monospace;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.msg-log {
|
||||
background: #f5f7fa;
|
||||
background: var(--frost-color-bg-light);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
height: 100%;
|
||||
@@ -387,7 +411,7 @@ async function copyTopic(topic) {
|
||||
|
||||
.msg-entry {
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
border-bottom: 1px solid var(--frost-shadow-black-6);
|
||||
}
|
||||
|
||||
.msg-header {
|
||||
@@ -398,7 +422,7 @@ async function copyTopic(topic) {
|
||||
}
|
||||
|
||||
.msg-time {
|
||||
color: #999;
|
||||
color: var(--frost-color-text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -406,31 +430,31 @@ async function copyTopic(topic) {
|
||||
flex-shrink: 0;
|
||||
font-weight: 700;
|
||||
width: 28px;
|
||||
}
|
||||
|
||||
.msg-dir.pub {
|
||||
color: #3b82f6;
|
||||
&.pub {
|
||||
color: var(--frost-info-color);
|
||||
}
|
||||
}
|
||||
|
||||
.msg-topic {
|
||||
color: #d25a00;
|
||||
color: var(--frost-color-topic);
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.msg-topic:hover {
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.msg-payload {
|
||||
margin: 6px 0 0;
|
||||
padding: 8px 10px;
|
||||
border-radius: 4px;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
background: var(--frost-shadow-black-4);
|
||||
color: #333;
|
||||
font: inherit;
|
||||
white-space: pre-wrap;
|
||||
@@ -438,7 +462,7 @@ async function copyTopic(topic) {
|
||||
}
|
||||
|
||||
.msg-meta {
|
||||
color: #999;
|
||||
color: var(--frost-color-text-tertiary);
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,843 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue';
|
||||
import { useMqttStore } from '../stores/mqtt.js';
|
||||
import { NCard, NSpace, NFormItem, NInput, NSelect, NSwitch, NButton, NEmpty, NModal, NForm, NGrid, NGridItem, NCheckbox, useMessage, useDialog } from 'naive-ui';
|
||||
|
||||
// 全局状态与消息 API
|
||||
const store = useMqttStore();
|
||||
const msg = useMessage();
|
||||
const dialog = useDialog();
|
||||
|
||||
// localStorage 持久化 key
|
||||
const storageKey = 'mqtt-client.quick-send-list';
|
||||
|
||||
/**
|
||||
* @typedef {Object} QuickSendItem
|
||||
* @property {string} id
|
||||
* @property {string} topic
|
||||
* @property {string} payload
|
||||
* @property {0|1|2} qos
|
||||
* @property {boolean} retain
|
||||
*/
|
||||
|
||||
// 快速发送列表
|
||||
/** @type {import('vue').Ref<QuickSendItem[]>} */
|
||||
const quickList = ref([]);
|
||||
|
||||
// 表单弹窗显隐
|
||||
const showModal = ref(false);
|
||||
const isEdit = ref(false);
|
||||
|
||||
// 表单数据
|
||||
const formId = ref('');
|
||||
const formTopic = ref('');
|
||||
const formPayload = ref('');
|
||||
const formQos = ref(1);
|
||||
const formRetain = ref(false);
|
||||
|
||||
// 从 localStorage 恢复列表
|
||||
try {
|
||||
|
||||
let saved = JSON.parse(localStorage.getItem(storageKey) || '[]');
|
||||
|
||||
if (Array.isArray(saved)) {
|
||||
quickList.value = saved.filter((item) => {
|
||||
return item && typeof item.id === 'string' && typeof item.topic === 'string';
|
||||
});
|
||||
}
|
||||
|
||||
} catch {
|
||||
localStorage.removeItem(storageKey);
|
||||
}
|
||||
|
||||
// 列表变化时持久化
|
||||
watch(
|
||||
quickList,
|
||||
() => {
|
||||
localStorage.setItem(storageKey, JSON.stringify(quickList.value));
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
// QoS 下拉选项
|
||||
const qosOptions = [
|
||||
{ label: 'QoS 0 - 最多一次', value: 0 },
|
||||
{ label: 'QoS 1 - 至少一次', value: 1 },
|
||||
{ label: 'QoS 2 - 恰好一次', value: 2 },
|
||||
];
|
||||
|
||||
// 当前是否有选中的服务器
|
||||
const hasActiveServer = computed(() => Boolean(store.activeServer.value));
|
||||
|
||||
// 拖拽排序状态
|
||||
/** @type {import('vue').Ref<number>} */
|
||||
const dragIndex = ref(-1);
|
||||
/** @type {import('vue').Ref<QuickSendItem | null>} */
|
||||
const dragItem = ref(null);
|
||||
|
||||
// 列表容器引用与响应式布局
|
||||
/** @type {import('vue').Ref<HTMLElement | null>} */
|
||||
const containerRef = ref(null);
|
||||
const columnCount = ref(1);
|
||||
/** @type {ResizeObserver | null} */
|
||||
let resizeObserver = null;
|
||||
|
||||
// 批量操作状态
|
||||
/** @type {import('vue').Ref<string[]>} */
|
||||
const selectedIds = ref([]);
|
||||
/** @type {import('vue').Ref<HTMLInputElement | null>} */
|
||||
const fileInput = ref(null);
|
||||
|
||||
// 是否已全选
|
||||
const isAllSelected = computed(() => {
|
||||
return quickList.value.length > 0 && selectedIds.value.length === quickList.value.length;
|
||||
});
|
||||
|
||||
// 是否有选中项
|
||||
const hasSelected = computed(() => selectedIds.value.length > 0);
|
||||
|
||||
/**
|
||||
* 生成唯一 id
|
||||
* @returns {string}
|
||||
*/
|
||||
function generateId() {
|
||||
return Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开新增弹窗
|
||||
*/
|
||||
function handleAdd() {
|
||||
isEdit.value = false;
|
||||
formId.value = '';
|
||||
formTopic.value = '';
|
||||
formPayload.value = '';
|
||||
formQos.value = 1;
|
||||
formRetain.value = false;
|
||||
showModal.value = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开编辑弹窗
|
||||
* @param {QuickSendItem} item
|
||||
*/
|
||||
function handleEdit(item) {
|
||||
isEdit.value = true;
|
||||
formId.value = item.id;
|
||||
formTopic.value = item.topic;
|
||||
formPayload.value = item.payload;
|
||||
formQos.value = item.qos;
|
||||
formRetain.value = item.retain;
|
||||
showModal.value = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存快速发送项
|
||||
*/
|
||||
function handleSave() {
|
||||
|
||||
let topic = formTopic.value.trim();
|
||||
|
||||
if (!topic) {
|
||||
msg.warning('请输入目标主题');
|
||||
return;
|
||||
}
|
||||
|
||||
let payload = formPayload.value;
|
||||
let qos = formQos.value;
|
||||
let retain = formRetain.value;
|
||||
|
||||
if (isEdit.value) {
|
||||
|
||||
let idx = quickList.value.findIndex((item) => item.id === formId.value);
|
||||
|
||||
if (idx !== -1) {
|
||||
quickList.value[idx] = {
|
||||
...quickList.value[idx],
|
||||
topic,
|
||||
payload,
|
||||
qos,
|
||||
retain,
|
||||
};
|
||||
}
|
||||
|
||||
} else {
|
||||
|
||||
quickList.value.push({
|
||||
id: generateId(),
|
||||
topic,
|
||||
payload,
|
||||
qos,
|
||||
retain,
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
showModal.value = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除快速发送项
|
||||
* @param {QuickSendItem} item
|
||||
*/
|
||||
function handleDelete(item) {
|
||||
|
||||
dialog.warning({
|
||||
title: '确认删除',
|
||||
content: `确定要删除主题 "${item.topic}" 的快速消息吗?`,
|
||||
positiveText: '删除',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: () => {
|
||||
quickList.value = quickList.value.filter((i) => i.id !== item.id);
|
||||
},
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始拖拽
|
||||
* @param {DragEvent} e
|
||||
* @param {QuickSendItem} item
|
||||
* @param {number} index
|
||||
*/
|
||||
function handleDragStart(e, item, index) {
|
||||
dragIndex.value = index;
|
||||
dragItem.value = item;
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', item.id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 拖拽经过目标项
|
||||
* @param {DragEvent} e
|
||||
* @param {number} index
|
||||
*/
|
||||
function handleDragOver(e, index) {
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
}
|
||||
if (dragIndex.value === -1 || dragIndex.value === index) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 放置拖拽项
|
||||
* @param {DragEvent} e
|
||||
* @param {number} index
|
||||
*/
|
||||
function handleDrop(e, index) {
|
||||
e.preventDefault();
|
||||
if (dragIndex.value === -1 || dragIndex.value === index) {
|
||||
return;
|
||||
}
|
||||
|
||||
let item = quickList.value[dragIndex.value];
|
||||
|
||||
quickList.value.splice(dragIndex.value, 1);
|
||||
quickList.value.splice(index, 0, item);
|
||||
}
|
||||
|
||||
/**
|
||||
* 拖拽结束
|
||||
*/
|
||||
function handleDragEnd() {
|
||||
dragIndex.value = -1;
|
||||
dragItem.value = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息到当前服务器
|
||||
* @param {QuickSendItem} item
|
||||
*/
|
||||
async function handleSend(item) {
|
||||
|
||||
let srv = store.activeServer.value;
|
||||
|
||||
if (!srv) {
|
||||
msg.warning('请先在左侧选择一个 MQTT 服务器');
|
||||
return;
|
||||
}
|
||||
|
||||
if (srv.status !== 'connected') {
|
||||
msg.warning('请先连接服务器');
|
||||
return;
|
||||
}
|
||||
|
||||
let result = await store.publishMessage(srv.id, item.topic, item.payload, {
|
||||
qos: item.qos,
|
||||
retain: item.retain,
|
||||
});
|
||||
|
||||
if (result) {
|
||||
msg.success('消息已发布到: ' + item.topic);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制主题到剪贴板
|
||||
* @param {string} topic
|
||||
*/
|
||||
async function copyTopic(topic) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(topic);
|
||||
msg.success('主题已复制');
|
||||
} catch {
|
||||
msg.error('复制失败');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化消息内容为 JSON
|
||||
*/
|
||||
function handleFormatJson() {
|
||||
try {
|
||||
|
||||
let obj = JSON.parse(formPayload.value);
|
||||
|
||||
formPayload.value = JSON.stringify(obj, null, 2);
|
||||
msg.info('JSON 已格式化');
|
||||
|
||||
} catch {
|
||||
msg.warning('无效的 JSON 格式');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 选中或取消选中单个快速消息
|
||||
* @param {string} id
|
||||
* @param {boolean} checked
|
||||
*/
|
||||
function handleSelectItem(id, checked) {
|
||||
if (checked) {
|
||||
selectedIds.value.push(id);
|
||||
} else {
|
||||
selectedIds.value = selectedIds.value.filter((i) => i !== id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换全选状态
|
||||
*/
|
||||
function handleToggleSelectAll() {
|
||||
if (isAllSelected.value) {
|
||||
selectedIds.value = [];
|
||||
} else {
|
||||
selectedIds.value = quickList.value.map((item) => item.id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除选中的快速消息
|
||||
*/
|
||||
function handleBatchDelete() {
|
||||
|
||||
if (!hasSelected.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
dialog.warning({
|
||||
title: '确认批量删除',
|
||||
content: `确定要删除选中的 ${selectedIds.value.length} 条快速消息吗?`,
|
||||
positiveText: '删除',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: () => {
|
||||
quickList.value = quickList.value.filter((item) => !selectedIds.value.includes(item.id));
|
||||
selectedIds.value = [];
|
||||
},
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出快速消息列表为 JSON 文件
|
||||
*/
|
||||
function handleExport() {
|
||||
|
||||
let data = quickList.value;
|
||||
let blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||
let url = URL.createObjectURL(blob);
|
||||
let a = document.createElement('a');
|
||||
|
||||
a.href = url;
|
||||
a.download = `frost-mqtt-quick-send-${new Date().toISOString().slice(0, 10)}.json`;
|
||||
a.click();
|
||||
|
||||
URL.revokeObjectURL(url);
|
||||
msg.success('快速消息已导出');
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发文件导入点击
|
||||
*/
|
||||
function handleImportClick() {
|
||||
fileInput.value?.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理导入的 JSON 文件
|
||||
* @param {Event} event
|
||||
*/
|
||||
function handleImportFile(event) {
|
||||
|
||||
let file = event.target.files?.[0];
|
||||
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
let reader = new FileReader();
|
||||
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
|
||||
let data = JSON.parse(String(e.target?.result || ''));
|
||||
|
||||
if (!Array.isArray(data)) {
|
||||
msg.error('导入失败: 文件内容不是数组');
|
||||
return;
|
||||
}
|
||||
|
||||
let validItems = data.filter((item) => {
|
||||
return item && typeof item.id === 'string' && typeof item.topic === 'string';
|
||||
});
|
||||
|
||||
if (validItems.length === 0) {
|
||||
msg.warning('未找到有效的快速消息');
|
||||
return;
|
||||
}
|
||||
|
||||
dialog.create({
|
||||
title: '选择导入方式',
|
||||
content: `检测到 ${validItems.length} 条快速消息,请选择导入方式:`,
|
||||
positiveText: '追加',
|
||||
negativeText: '覆盖',
|
||||
class: 'quick-send-dialog',
|
||||
onPositiveClick: () => {
|
||||
let existingIds = new Set(quickList.value.map((item) => item.id));
|
||||
let itemsToAdd = validItems.map((item) => {
|
||||
if (existingIds.has(item.id)) {
|
||||
return { ...item, id: generateId() };
|
||||
}
|
||||
return item;
|
||||
});
|
||||
quickList.value.push(...itemsToAdd);
|
||||
selectedIds.value = [];
|
||||
msg.success(`已追加 ${itemsToAdd.length} 条快速消息`);
|
||||
},
|
||||
onNegativeClick: () => {
|
||||
quickList.value = validItems;
|
||||
selectedIds.value = [];
|
||||
msg.success(`已覆盖为导入的 ${validItems.length} 条快速消息`);
|
||||
},
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
msg.error('导入失败: ' + (err.message || '未知错误'));
|
||||
}
|
||||
};
|
||||
|
||||
reader.onerror = () => {
|
||||
msg.error('读取文件失败');
|
||||
};
|
||||
|
||||
reader.readAsText(file);
|
||||
event.target.value = '';
|
||||
|
||||
}
|
||||
|
||||
function updateColumnCount(width) {
|
||||
if (width >= 1520) {
|
||||
columnCount.value = 3;
|
||||
} else if (width >= 720) {
|
||||
columnCount.value = 2;
|
||||
} else {
|
||||
columnCount.value = 1;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
resizeObserver = new ResizeObserver((entries) => {
|
||||
for (let entry of entries) {
|
||||
updateColumnCount(entry.contentRect.width);
|
||||
}
|
||||
});
|
||||
if (containerRef.value) {
|
||||
resizeObserver.observe(containerRef.value);
|
||||
updateColumnCount(containerRef.value.clientWidth);
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect();
|
||||
resizeObserver = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="quick-send-view"
|
||||
>
|
||||
<n-card
|
||||
size="small"
|
||||
:content-style="{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: 0,
|
||||
overflow: 'hidden',
|
||||
}"
|
||||
>
|
||||
<n-space
|
||||
style="margin-bottom: 12px"
|
||||
align="center"
|
||||
>
|
||||
<n-button
|
||||
type="primary"
|
||||
@click="handleAdd"
|
||||
>新增快速消息</n-button>
|
||||
<n-button
|
||||
size="small"
|
||||
:disabled="quickList.length === 0"
|
||||
@click="handleToggleSelectAll"
|
||||
>{{ isAllSelected ? '取消全选' : '全选' }}</n-button>
|
||||
<n-button
|
||||
size="small"
|
||||
type="error"
|
||||
ghost
|
||||
:disabled="!hasSelected"
|
||||
@click="handleBatchDelete"
|
||||
>批量删除</n-button>
|
||||
<n-button
|
||||
size="small"
|
||||
@click="handleImportClick"
|
||||
>导入</n-button>
|
||||
<n-button
|
||||
size="small"
|
||||
@click="handleExport"
|
||||
>导出</n-button>
|
||||
</n-space>
|
||||
|
||||
<n-empty
|
||||
v-if="quickList.length === 0"
|
||||
description="暂无快速发送消息,点击上方按钮添加"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="quick-list"
|
||||
:class="`columns-${columnCount}`"
|
||||
>
|
||||
<div
|
||||
v-for="(item, index) in quickList"
|
||||
:key="item.id"
|
||||
class="quick-item"
|
||||
:class="{ dragging: dragIndex === index }"
|
||||
draggable="true"
|
||||
@dragstart="handleDragStart($event, item, index)"
|
||||
@dragover.prevent="handleDragOver($event, index)"
|
||||
@drop.prevent="handleDrop($event, index)"
|
||||
@dragend="handleDragEnd"
|
||||
>
|
||||
<div class="quick-header">
|
||||
<n-checkbox
|
||||
class="quick-checkbox"
|
||||
:checked="selectedIds.includes(item.id)"
|
||||
@update:checked="(checked) => handleSelectItem(item.id, checked)"
|
||||
@click.stop
|
||||
/>
|
||||
<div class="quick-topic-wrap">
|
||||
<span class="quick-dir pub">PUB</span>
|
||||
<span
|
||||
class="quick-topic"
|
||||
:title="item.topic"
|
||||
@click="copyTopic(item.topic)"
|
||||
>{{ item.topic }}</span>
|
||||
<span class="quick-meta">QoS{{ item.qos }}{{ item.retain ? ' R' : '' }}</span>
|
||||
</div>
|
||||
<n-space>
|
||||
<n-button
|
||||
size="tiny"
|
||||
:disabled="!hasActiveServer"
|
||||
type="primary"
|
||||
@click="handleSend(item)"
|
||||
>发送</n-button>
|
||||
<n-button
|
||||
size="tiny"
|
||||
@click="handleEdit(item)"
|
||||
>编辑</n-button>
|
||||
<n-button
|
||||
size="tiny"
|
||||
type="error"
|
||||
ghost
|
||||
@click="handleDelete(item)"
|
||||
>删除</n-button>
|
||||
</n-space>
|
||||
</div>
|
||||
<pre
|
||||
v-if="item.payload"
|
||||
class="quick-payload"
|
||||
>{{ item.payload }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
style="display: none"
|
||||
@change="handleImportFile"
|
||||
>
|
||||
</n-card>
|
||||
|
||||
<n-modal
|
||||
v-model:show="showModal"
|
||||
class="quick-send-modal"
|
||||
:title="isEdit ? '编辑快速消息' : '新增快速消息'"
|
||||
preset="card"
|
||||
style="width: 520px; max-width: 90vw"
|
||||
:bordered="false"
|
||||
:segmented="{ content: true }"
|
||||
>
|
||||
<n-form
|
||||
label-placement="top"
|
||||
:show-feedback="false"
|
||||
>
|
||||
<n-form-item label="目标主题">
|
||||
<n-input
|
||||
v-model:value="formTopic"
|
||||
placeholder="例如: sensor/device01/temperature"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-grid
|
||||
cols="2"
|
||||
x-gap="12"
|
||||
>
|
||||
<n-grid-item>
|
||||
<n-form-item label="QoS">
|
||||
<n-select
|
||||
v-model:value="formQos"
|
||||
:options="qosOptions"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-form-item label="保留消息">
|
||||
<n-switch v-model:value="formRetain" />
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
<n-form-item label="消息内容">
|
||||
<div class="payload-field">
|
||||
<n-input
|
||||
v-model:value="formPayload"
|
||||
type="textarea"
|
||||
:resizable="false"
|
||||
:rows="6"
|
||||
placeholder="输入要发送的消息内容"
|
||||
/>
|
||||
<n-button
|
||||
size="small"
|
||||
@click="handleFormatJson"
|
||||
>格式化 JSON</n-button>
|
||||
</div>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<template #footer>
|
||||
<n-space justify="end">
|
||||
<n-button @click="showModal = false">取消</n-button>
|
||||
<n-button
|
||||
type="primary"
|
||||
@click="handleSave"
|
||||
>保存</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</n-modal>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<style scoped lang="less">
|
||||
.quick-send-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
|
||||
:deep(.n-card) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
:deep(.n-card__content) {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.quick-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
padding-right: 4px;
|
||||
align-content: start;
|
||||
|
||||
&.columns-1 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
&.columns-2 {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
&.columns-3 {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.payload-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
|
||||
:deep(.n-input) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.n-button {
|
||||
align-self: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
.quick-item {
|
||||
border: 1px solid var(--frost-shadow-black-8);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
background: var(--frost-color-bg-item);
|
||||
cursor: grab;
|
||||
transition: box-shadow 0.2s, opacity 0.2s;
|
||||
|
||||
&.dragging {
|
||||
opacity: 0.6;
|
||||
box-shadow: 0 4px 12px var(--frost-shadow-black-12);
|
||||
cursor: grabbing;
|
||||
}
|
||||
}
|
||||
|
||||
.quick-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.quick-checkbox {
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
|
||||
:deep(.n-checkbox-box) {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.quick-topic-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.quick-dir {
|
||||
flex-shrink: 0;
|
||||
font-weight: 700;
|
||||
width: 28px;
|
||||
font-size: 12px;
|
||||
|
||||
&.pub {
|
||||
color: var(--frost-info-color);
|
||||
}
|
||||
}
|
||||
|
||||
.quick-topic {
|
||||
color: var(--frost-color-topic);
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.quick-meta {
|
||||
color: var(--frost-color-text-tertiary);
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.quick-payload {
|
||||
margin: 10px 0 0;
|
||||
padding: 8px 10px;
|
||||
border-radius: 4px;
|
||||
background: var(--frost-shadow-black-4);
|
||||
color: #333;
|
||||
font: inherit;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.quick-send-modal {
|
||||
.n-form-item {
|
||||
margin-bottom: 20px;
|
||||
|
||||
&__label {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.n-grid {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.quick-send-dialog {
|
||||
:deep(.n-dialog__icon) {
|
||||
color: var(--frost-primary-color);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,105 +1,142 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import { useMqttStore } from '../stores/mqtt.js';
|
||||
import {
|
||||
NCard,
|
||||
NSpace,
|
||||
NFormItem,
|
||||
NInput,
|
||||
NSelect,
|
||||
NButton,
|
||||
NEmpty,
|
||||
NList,
|
||||
NListItem,
|
||||
NTag,
|
||||
NEllipsis,
|
||||
useMessage,
|
||||
} from 'naive-ui';
|
||||
import { NCard, NSpace, NFormItem, NInput, NSelect, NButton, NEmpty, NList, NListItem, NTag, useMessage } from 'naive-ui';
|
||||
|
||||
// 全局状态与消息 API
|
||||
const store = useMqttStore();
|
||||
const msg = useMessage();
|
||||
|
||||
// 主题输入与 QoS 选择
|
||||
const topicInput = ref('');
|
||||
const qos = ref(2);
|
||||
const comment = ref('');
|
||||
|
||||
// 编辑状态
|
||||
const editingTopicId = ref(null);
|
||||
const editingTopic = ref('');
|
||||
const editingQos = ref(2);
|
||||
const editingComment = ref('');
|
||||
|
||||
// QoS 下拉选项
|
||||
const qosOptions = [
|
||||
{ label: 'QoS 0 - 最多一次', value: 0 },
|
||||
{ label: 'QoS 1 - 至少一次', value: 1 },
|
||||
{ label: 'QoS 2 - 恰好一次', value: 2 },
|
||||
];
|
||||
|
||||
// 表单本地存储 key
|
||||
const formStorageKey = 'mqtt-client.subscribe-form';
|
||||
|
||||
let formReady = false;
|
||||
|
||||
// 从 localStorage 恢复表单输入
|
||||
try {
|
||||
const savedForm = JSON.parse(localStorage.getItem(formStorageKey) || '{}');
|
||||
|
||||
let savedForm = JSON.parse(localStorage.getItem(formStorageKey) || '{}');
|
||||
|
||||
if (typeof savedForm.topicInput === 'string') {
|
||||
topicInput.value = savedForm.topicInput;
|
||||
}
|
||||
|
||||
if ([0, 1, 2].includes(savedForm.qos)) {
|
||||
qos.value = savedForm.qos;
|
||||
}
|
||||
|
||||
} catch {
|
||||
localStorage.removeItem(formStorageKey);
|
||||
}
|
||||
|
||||
// 表单变化时持久化到 localStorage
|
||||
watch([topicInput, qos], () => {
|
||||
|
||||
if (!formReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem(
|
||||
formStorageKey,
|
||||
JSON.stringify({ topicInput: topicInput.value, qos: qos.value })
|
||||
);
|
||||
|
||||
});
|
||||
|
||||
formReady = true;
|
||||
|
||||
/**
|
||||
* 添加新主题
|
||||
*/
|
||||
async function handleAddTopic() {
|
||||
const topic = topicInput.value.trim();
|
||||
|
||||
let topic = topicInput.value.trim();
|
||||
|
||||
if (!topic) {
|
||||
msg.warning('请输入主题名称');
|
||||
return;
|
||||
}
|
||||
const srv = store.activeServer.value;
|
||||
|
||||
let srv = store.activeServer.value;
|
||||
|
||||
if (!srv) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (srv.topics && srv.topics.find((t) => t.topic === topic)) {
|
||||
msg.warning('该主题已存在');
|
||||
return;
|
||||
}
|
||||
const result = await store.addTopic(srv.id, topic, qos.value);
|
||||
|
||||
let result = await store.addTopic(srv.id, topic, qos.value, comment.value.trim());
|
||||
|
||||
if (result) {
|
||||
msg.success('主题已添加' + (srv.status === 'connected' ? '并已订阅' : ''));
|
||||
topicInput.value = '';
|
||||
comment.value = '';
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除主题
|
||||
* @param {string} topic - 主题名称
|
||||
*/
|
||||
async function handleRemoveTopic(topic) {
|
||||
const srv = store.activeServer.value;
|
||||
|
||||
let srv = store.activeServer.value;
|
||||
|
||||
if (!srv) {
|
||||
return;
|
||||
}
|
||||
|
||||
await store.removeTopic(srv.id, topic);
|
||||
|
||||
msg.info('主题已移除');
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换单个主题订阅状态
|
||||
* @param {string} topic - 主题名称
|
||||
*/
|
||||
async function handleToggleSub(topic) {
|
||||
const srv = store.activeServer.value;
|
||||
|
||||
let srv = store.activeServer.value;
|
||||
|
||||
if (!srv) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (srv.status !== 'connected') {
|
||||
msg.warning('请先连接服务器');
|
||||
return;
|
||||
}
|
||||
const t = srv.topics.find((t) => t.topic === topic);
|
||||
|
||||
let t = srv.topics.find((t) => t.topic === topic);
|
||||
|
||||
if (!t) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (t.subscribed) {
|
||||
await store.unsubscribeTopic(srv.id, topic);
|
||||
msg.info('已取消订阅: ' + topic);
|
||||
@@ -107,43 +144,70 @@ async function handleToggleSub(topic) {
|
||||
await store.subscribeTopic(srv.id, topic);
|
||||
msg.info('已订阅: ' + topic);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量订阅当前服务器下所有未订阅主题
|
||||
*/
|
||||
async function handleSubscribeAll() {
|
||||
const srv = store.activeServer.value;
|
||||
|
||||
let srv = store.activeServer.value;
|
||||
|
||||
if (!srv) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (srv.status !== 'connected') {
|
||||
msg.warning('请先连接服务器');
|
||||
return;
|
||||
}
|
||||
|
||||
await store.subscribeAllTopics(srv.id);
|
||||
msg.success('已订阅全部主题');
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 进入主题编辑状态
|
||||
* @param {import('@types/jsdoc').MqttTopic} t - 主题对象
|
||||
*/
|
||||
function startEdit(t) {
|
||||
editingTopicId.value = t.id || t.topic;
|
||||
editingTopic.value = t.topic;
|
||||
editingQos.value = t.qos ?? 2;
|
||||
editingComment.value = t.comment ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消主题编辑
|
||||
*/
|
||||
function cancelEdit() {
|
||||
editingTopicId.value = null;
|
||||
editingTopic.value = '';
|
||||
editingQos.value = 2;
|
||||
editingComment.value = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存主题编辑
|
||||
* @param {import('@types/jsdoc').MqttTopic} t - 主题对象
|
||||
*/
|
||||
async function handleEditSave(t) {
|
||||
const srv = store.activeServer.value;
|
||||
|
||||
let srv = store.activeServer.value;
|
||||
|
||||
if (!srv) {
|
||||
return;
|
||||
}
|
||||
const topicName = editingTopic.value.trim();
|
||||
|
||||
let topicName = editingTopic.value.trim();
|
||||
|
||||
if (!topicName) {
|
||||
msg.warning('请输入主题名称');
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
srv.topics.find(
|
||||
(item) => item.topic === topicName && (item.id || item.topic) !== editingTopicId.value
|
||||
@@ -152,22 +216,34 @@ async function handleEditSave(t) {
|
||||
msg.warning('该主题已存在');
|
||||
return;
|
||||
}
|
||||
const topicId = t.id || t.topic;
|
||||
const result = await store.updateTopic(srv.id, topicId, topicName, editingQos.value);
|
||||
|
||||
let topicId = t.id || t.topic;
|
||||
let result = await store.updateTopic(
|
||||
srv.id,
|
||||
topicId,
|
||||
topicName,
|
||||
editingQos.value,
|
||||
editingComment.value.trim()
|
||||
);
|
||||
|
||||
if (result) {
|
||||
msg.success('主题已更新');
|
||||
cancelEdit();
|
||||
} else {
|
||||
msg.error('主题更新失败');
|
||||
}
|
||||
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<div
|
||||
v-if="store.activeServer.value"
|
||||
class="subscribe-view"
|
||||
>
|
||||
|
||||
<!-- 添加主题 -->
|
||||
<n-card
|
||||
title="添加订阅主题"
|
||||
@@ -184,6 +260,16 @@ async function handleEditSave(t) {
|
||||
@keydown.enter="handleAddTopic"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item
|
||||
class="comment-field"
|
||||
label="备注"
|
||||
>
|
||||
<n-input
|
||||
v-model:value="comment"
|
||||
placeholder="可选备注"
|
||||
@keydown.enter="handleAddTopic"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item
|
||||
class="qos-field"
|
||||
label="QoS"
|
||||
@@ -197,9 +283,7 @@ async function handleEditSave(t) {
|
||||
class="topic-submit"
|
||||
type="primary"
|
||||
@click="handleAddTopic"
|
||||
>
|
||||
+ 添加订阅
|
||||
</n-button>
|
||||
>+ 添加订阅</n-button>
|
||||
</div>
|
||||
</n-card>
|
||||
|
||||
@@ -219,9 +303,7 @@ async function handleEditSave(t) {
|
||||
<n-button
|
||||
size="small"
|
||||
@click="handleSubscribeAll"
|
||||
>
|
||||
全部订阅
|
||||
</n-button>
|
||||
>全部订阅</n-button>
|
||||
</template>
|
||||
<n-empty
|
||||
v-if="!store.activeServer.value.topics || store.activeServer.value.topics.length === 0"
|
||||
@@ -237,34 +319,55 @@ async function handleEditSave(t) {
|
||||
:key="t.id || t.topic"
|
||||
class="topic-item"
|
||||
>
|
||||
<template
|
||||
v-if="editingTopicId !== (t.id || t.topic)"
|
||||
#prefix
|
||||
>
|
||||
<template #prefix>
|
||||
<n-tag
|
||||
class="topic-qos-tag"
|
||||
:type="t.subscribed ? 'success' : 'default'"
|
||||
size="small"
|
||||
>
|
||||
QoS {{ t.qos }}
|
||||
</n-tag>
|
||||
>QoS {{ t.qos }}</n-tag>
|
||||
</template>
|
||||
<div class="topic-content">
|
||||
<div
|
||||
class="topic-name"
|
||||
:class="{ 'is-hidden': editingTopicId === (t.id || t.topic) }"
|
||||
>
|
||||
<span
|
||||
class="topic-label"
|
||||
:title="t.topic"
|
||||
>{{ t.topic }}</span>
|
||||
<div class="topic-comment">
|
||||
<span class="topic-comment-prefix">备注</span>
|
||||
<span
|
||||
class="topic-comment-text"
|
||||
:title="t.comment"
|
||||
>{{ t.comment }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="editingTopicId === (t.id || t.topic)"
|
||||
class="topic-edit-row"
|
||||
:class="{ 'is-visible': editingTopicId === (t.id || t.topic) }"
|
||||
>
|
||||
<n-input
|
||||
v-model:value="editingTopic"
|
||||
class="topic-edit-topic"
|
||||
size="small"
|
||||
placeholder="主题名称"
|
||||
@keydown.enter="handleEditSave(t)"
|
||||
/>
|
||||
<n-input
|
||||
v-model:value="editingComment"
|
||||
class="topic-edit-comment"
|
||||
size="small"
|
||||
placeholder="备注"
|
||||
/>
|
||||
<n-select
|
||||
v-model:value="editingQos"
|
||||
class="topic-edit-qos"
|
||||
size="small"
|
||||
:options="qosOptions"
|
||||
style="width: 160px; flex-shrink: 0"
|
||||
/>
|
||||
<n-space
|
||||
class="topic-edit-actions"
|
||||
:size="4"
|
||||
:wrap="false"
|
||||
>
|
||||
@@ -272,65 +375,58 @@ async function handleEditSave(t) {
|
||||
size="tiny"
|
||||
type="primary"
|
||||
@click="handleEditSave(t)"
|
||||
>
|
||||
保存
|
||||
</n-button>
|
||||
>保存</n-button>
|
||||
<n-button
|
||||
size="tiny"
|
||||
@click="cancelEdit"
|
||||
>
|
||||
取消
|
||||
</n-button>
|
||||
>取消</n-button>
|
||||
</n-space>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="topic-name"
|
||||
>
|
||||
<n-ellipsis>{{ t.topic }}</n-ellipsis>
|
||||
</div>
|
||||
<template #suffix>
|
||||
<n-space
|
||||
v-if="editingTopicId !== (t.id || t.topic)"
|
||||
class="topic-actions"
|
||||
:class="{ 'is-hidden': editingTopicId === (t.id || t.topic) }"
|
||||
:size="4"
|
||||
:wrap="false"
|
||||
>
|
||||
<n-button
|
||||
size="tiny"
|
||||
@click="startEdit(t)"
|
||||
>
|
||||
编辑
|
||||
</n-button>
|
||||
>编辑</n-button>
|
||||
<n-button
|
||||
class="topic-action-sub"
|
||||
size="tiny"
|
||||
:type="t.subscribed ? 'error' : 'success'"
|
||||
:type="t.subscribed ? 'success' : 'primary'"
|
||||
@click="handleToggleSub(t.topic)"
|
||||
>
|
||||
{{ t.subscribed ? '取消订阅' : '订阅' }}
|
||||
{{ t.subscribed ? '已订阅' : '未订阅' }}
|
||||
</n-button>
|
||||
<n-button
|
||||
class="topic-action-remove"
|
||||
size="tiny"
|
||||
type="error"
|
||||
text
|
||||
@click.stop="handleRemoveTopic(t.topic)"
|
||||
>
|
||||
✕
|
||||
</n-button>
|
||||
>✕</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</n-list-item>
|
||||
</n-list>
|
||||
</div>
|
||||
</n-card>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<n-empty
|
||||
v-else
|
||||
description="请先在左侧选择一个 MQTT 服务器"
|
||||
/>
|
||||
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
<style scoped lang="less">
|
||||
.subscribe-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -374,45 +470,168 @@ async function handleEditSave(t) {
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
.comment-field {
|
||||
flex: 1 1 180px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.topic-submit {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.topic-field :deep(.n-form-item-blank),
|
||||
.qos-field :deep(.n-form-item-blank) {
|
||||
.topic-field,
|
||||
.qos-field,
|
||||
.comment-field {
|
||||
:deep(.n-form-item-blank) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.topic-item :deep(.n-list-item__main) {
|
||||
.topic-item {
|
||||
box-sizing: border-box;
|
||||
height: 56px;
|
||||
min-height: 56px;
|
||||
padding-top: 0 !important;
|
||||
padding-bottom: 0 !important;
|
||||
|
||||
:deep(.n-list-item__main) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.topic-content {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.topic-name {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
font-family: monospace;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: stretch;
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
gap: 2px;
|
||||
font-size: 13px;
|
||||
overflow: hidden;
|
||||
visibility: visible;
|
||||
|
||||
&.is-hidden {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.topic-label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-family: monospace;
|
||||
line-height: 18px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.topic-comment {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
padding: 1px 4px;
|
||||
gap: 4px;
|
||||
border: 1px solid rgba(14, 165, 160, 0.16);
|
||||
border-radius: 4px;
|
||||
background: rgba(20, 184, 166, 0.06);
|
||||
color: var(--n-text-color-3);
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
|
||||
&-prefix {
|
||||
flex-shrink: 0;
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
&-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.topic-qos-tag {
|
||||
min-width: 56px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.topic-actions {
|
||||
flex-shrink: 0;
|
||||
visibility: visible;
|
||||
|
||||
&.is-hidden {
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.n-button {
|
||||
min-width: 40px;
|
||||
}
|
||||
|
||||
.topic-action-sub {
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.topic-action-remove {
|
||||
min-width: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.topic-edit-row {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
flex-wrap: nowrap;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
|
||||
&.is-visible {
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.topic-edit-topic {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.topic-edit-row .n-input {
|
||||
flex: 1;
|
||||
min-width: 180px;
|
||||
.topic-edit-comment {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.topic-edit-qos {
|
||||
flex: 0 0 auto;
|
||||
width: 160px;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* 公共 MQTT 类型定义
|
||||
* 供 renderer 与 main 进程共享,避免在多个模块中重复定义 JSDoc @typedef
|
||||
*/
|
||||
|
||||
/** MQTT QoS 等级 */
|
||||
export type MqttQoS = 0 | 1 | 2;
|
||||
|
||||
/** MQTT 服务器连接状态 */
|
||||
export type MqttServerStatus = 'disconnected' | 'connecting' | 'connected' | 'error';
|
||||
|
||||
/** MQTT 主题 */
|
||||
export interface MqttTopic {
|
||||
/** 主题唯一标识 */
|
||||
id: string;
|
||||
/** 主题名称(支持通配符) */
|
||||
topic: string;
|
||||
/** 订阅 QoS 等级 */
|
||||
qos: MqttQoS;
|
||||
/** 主题备注,空字符串表示未备注 */
|
||||
comment: string;
|
||||
/** 当前是否已订阅 */
|
||||
subscribed: boolean;
|
||||
}
|
||||
|
||||
/** 主题配置(用于导入/导出或表单) */
|
||||
export interface TopicConfig {
|
||||
/** 主题唯一标识,未提供时由后端生成 */
|
||||
id?: string;
|
||||
/** 主题名称(支持通配符) */
|
||||
topic: string;
|
||||
/** 订阅 QoS 等级,默认 1 */
|
||||
qos?: MqttQoS;
|
||||
/** 主题备注,默认空字符串 */
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
/** MQTT 服务器配置(不含运行时状态) */
|
||||
export interface ServerConfig {
|
||||
/** 显示名称 */
|
||||
name: string;
|
||||
/** 主机地址 */
|
||||
host: string;
|
||||
/** 端口号,默认 1883 */
|
||||
port?: number;
|
||||
/** 连接协议,默认 'mqtt' */
|
||||
protocol?: 'mqtt' | 'mqtts' | 'ws' | 'wss';
|
||||
/** 客户端 ID,默认随机生成 */
|
||||
clientId?: string;
|
||||
/** 用户名,默认空字符串 */
|
||||
username?: string;
|
||||
/** 密码,默认空字符串 */
|
||||
password?: string;
|
||||
/** 心跳间隔(秒),默认 60 */
|
||||
keepAlive?: number;
|
||||
/** 是否清理会话,默认 true */
|
||||
cleanSession?: boolean;
|
||||
/** 连接超时(毫秒),默认 10000 */
|
||||
connectTimeout?: number;
|
||||
/** 是否自动重连,默认 true */
|
||||
reconnect?: boolean;
|
||||
/** 重连间隔(毫秒),默认 5000 */
|
||||
reconnectInterval?: number;
|
||||
/** 初始主题列表 */
|
||||
topics?: TopicConfig[];
|
||||
}
|
||||
|
||||
/** MQTT 服务器运行时对象 */
|
||||
export interface MqttServer extends ServerConfig {
|
||||
/** 服务器唯一标识 */
|
||||
id: string;
|
||||
/** 当前连接状态 */
|
||||
status: MqttServerStatus;
|
||||
/** 该服务器下的主题列表 */
|
||||
topics: MqttTopic[];
|
||||
}
|
||||
|
||||
/** 发布/订阅选项 */
|
||||
export interface PublishOptions {
|
||||
/** QoS 等级,默认 1 */
|
||||
qos?: MqttQoS;
|
||||
/** 是否保留消息,默认 false */
|
||||
retain?: boolean;
|
||||
}
|
||||
|
||||
/** MQTT 消息(订阅接收或本地发布) */
|
||||
export interface MqttMessage {
|
||||
/** 消息唯一标识 */
|
||||
id: string;
|
||||
/** 接收/发送时间(格式为 HH:mm:ss.SSS) */
|
||||
time: string;
|
||||
/** 消息主题 */
|
||||
topic: string;
|
||||
/** 消息负载内容 */
|
||||
payload: string;
|
||||
/** 消息 QoS 等级 */
|
||||
qos: MqttQoS;
|
||||
/** 是否为保留消息 */
|
||||
retain: boolean;
|
||||
/** 消息方向:pub 表示发布,sub 表示订阅 */
|
||||
direction: 'pub' | 'sub';
|
||||
}
|
||||
|
||||
/** 发布历史记录 */
|
||||
export interface PublishRecord {
|
||||
/** 发布时间(格式为 HH:mm:ss.SSS) */
|
||||
time: string;
|
||||
/** 目标主题 */
|
||||
topic: string;
|
||||
/** 发布内容 */
|
||||
payload: string;
|
||||
/** 发布 QoS 等级 */
|
||||
qos: MqttQoS;
|
||||
/** 是否保留消息 */
|
||||
retain: boolean;
|
||||
/** 固定为 pub */
|
||||
direction: 'pub';
|
||||
}
|
||||
@@ -813,7 +813,7 @@
|
||||
<div class="sidebar-logo">F</div>
|
||||
<div>
|
||||
<div class="sidebar-title">Frost MQTT Client</div>
|
||||
<div class="sidebar-subtitle">多服务器管理客户端</div>
|
||||
<div class="sidebar-subtitle">多功能 MQTT 客户端</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# CHANGELOG
|
||||
|
||||
## [1.0.0] - 2026-07-21
|
||||
|
||||
- 初始版本发布。
|
||||
@@ -0,0 +1,4 @@
|
||||
# TODO
|
||||
|
||||
- [X] 过滤主题功能支持下拉
|
||||
- [X] 订阅主题备注
|
||||
@@ -0,0 +1,52 @@
|
||||
# 代码风格
|
||||
|
||||
## 基础格式
|
||||
|
||||
- 使用 UTF-8、LF 换行、2 个空格缩进;文件末尾保留换行,删除行尾空白。
|
||||
- JavaScript、Vue SFC 使用分号结尾。
|
||||
- 字符串通常使用单引号;模板属性和值使用双引号。
|
||||
- 对象、数组尾项保留逗号。
|
||||
- 复杂函数的函数体内常使用空行分隔初始化、分支和返回逻辑;保持与所在文件一致即可。
|
||||
|
||||
## JavaScript
|
||||
|
||||
- 使用 ES Module:`import` / `export`。
|
||||
- 导入顺序通常为:框架或第三方依赖、项目内部模块、资源文件;不同分组之间空一行。
|
||||
- 变量优先使用 `const`;需要重新赋值的局部变量使用 `let`;不使用 `var`。
|
||||
- 函数以具名 `function` 声明为主;回调和简单映射使用箭头函数。
|
||||
- 命名使用 camelCase:变量、函数、方法和参数均使用英文语义名称;布尔值常以 `is`、`show`、`has` 等开头。
|
||||
- 常量或配置集合也多使用 camelCase,例如 `protocolOptions`、`themeOverrides`。
|
||||
- 错误处理使用 `try...catch`,错误信息通过中文 `console.error` 或界面消息反馈。
|
||||
|
||||
## 注释与类型
|
||||
|
||||
- 注释、JSDoc 说明和面向用户的提示文案使用简体中文。
|
||||
- 按职责使用 `// ==================== 模块名 ====================` 分隔较长的文件。
|
||||
- 公开方法、重要状态和较复杂逻辑使用 JSDoc 描述用途、参数和返回值。
|
||||
- JavaScript 项目通过 JSDoc 引入或声明类型,例如 `@typedef`、`@param`、`@returns`,公共 MQTT 类型集中引用 `types/jsdoc.d.ts`。
|
||||
- 简单实现可配合单行中文注释说明意图,避免重复描述显而易见的代码。
|
||||
|
||||
## Vue 组件
|
||||
|
||||
- 单文件组件按 `<script setup>`、`<template>`、`<style>` 的顺序组织。
|
||||
- 组件文件使用 PascalCase,例如 `ServerModal.vue`;组件在模板中同样使用 PascalCase。
|
||||
- `script setup` 中通常依次放置:导入、Props/Emits、响应式状态、静态配置、侦听或生命周期、事件处理函数。
|
||||
- 事件处理函数以 `handle` 开头,例如 `handleSave`;模板事件名采用 kebab-case,例如 `@edit-server`。
|
||||
- Naive UI 组件使用 `NButton` 等 PascalCase 导入,在模板中写为 `n-button`。
|
||||
- 模板属性一行较长时换行,每个属性单独一行,并保持 2 空格缩进。
|
||||
- 元素标签内文本简短时,将文本与结束标签放在同一行;较长表达式或动态内容可单独成行。
|
||||
- 样式使用 Less;Vue SFC 中通过 `<style lang="less">` / `<style lang="less" scoped>` 声明。
|
||||
- 类名使用 kebab-case;布局和组件私有样式就近放在对应组件中。
|
||||
- 善用嵌套规则、`&` 父选择器减少重复;全局主题色等公共变量在 App.vue 中通过 CSS 自定义属性定义,命名与 `themeOverrides` 对应(例如 `--frost-primary-color`)。
|
||||
- 组件内直接使用 `var(--xxx)` 引用全局变量,不再单独定义 Less 变量。
|
||||
|
||||
## Electron 分层
|
||||
|
||||
- `src/main` 处理窗口、IPC 与 MQTT 等主进程逻辑。
|
||||
- `src/preload` 统一封装并暴露 `window.api`,渲染进程不直接使用 Electron IPC。
|
||||
- `src/renderer` 放置 Vue 页面、组件和状态管理;通过 `window.api` 调用主进程能力。
|
||||
|
||||
## ESLint
|
||||
|
||||
- 使用 `@electron-toolkit/eslint-config` 与 Vue 推荐规则作为基础。
|
||||
- 提交前执行 `pnpm lint`,并修复实际报告的问题。
|
||||