Compare commits
36 Commits
7ebb384f01
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| f2b7af5824 | |||
| 0f89e142e9 | |||
| 9723495f12 | |||
| 4aaf584451 | |||
| 330a9a48fd | |||
| e233911d5e | |||
| 821b895d04 | |||
| 6a60b4daf4 | |||
| e491dd78bf | |||
| 7ec0ad36ba | |||
| 0828edf6b1 | |||
| 7eb62ca37f | |||
| cd2f926ba7 | |||
| 515fc2158b | |||
| a1106d4ce6 | |||
| b23cf4d854 | |||
| 089bfaacf7 | |||
| 21683cb790 | |||
| 2682c0a1d6 | |||
| 4b01ae9898 | |||
| ededa0dc19 | |||
| 1483712c93 | |||
| e4122162ba | |||
| 974dc89292 | |||
| 611cb0c6d7 | |||
| 455833c9d4 | |||
| 0c07a7c623 | |||
| 1bef01c0b2 | |||
| 78797dee86 | |||
| 6bd13851d9 | |||
| a62b9557ff | |||
| 1c23b85d92 | |||
| 72ee175bff | |||
| 771d8447b4 | |||
| acee68ab58 | |||
| 9c2a469e4d |
@@ -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,6 +0,0 @@
|
||||
out
|
||||
dist
|
||||
pnpm-lock.yaml
|
||||
LICENSE.md
|
||||
tsconfig.json
|
||||
tsconfig.*.json
|
||||
@@ -1,4 +0,0 @@
|
||||
singleQuote: true
|
||||
semi: false
|
||||
printWidth: 100
|
||||
trailingComma: none
|
||||
@@ -1,11 +1 @@
|
||||
{
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
}
|
||||
}
|
||||
{}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
# frost-mqtt-client
|
||||
|
||||
An Electron application with Vue
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
- [VSCode](https://code.visualstudio.com/) + [ESLint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) + [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar)
|
||||
|
||||
## 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
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import eslintConfig from '@electron-toolkit/eslint-config'
|
||||
import eslintConfigPrettier from '@electron-toolkit/eslint-config-prettier'
|
||||
import eslintPluginVue from 'eslint-plugin-vue'
|
||||
import vueParser from 'vue-eslint-parser'
|
||||
|
||||
export default [
|
||||
{ ignores: ['**/node_modules', '**/dist', '**/out'] },
|
||||
{
|
||||
ignores: ['**/node_modules', '**/dist', '**/out'],
|
||||
},
|
||||
eslintConfig,
|
||||
...eslintPluginVue.configs['flat/recommended'],
|
||||
{
|
||||
@@ -13,18 +14,19 @@ export default [
|
||||
parser: vueParser,
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
jsx: true
|
||||
jsx: true,
|
||||
},
|
||||
extraFileExtensions: ['.vue']
|
||||
}
|
||||
}
|
||||
extraFileExtensions: ['.vue'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.{js,jsx,vue}'],
|
||||
rules: {
|
||||
'vue/multi-word-component-names': 'off',
|
||||
'vue/multiline-html-element-content-newline': 'off',
|
||||
'vue/require-default-prop': 'off',
|
||||
'vue/multi-word-component-names': 'off'
|
||||
}
|
||||
'vue/singleline-html-element-content-newline': 'off',
|
||||
},
|
||||
},
|
||||
eslintConfigPrettier
|
||||
]
|
||||
];
|
||||
|
||||
@@ -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,12 +1,11 @@
|
||||
{
|
||||
"name": "frost-mqtt-client",
|
||||
"version": "1.0.0",
|
||||
"description": "An Electron application with Vue",
|
||||
"version": "1.0.1",
|
||||
"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": {
|
||||
"format": "prettier --write .",
|
||||
"lint": "eslint --cache .",
|
||||
"start": "electron-vite preview",
|
||||
"dev": "electron-vite dev",
|
||||
@@ -24,15 +23,14 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron-toolkit/eslint-config": "^2.1.0",
|
||||
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
|
||||
"@vitejs/plugin-vue": "^6.0.2",
|
||||
"electron": "^39.2.6",
|
||||
"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",
|
||||
"prettier": "^3.7.4",
|
||||
"vite": "^7.2.6",
|
||||
"vue": "^3.5.25",
|
||||
"vue-eslint-parser": "^10.2.0"
|
||||
|
||||
@@ -21,12 +21,9 @@ importers:
|
||||
'@electron-toolkit/eslint-config':
|
||||
specifier: ^2.1.0
|
||||
version: 2.1.0(eslint@9.39.5(jiti@2.7.0))
|
||||
'@electron-toolkit/eslint-config-prettier':
|
||||
specifier: ^3.0.0
|
||||
version: 3.0.0(eslint@9.39.5(jiti@2.7.0))(prettier@3.9.5)
|
||||
'@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
|
||||
@@ -35,22 +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)
|
||||
prettier:
|
||||
specifier: ^3.7.4
|
||||
version: 3.9.5
|
||||
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
|
||||
@@ -151,12 +148,6 @@ packages:
|
||||
peerDependencies:
|
||||
vue: ^3.0.11
|
||||
|
||||
'@electron-toolkit/eslint-config-prettier@3.0.0':
|
||||
resolution: {integrity: sha512-YapmIOVkbYdHLuTa+ad1SAVtcqYL9A/SJsc7cxQokmhcwAwonGevNom37jBf9slXegcZ/Slh01I/JARG1yhNFw==}
|
||||
peerDependencies:
|
||||
eslint: '>= 9.0.0'
|
||||
prettier: '>= 3.0.0'
|
||||
|
||||
'@electron-toolkit/eslint-config@2.1.0':
|
||||
resolution: {integrity: sha512-F/r45x5wDHs8r2RSkXwcrMYo9X4lkc/W+ZTlWzOzjXn7ncirMqVo256BCAogeNBWIxd24ePsSwmJXELiQZUZUg==}
|
||||
peerDependencies:
|
||||
@@ -638,10 +629,6 @@ packages:
|
||||
resolution: {integrity: sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ==}
|
||||
engines: {node: '>=14.18.0'}
|
||||
|
||||
'@pkgr/core@0.3.6':
|
||||
resolution: {integrity: sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==}
|
||||
engines: {node: ^14.18.0 || >=16.0.0}
|
||||
|
||||
'@rolldown/pluginutils@1.0.1':
|
||||
resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
|
||||
|
||||
@@ -1104,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==}
|
||||
|
||||
@@ -1136,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'}
|
||||
@@ -1248,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'}
|
||||
@@ -1285,26 +1296,6 @@ packages:
|
||||
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
eslint-config-prettier@10.1.8:
|
||||
resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
eslint: '>=7.0.0'
|
||||
|
||||
eslint-plugin-prettier@5.5.6:
|
||||
resolution: {integrity: sha512-ifetmTcxWfz+4qRW3pH/ujdTq2jQIj59AxJMIN26K5avYgU8dxycUETQonWiW+wPrYXA0j3Try0l1CnwVQtDqQ==}
|
||||
engines: {node: ^14.18.0 || >=16.0.0}
|
||||
peerDependencies:
|
||||
'@types/eslint': '>=8.0.0'
|
||||
eslint: '>=8.0.0'
|
||||
eslint-config-prettier: '>= 7.0.0 <10.0.0 || >=10.1.0'
|
||||
prettier: '>=3.0.0'
|
||||
peerDependenciesMeta:
|
||||
'@types/eslint':
|
||||
optional: true
|
||||
eslint-config-prettier:
|
||||
optional: true
|
||||
|
||||
eslint-plugin-vue@10.9.2:
|
||||
resolution: {integrity: sha512-4g7ZP3pYcuqd7Zp0pzUKcos0W+RkjBz4EGdhJ92FcYk6v03Ti/GK5NwjgsjxHK+98eXDbHeK7VtX1az7/8doZA==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
@@ -1398,9 +1389,6 @@ packages:
|
||||
fast-deep-equal@3.1.3:
|
||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||
|
||||
fast-diff@1.3.0:
|
||||
resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==}
|
||||
|
||||
fast-json-stable-stringify@2.1.0:
|
||||
resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
|
||||
|
||||
@@ -1583,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==}
|
||||
|
||||
@@ -1621,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==}
|
||||
|
||||
@@ -1699,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'}
|
||||
@@ -1733,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'}
|
||||
@@ -1749,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'}
|
||||
@@ -1800,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==}
|
||||
|
||||
@@ -1817,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'}
|
||||
@@ -1878,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'}
|
||||
@@ -1929,14 +1960,8 @@ packages:
|
||||
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
||||
prettier-linter-helpers@1.0.1:
|
||||
resolution: {integrity: sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
prettier@3.9.5:
|
||||
resolution: {integrity: sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==}
|
||||
engines: {node: '>=14'}
|
||||
hasBin: true
|
||||
probe-image-size@7.3.0:
|
||||
resolution: {integrity: sha512-7CaDeBwiAbh6ohXsvLbAZhO7wzsZAmaevfxe39qvCwRh8LyaZfDlBGGLU1CCTgrTLtCOdwBBhjOrIHaIIimHfQ==}
|
||||
|
||||
proc-log@6.1.0:
|
||||
resolution: {integrity: sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==}
|
||||
@@ -1960,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==}
|
||||
|
||||
@@ -2042,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==}
|
||||
|
||||
@@ -2122,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'}
|
||||
@@ -2148,10 +2182,6 @@ packages:
|
||||
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
synckit@0.11.13:
|
||||
resolution: {integrity: sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==}
|
||||
engines: {node: ^14.18.0 || >=16.0.0}
|
||||
|
||||
tar@7.5.20:
|
||||
resolution: {integrity: sha512-9FcyK4PA6+WbzlTM9WhQm6vB5W7cP7dUiPsv1g7YDwEQnQ1CGpK3MGlKk/ITVWMk05kHZuBhmVhiv8LZoy/PFQ==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -2511,15 +2541,6 @@ snapshots:
|
||||
dependencies:
|
||||
vue: 3.5.39
|
||||
|
||||
'@electron-toolkit/eslint-config-prettier@3.0.0(eslint@9.39.5(jiti@2.7.0))(prettier@3.9.5)':
|
||||
dependencies:
|
||||
eslint: 9.39.5(jiti@2.7.0)
|
||||
eslint-config-prettier: 10.1.8(eslint@9.39.5(jiti@2.7.0))
|
||||
eslint-plugin-prettier: 5.5.6(eslint-config-prettier@10.1.8(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0))(prettier@3.9.5)
|
||||
prettier: 3.9.5
|
||||
transitivePeerDependencies:
|
||||
- '@types/eslint'
|
||||
|
||||
'@electron-toolkit/eslint-config@2.1.0(eslint@9.39.5(jiti@2.7.0))':
|
||||
dependencies:
|
||||
'@eslint/js': 9.39.5
|
||||
@@ -2911,8 +2932,6 @@ snapshots:
|
||||
tslib: 2.8.1
|
||||
webcrypto-core: 1.9.2
|
||||
|
||||
'@pkgr/core@0.3.6': {}
|
||||
|
||||
'@rolldown/pluginutils@1.0.1': {}
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.62.2':
|
||||
@@ -3056,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':
|
||||
@@ -3383,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:
|
||||
@@ -3411,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
|
||||
@@ -3518,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)
|
||||
@@ -3526,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
|
||||
|
||||
@@ -3562,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: {}
|
||||
@@ -3642,19 +3680,6 @@ snapshots:
|
||||
|
||||
escape-string-regexp@4.0.0: {}
|
||||
|
||||
eslint-config-prettier@10.1.8(eslint@9.39.5(jiti@2.7.0)):
|
||||
dependencies:
|
||||
eslint: 9.39.5(jiti@2.7.0)
|
||||
|
||||
eslint-plugin-prettier@5.5.6(eslint-config-prettier@10.1.8(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0))(prettier@3.9.5):
|
||||
dependencies:
|
||||
eslint: 9.39.5(jiti@2.7.0)
|
||||
prettier: 3.9.5
|
||||
prettier-linter-helpers: 1.0.1
|
||||
synckit: 0.11.13
|
||||
optionalDependencies:
|
||||
eslint-config-prettier: 10.1.8(eslint@9.39.5(jiti@2.7.0))
|
||||
|
||||
eslint-plugin-vue@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))):
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.5(jiti@2.7.0))
|
||||
@@ -3771,8 +3796,6 @@ snapshots:
|
||||
|
||||
fast-deep-equal@3.1.3: {}
|
||||
|
||||
fast-diff@1.3.0: {}
|
||||
|
||||
fast-json-stable-stringify@2.1.0: {}
|
||||
|
||||
fast-levenshtein@2.0.6: {}
|
||||
@@ -3987,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: {}
|
||||
@@ -4015,6 +4048,8 @@ snapshots:
|
||||
dependencies:
|
||||
is-extglob: 2.1.1
|
||||
|
||||
is-what@4.1.16: {}
|
||||
|
||||
isarray@1.0.0: {}
|
||||
|
||||
isbinaryfile@4.0.10: {}
|
||||
@@ -4074,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
|
||||
@@ -4105,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
|
||||
@@ -4118,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: {}
|
||||
@@ -4183,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):
|
||||
@@ -4211,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
|
||||
@@ -4283,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: {}
|
||||
@@ -4330,11 +4406,14 @@ snapshots:
|
||||
|
||||
prelude-ls@1.2.1: {}
|
||||
|
||||
prettier-linter-helpers@1.0.1:
|
||||
probe-image-size@7.3.0:
|
||||
dependencies:
|
||||
fast-diff: 1.3.0
|
||||
|
||||
prettier@3.9.5: {}
|
||||
lodash.merge: 4.6.2
|
||||
needle: 2.9.1
|
||||
stream-parser: 0.3.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
optional: true
|
||||
|
||||
proc-log@6.1.0: {}
|
||||
|
||||
@@ -4355,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
|
||||
@@ -4469,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
|
||||
@@ -4528,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
|
||||
@@ -4558,10 +4650,6 @@ snapshots:
|
||||
dependencies:
|
||||
has-flag: 4.0.0
|
||||
|
||||
synckit@0.11.13:
|
||||
dependencies:
|
||||
'@pkgr/core': 0.3.6
|
||||
|
||||
tar@7.5.20:
|
||||
dependencies:
|
||||
'@isaacs/fs-minipass': 4.0.1
|
||||
@@ -4649,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)
|
||||
@@ -4661,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 |
@@ -1,11 +1,17 @@
|
||||
import { app, shell, BrowserWindow } from 'electron'
|
||||
import { join } from 'path'
|
||||
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
||||
import icon from '../../resources/icon.png?asset'
|
||||
import { registerIpcHandlers } from './ipc-handlers.js'
|
||||
import { app, shell, BrowserWindow } from 'electron';
|
||||
import { join } from 'path';
|
||||
import { electronApp, optimizer, is } from '@electron-toolkit/utils';
|
||||
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,
|
||||
@@ -16,62 +22,66 @@ function createWindow() {
|
||||
...(process.platform === 'linux' ? { icon } : {}),
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
sandbox: false
|
||||
}
|
||||
})
|
||||
sandbox: false,
|
||||
},
|
||||
});
|
||||
|
||||
// 窗口准备就绪后显示,避免白屏
|
||||
mainWindow.on('ready-to-show', () => {
|
||||
mainWindow.show()
|
||||
})
|
||||
mainWindow.show();
|
||||
});
|
||||
|
||||
// 拦截新窗口打开请求,使用系统默认浏览器打开外部链接
|
||||
mainWindow.webContents.setWindowOpenHandler((details) => {
|
||||
shell.openExternal(details.url)
|
||||
return { action: 'deny' }
|
||||
})
|
||||
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'])
|
||||
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']);
|
||||
} else {
|
||||
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
|
||||
mainWindow.loadFile(join(__dirname, '../renderer/index.html'));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 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
|
||||
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
|
||||
// 为 Windows 设置应用用户模型 ID
|
||||
electronApp.setAppUserModelId('com.electron');
|
||||
|
||||
// 开发环境通过 F12 打开/关闭 DevTools
|
||||
// 生产环境忽略 CommandOrControl + R 刷新
|
||||
// 详见 https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
|
||||
app.on('browser-window-created', (_, window) => {
|
||||
optimizer.watchWindowShortcuts(window)
|
||||
})
|
||||
optimizer.watchWindowShortcuts(window);
|
||||
});
|
||||
|
||||
// 注册 IPC 处理器
|
||||
registerIpcHandlers()
|
||||
registerIpcHandlers();
|
||||
|
||||
createWindow()
|
||||
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.
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
||||
})
|
||||
})
|
||||
// 在 macOS 上,当点击应用图标且没有其他窗口打开时,通常会重新创建一个窗口
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
}
|
||||
});
|
||||
|
||||
// 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()
|
||||
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.
|
||||
// 在这个文件中,你可以包含应用特定的主进程代码
|
||||
// 也可以将它们放到单独的文件中,然后在这里引入
|
||||
|
||||
@@ -1,18 +1,31 @@
|
||||
import { ipcMain, BrowserWindow } from 'electron'
|
||||
import mqttManager from './mqtt-manager.js'
|
||||
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
|
||||
const windows = BrowserWindow.getAllWindows();
|
||||
return windows.length > 0 ? windows[0] : null;
|
||||
}
|
||||
|
||||
// 向渲染进程发送事件
|
||||
/**
|
||||
* 向渲染进程发送事件
|
||||
* @param {string} channel - IPC 通道名称
|
||||
* @param {*} data - 要发送的数据
|
||||
*/
|
||||
function sendToRenderer(channel, data) {
|
||||
const win = getMainWindow()
|
||||
const win = getMainWindow();
|
||||
if (win && !win.isDestroyed()) {
|
||||
win.webContents.send(channel, data)
|
||||
win.webContents.send(channel, data);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,100 +33,122 @@ export function registerIpcHandlers() {
|
||||
mqttManager.addListener((event, data) => {
|
||||
switch (event) {
|
||||
case 'status-change':
|
||||
sendToRenderer('mqtt:status-change', data)
|
||||
break
|
||||
sendToRenderer('mqtt:status-change', data);
|
||||
break;
|
||||
case 'message':
|
||||
sendToRenderer('mqtt:message', data)
|
||||
break
|
||||
sendToRenderer('mqtt:message', data);
|
||||
break;
|
||||
case 'subscription-change':
|
||||
sendToRenderer('mqtt:subscription-change', data)
|
||||
break
|
||||
sendToRenderer('mqtt:subscription-change', data);
|
||||
break;
|
||||
case 'subscription-error':
|
||||
sendToRenderer('mqtt:subscription-error', data)
|
||||
break
|
||||
sendToRenderer('mqtt:subscription-error', data);
|
||||
break;
|
||||
case 'publish-error':
|
||||
sendToRenderer('mqtt:publish-error', data)
|
||||
break
|
||||
sendToRenderer('mqtt:publish-error', data);
|
||||
break;
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// ==================== 服务器管理 ====================
|
||||
|
||||
ipcMain.handle('mqtt:get-servers', () => {
|
||||
return mqttManager.getServers()
|
||||
})
|
||||
return mqttManager.getServers();
|
||||
});
|
||||
|
||||
ipcMain.handle('mqtt:add-server', (_, config) => {
|
||||
return mqttManager.addServer(config)
|
||||
})
|
||||
return mqttManager.addServer(config);
|
||||
});
|
||||
|
||||
ipcMain.handle('mqtt:update-server', (_, id, config) => {
|
||||
return mqttManager.updateServer(id, config)
|
||||
})
|
||||
return mqttManager.updateServer(id, config);
|
||||
});
|
||||
|
||||
ipcMain.handle('mqtt:delete-server', (_, id) => {
|
||||
mqttManager.deleteServer(id)
|
||||
return true
|
||||
})
|
||||
mqttManager.deleteServer(id);
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.handle('mqtt:export-servers', () => {
|
||||
return mqttManager.exportServers();
|
||||
});
|
||||
|
||||
ipcMain.handle('mqtt:import-servers', (_, serverList) => {
|
||||
return mqttManager.importServers(serverList);
|
||||
});
|
||||
|
||||
// ==================== 快速发送 ====================
|
||||
|
||||
ipcMain.handle('mqtt:get-quick-send-list', () => {
|
||||
return mqttManager.getQuickSendList();
|
||||
});
|
||||
|
||||
ipcMain.handle('mqtt:save-quick-send-list', (_, list) => {
|
||||
mqttManager.saveQuickSendList(list);
|
||||
return true;
|
||||
});
|
||||
|
||||
// ==================== 连接管理 ====================
|
||||
|
||||
ipcMain.handle('mqtt:connect', (_, id) => {
|
||||
return mqttManager.connect(id)
|
||||
})
|
||||
return mqttManager.connect(id);
|
||||
});
|
||||
|
||||
ipcMain.handle('mqtt:disconnect', (_, id) => {
|
||||
mqttManager.disconnect(id)
|
||||
return true
|
||||
})
|
||||
mqttManager.disconnect(id);
|
||||
return true;
|
||||
});
|
||||
|
||||
// ==================== 主题管理 ====================
|
||||
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) => {
|
||||
return mqttManager.removeTopic(serverId, topic)
|
||||
})
|
||||
return mqttManager.removeTopic(serverId, topic);
|
||||
});
|
||||
|
||||
ipcMain.handle('mqtt:subscribe', (_, serverId, topic) => {
|
||||
return mqttManager.subscribe(serverId, topic)
|
||||
})
|
||||
return mqttManager.subscribe(serverId, topic);
|
||||
});
|
||||
|
||||
ipcMain.handle('mqtt:unsubscribe', (_, serverId, topic) => {
|
||||
return mqttManager.unsubscribe(serverId, topic)
|
||||
})
|
||||
return mqttManager.unsubscribe(serverId, topic);
|
||||
});
|
||||
|
||||
ipcMain.handle('mqtt:subscribe-all', (_, serverId) => {
|
||||
return mqttManager.subscribeAll(serverId)
|
||||
})
|
||||
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);
|
||||
});
|
||||
|
||||
// ==================== 消息发布 ====================
|
||||
|
||||
ipcMain.handle('mqtt:publish', (_, serverId, topic, payload, opts) => {
|
||||
return mqttManager.publish(serverId, topic, payload, opts)
|
||||
})
|
||||
return mqttManager.publish(serverId, topic, payload, opts);
|
||||
});
|
||||
|
||||
// ==================== 消息管理 ====================
|
||||
|
||||
ipcMain.handle('mqtt:get-messages', (_, serverId) => {
|
||||
return mqttManager.getMessages(serverId)
|
||||
})
|
||||
return mqttManager.getMessages(serverId);
|
||||
});
|
||||
|
||||
ipcMain.handle('mqtt:get-publish-history', (_, serverId) => {
|
||||
return mqttManager.getPublishHistory(serverId)
|
||||
})
|
||||
return mqttManager.getPublishHistory(serverId);
|
||||
});
|
||||
|
||||
ipcMain.handle('mqtt:clear-messages', (_, serverId) => {
|
||||
mqttManager.clearMessages(serverId)
|
||||
return true
|
||||
})
|
||||
mqttManager.clearMessages(serverId);
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.handle('mqtt:clear-publish-history', (_, serverId) => {
|
||||
mqttManager.clearPublishHistory(serverId)
|
||||
return true
|
||||
})
|
||||
mqttManager.clearPublishHistory(serverId);
|
||||
return true;
|
||||
});
|
||||
|
||||
// 兼容旧的 ping 测试
|
||||
ipcMain.on('ping', () => console.log('pong'))
|
||||
}
|
||||
|
||||
@@ -1,26 +1,37 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
import { electronAPI } from '@electron-toolkit/preload'
|
||||
import { contextBridge, ipcRenderer } from 'electron';
|
||||
import { electronAPI } from '@electron-toolkit/preload';
|
||||
|
||||
// Custom APIs for renderer
|
||||
/**
|
||||
* 渲染进程可调用的自定义 API
|
||||
* 封装了所有与主进程通信的 IPC 调用及事件监听
|
||||
*/
|
||||
const api = {
|
||||
|
||||
// 服务器管理
|
||||
getServers: () => ipcRenderer.invoke('mqtt:get-servers'),
|
||||
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),
|
||||
|
||||
// 快速发送
|
||||
getQuickSendList: () => ipcRenderer.invoke('mqtt:get-quick-send-list'),
|
||||
saveQuickSendList: (list) => ipcRenderer.invoke('mqtt:save-quick-send-list', list),
|
||||
|
||||
// 连接管理
|
||||
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) =>
|
||||
@@ -34,43 +45,42 @@ const api = {
|
||||
|
||||
// 事件监听
|
||||
onStatusChange: (callback) => {
|
||||
const handler = (_, data) => callback(data)
|
||||
ipcRenderer.on('mqtt:status-change', handler)
|
||||
return () => ipcRenderer.removeListener('mqtt:status-change', handler)
|
||||
const handler = (_, data) => callback(data);
|
||||
ipcRenderer.on('mqtt:status-change', handler);
|
||||
return () => ipcRenderer.removeListener('mqtt:status-change', handler);
|
||||
},
|
||||
onMessage: (callback) => {
|
||||
const handler = (_, data) => callback(data)
|
||||
ipcRenderer.on('mqtt:message', handler)
|
||||
return () => ipcRenderer.removeListener('mqtt:message', handler)
|
||||
const handler = (_, data) => callback(data);
|
||||
ipcRenderer.on('mqtt:message', handler);
|
||||
return () => ipcRenderer.removeListener('mqtt:message', handler);
|
||||
},
|
||||
onSubscriptionChange: (callback) => {
|
||||
const handler = (_, data) => callback(data)
|
||||
ipcRenderer.on('mqtt:subscription-change', handler)
|
||||
return () => ipcRenderer.removeListener('mqtt:subscription-change', handler)
|
||||
const handler = (_, data) => callback(data);
|
||||
ipcRenderer.on('mqtt:subscription-change', handler);
|
||||
return () => ipcRenderer.removeListener('mqtt:subscription-change', handler);
|
||||
},
|
||||
onSubscriptionError: (callback) => {
|
||||
const handler = (_, data) => callback(data)
|
||||
ipcRenderer.on('mqtt:subscription-error', handler)
|
||||
return () => ipcRenderer.removeListener('mqtt:subscription-error', handler)
|
||||
const handler = (_, data) => callback(data);
|
||||
ipcRenderer.on('mqtt:subscription-error', handler);
|
||||
return () => ipcRenderer.removeListener('mqtt:subscription-error', handler);
|
||||
},
|
||||
onPublishError: (callback) => {
|
||||
const handler = (_, data) => callback(data)
|
||||
ipcRenderer.on('mqtt:publish-error', handler)
|
||||
return () => ipcRenderer.removeListener('mqtt:publish-error', handler)
|
||||
}
|
||||
}
|
||||
const handler = (_, data) => callback(data);
|
||||
ipcRenderer.on('mqtt:publish-error', handler);
|
||||
return () => ipcRenderer.removeListener('mqtt:publish-error', handler);
|
||||
},
|
||||
|
||||
// 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)
|
||||
contextBridge.exposeInMainWorld('api', api)
|
||||
contextBridge.exposeInMainWorld('electron', electronAPI);
|
||||
contextBridge.exposeInMainWorld('api', api);
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
console.error(error);
|
||||
}
|
||||
} else {
|
||||
window.electron = electronAPI
|
||||
window.api = api
|
||||
window.electron = electronAPI;
|
||||
window.api = api;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Frost MQTT Client</title>
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws: wss: mqtt: mqtts:"
|
||||
/>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
|
||||
@@ -1,74 +1,143 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import {
|
||||
NLayout,
|
||||
NMessageProvider,
|
||||
NDialogProvider,
|
||||
NConfigProvider,
|
||||
NTabs,
|
||||
NTabPane,
|
||||
zhCN,
|
||||
dateZhCN
|
||||
} from 'naive-ui'
|
||||
import Sidebar from './components/Sidebar.vue'
|
||||
import ServerModal from './components/ServerModal.vue'
|
||||
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 { useMqttStore } from './stores/mqtt.js'
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { NLayout, NMessageProvider, NDialogProvider, NConfigProvider, NTabs, NTabPane, zhCN, dateZhCN } from 'naive-ui';
|
||||
import { useMqttStore } from './stores/mqtt.js';
|
||||
|
||||
const store = useMqttStore()
|
||||
import Sidebar from './components/Sidebar.vue';
|
||||
import ServerModal from './components/ServerModal.vue';
|
||||
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 activeTab = ref('dashboard')
|
||||
const showServerModal = ref(false)
|
||||
const editServerData = ref(null)
|
||||
// 全局状态
|
||||
const store = useMqttStore();
|
||||
|
||||
// 主题覆盖配置
|
||||
const themeOverrides = {
|
||||
common: {
|
||||
primaryColor: '#0ea5a0',
|
||||
primaryColorHover: '#0b8c87',
|
||||
primaryColorPressed: '#0b8c87',
|
||||
primaryColorSuppl: '#0ea5a0',
|
||||
successColor: '#22c55e',
|
||||
successColorHover: '#16a34a',
|
||||
successColorPressed: '#15803d',
|
||||
successColorSuppl: '#22c55e',
|
||||
warningColor: '#f59e0b',
|
||||
warningColorHover: '#d97706',
|
||||
warningColorPressed: '#b45309',
|
||||
warningColorSuppl: '#f59e0b',
|
||||
errorColor: '#ef4444',
|
||||
errorColorHover: '#dc2626',
|
||||
errorColorPressed: '#b91c1c',
|
||||
errorColorSuppl: '#ef4444',
|
||||
infoColor: '#3b82f6',
|
||||
infoColorHover: '#2563eb',
|
||||
infoColorPressed: '#1d4ed8',
|
||||
infoColorSuppl: '#3b82f6',
|
||||
},
|
||||
};
|
||||
|
||||
// 当前激活的标签页
|
||||
const activeTab = ref('dashboard');
|
||||
// 是否显示服务器编辑弹窗
|
||||
const showServerModal = ref(false);
|
||||
// 当前编辑的服务器数据,null 表示新增
|
||||
const editServerData = ref(null);
|
||||
|
||||
// 组件挂载时初始化 Store
|
||||
onMounted(async () => {
|
||||
await store.init()
|
||||
})
|
||||
await store.init();
|
||||
});
|
||||
|
||||
/**
|
||||
* 打开新增服务器弹窗
|
||||
*/
|
||||
function handleAddServer() {
|
||||
editServerData.value = null
|
||||
showServerModal.value = true
|
||||
editServerData.value = null;
|
||||
showServerModal.value = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开编辑服务器弹窗
|
||||
* @param {import('@types/jsdoc').MqttServer} server - 要编辑的服务器对象
|
||||
*/
|
||||
function handleEditServer(server) {
|
||||
editServerData.value = server
|
||||
showServerModal.value = true
|
||||
editServerData.value = server;
|
||||
showServerModal.value = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 服务器保存后的回调
|
||||
*/
|
||||
function handleServerSaved() {
|
||||
// 刷新数据
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换标签页
|
||||
* @param {string} tab - 标签页名称
|
||||
*/
|
||||
function handleSwitchTab(tab) {
|
||||
activeTab.value = tab
|
||||
activeTab.value = tab;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-config-provider :locale="zhCN" :date-locale="dateZhCN" :theme="null">
|
||||
<n-config-provider
|
||||
:locale="zhCN"
|
||||
:date-locale="dateZhCN"
|
||||
:theme="null"
|
||||
:theme-overrides="themeOverrides"
|
||||
>
|
||||
<n-message-provider placement="bottom">
|
||||
<n-dialog-provider>
|
||||
<n-layout has-sider position="absolute">
|
||||
<n-layout
|
||||
has-sider
|
||||
position="absolute"
|
||||
>
|
||||
|
||||
<!-- 侧边栏 -->
|
||||
<Sidebar @add-server="handleAddServer" @edit-server="handleEditServer" />
|
||||
<Sidebar
|
||||
@add-server="handleAddServer"
|
||||
@edit-server="handleEditServer"
|
||||
/>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<n-layout content-class="main-layout-content">
|
||||
<n-tabs v-model:value="activeTab" type="line" size="medium" style="padding: 0 16px">
|
||||
<n-tab-pane name="dashboard" tab="仪表盘">
|
||||
<n-tabs
|
||||
v-model:value="activeTab"
|
||||
type="line"
|
||||
size="medium"
|
||||
style="padding: 0 16px"
|
||||
>
|
||||
<n-tab-pane
|
||||
name="dashboard"
|
||||
tab="仪表盘"
|
||||
>
|
||||
<div class="tab-content dashboard-content">
|
||||
<Dashboard @switch-tab="handleSwitchTab" @edit-server="handleEditServer" />
|
||||
<div class="dashboard-scroll-wrapper">
|
||||
<Dashboard
|
||||
@switch-tab="handleSwitchTab"
|
||||
@edit-server="handleEditServer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="subscribe" tab="主题订阅">
|
||||
<n-tab-pane
|
||||
name="subscribe"
|
||||
tab="主题订阅"
|
||||
>
|
||||
<div class="tab-content">
|
||||
<Subscribe />
|
||||
</div>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="messages" tab="消息中心">
|
||||
<n-tab-pane
|
||||
name="messages"
|
||||
tab="消息中心"
|
||||
>
|
||||
<div class="tab-content">
|
||||
<div class="message-center-layout">
|
||||
<section class="publish-panel">
|
||||
@@ -80,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>
|
||||
|
||||
@@ -88,16 +165,46 @@ function handleSwitchTab(tab) {
|
||||
:edit-server="editServerData"
|
||||
@saved="handleServerSaved"
|
||||
/>
|
||||
|
||||
</n-layout>
|
||||
</n-dialog-provider>
|
||||
</n-message-provider>
|
||||
</n-config-provider>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
<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;
|
||||
height: 100%;
|
||||
@@ -110,12 +217,20 @@ body,
|
||||
|
||||
.tab-content {
|
||||
box-sizing: border-box;
|
||||
padding: 12px 0;
|
||||
padding: 0 0 16px 0;
|
||||
height: calc(100vh - 50px);
|
||||
overflow: hidden;
|
||||
|
||||
&.dashboard-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-content {
|
||||
.dashboard-scroll-wrapper {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
@@ -126,30 +241,27 @@ body,
|
||||
gap: 12px;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.publish-panel,
|
||||
.messages-panel {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
.publish-panel,
|
||||
.messages-panel {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.publish-panel {
|
||||
box-sizing: border-box;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding-right: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.publish-panel {
|
||||
box-sizing: border-box;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.messages-panel {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.messages-panel {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.message-center-layout {
|
||||
@media (max-width: 900px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -159,22 +271,27 @@ body,
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(144, 147, 153, 0.5) transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background-color: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(144, 147, 153, 0.5);
|
||||
border-radius: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(144, 147, 153, 0.8);
|
||||
}
|
||||
::-webkit-scrollbar-corner {
|
||||
display: none;
|
||||
background-color: transparent;
|
||||
|
||||
&-track {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
&-thumb {
|
||||
background-color: rgba(144, 147, 153, 0.5);
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(144, 147, 153, 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
&-corner {
|
||||
display: none;
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,31 +1,22 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import {
|
||||
NModal,
|
||||
NForm,
|
||||
NFormItem,
|
||||
NGrid,
|
||||
NFormItemGi,
|
||||
NInput,
|
||||
NInputNumber,
|
||||
NSelect,
|
||||
NSwitch,
|
||||
NButton,
|
||||
useMessage
|
||||
} from 'naive-ui'
|
||||
import { useMqttStore } from '../stores/mqtt.js'
|
||||
import { ref, watch } from 'vue';
|
||||
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
|
||||
})
|
||||
editServer: Object,
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:show', 'saved'])
|
||||
// 更新 show、保存完成事件
|
||||
const emit = defineEmits(['update:show', 'saved']);
|
||||
|
||||
const store = useMqttStore()
|
||||
const msg = useMessage()
|
||||
const store = useMqttStore();
|
||||
const msg = useMessage();
|
||||
|
||||
const formRef = ref(null)
|
||||
// 表单引用与数据
|
||||
const formRef = ref(null);
|
||||
const form = ref({
|
||||
name: '',
|
||||
host: '',
|
||||
@@ -35,30 +26,39 @@ const form = ref({
|
||||
username: '',
|
||||
password: '',
|
||||
keepAlive: 60,
|
||||
cleanSession: true
|
||||
})
|
||||
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' }
|
||||
}
|
||||
port: { required: true, type: 'number', message: '请输入端口号', trigger: 'blur' },
|
||||
};
|
||||
|
||||
// 协议下拉选项
|
||||
const protocolOptions = [
|
||||
{ label: 'mqtt:// (默认)', value: 'mqtt' },
|
||||
{ label: 'mqtts:// (TLS)', value: 'mqtts' },
|
||||
{ label: 'ws:// (WebSocket)', value: 'ws' },
|
||||
{ label: 'wss:// (WebSocket TLS)', value: 'wss' }
|
||||
]
|
||||
{ label: 'wss:// (WebSocket TLS)', value: 'wss' },
|
||||
];
|
||||
|
||||
const isEdit = ref(false)
|
||||
// 是否为编辑模式
|
||||
const isEdit = ref(false);
|
||||
|
||||
/**
|
||||
* 监听弹窗显示状态,打开时初始化表单
|
||||
*/
|
||||
watch(
|
||||
() => props.show,
|
||||
(val) => {
|
||||
if (val) {
|
||||
if (props.editServer) {
|
||||
isEdit.value = true
|
||||
isEdit.value = true;
|
||||
form.value = {
|
||||
name: props.editServer.name,
|
||||
host: props.editServer.host,
|
||||
@@ -66,12 +66,15 @@ 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
|
||||
}
|
||||
cleanSession: props.editServer.cleanSession !== false,
|
||||
connectTimeout: props.editServer.connectTimeout ?? 10000,
|
||||
reconnect: props.editServer.reconnect !== false,
|
||||
reconnectInterval: props.editServer.reconnectInterval ?? 5000,
|
||||
};
|
||||
} else {
|
||||
isEdit.value = false
|
||||
isEdit.value = false;
|
||||
form.value = {
|
||||
name: '',
|
||||
host: '',
|
||||
@@ -81,61 +84,107 @@ watch(
|
||||
username: '',
|
||||
password: '',
|
||||
keepAlive: 60,
|
||||
cleanSession: true
|
||||
}
|
||||
cleanSession: true,
|
||||
connectTimeout: 10000,
|
||||
reconnect: true,
|
||||
reconnectInterval: 5000,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
* 保存服务器配置
|
||||
*/
|
||||
async function handleSave() {
|
||||
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
await formRef.value?.validate();
|
||||
} catch {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
if (isEdit.value && props.editServer) {
|
||||
await store.updateServer(props.editServer.id, { ...form.value })
|
||||
msg.success('服务器配置已更新')
|
||||
await store.updateServer(props.editServer.id, { ...form.value });
|
||||
msg.success('服务器配置已更新');
|
||||
} else {
|
||||
await store.addServer({ ...form.value })
|
||||
msg.success('服务器已添加')
|
||||
await store.addServer({ ...form.value });
|
||||
msg.success('服务器已添加');
|
||||
}
|
||||
emit('saved')
|
||||
emit('update:show', false)
|
||||
|
||||
emit('saved');
|
||||
emit('update:show', false);
|
||||
|
||||
} catch (e) {
|
||||
msg.error('操作失败: ' + (e.message || '未知错误'))
|
||||
msg.error('操作失败: ' + (e.message || '未知错误'));
|
||||
}
|
||||
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-modal
|
||||
:show="show"
|
||||
:mask-closable="false"
|
||||
preset="card"
|
||||
:show="show"
|
||||
:title="isEdit ? '编辑 MQTT 服务器' : '添加 MQTT 服务器'"
|
||||
preset="card"
|
||||
style="width: 520px; max-width: 90vw"
|
||||
@update:show="$emit('update:show', $event)"
|
||||
>
|
||||
<n-form ref="formRef" :model="form" :rules="rules" label-placement="top" size="medium">
|
||||
<n-grid :cols="2" :x-gap="12">
|
||||
<n-form-item-gi label="服务器名称" path="name">
|
||||
<n-input v-model:value="form.name" placeholder="例如: 本地开发服务器" />
|
||||
<n-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
label-placement="top"
|
||||
size="medium"
|
||||
>
|
||||
<n-grid
|
||||
:cols="2"
|
||||
:x-gap="12"
|
||||
>
|
||||
<n-form-item-gi
|
||||
label="服务器名称"
|
||||
path="name"
|
||||
>
|
||||
<n-input
|
||||
v-model:value="form.name"
|
||||
placeholder="例如: 本地开发服务器"
|
||||
/>
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi label="主机地址" path="host">
|
||||
<n-input v-model:value="form.host" placeholder="例如: broker.emqx.io" />
|
||||
<n-form-item-gi
|
||||
label="主机地址"
|
||||
path="host"
|
||||
>
|
||||
<n-input
|
||||
v-model:value="form.host"
|
||||
placeholder="例如: broker.emqx.io"
|
||||
/>
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi label="端口" path="port">
|
||||
<n-input-number v-model:value="form.port" :min="1" :max="65535" style="width: 100%" />
|
||||
<n-form-item-gi
|
||||
label="端口"
|
||||
path="port"
|
||||
>
|
||||
<n-input-number
|
||||
v-model:value="form.port"
|
||||
:min="1"
|
||||
:max="65535"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi label="协议">
|
||||
<n-select v-model:value="form.protocol" :options="protocolOptions" />
|
||||
<n-select
|
||||
v-model:value="form.protocol"
|
||||
:options="protocolOptions"
|
||||
/>
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi label="客户端 ID">
|
||||
<n-input v-model:value="form.clientId" placeholder="自动生成" />
|
||||
<n-input
|
||||
v-model:value="form.clientId"
|
||||
placeholder="自动生成"
|
||||
/>
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi label="Keep Alive (秒)">
|
||||
<n-input-number
|
||||
@@ -146,20 +195,66 @@ async function handleSave() {
|
||||
/>
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi label="用户名">
|
||||
<n-input v-model:value="form.username" placeholder="可选" />
|
||||
<n-input
|
||||
v-model:value="form.username"
|
||||
placeholder="可选"
|
||||
/>
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi label="密码">
|
||||
<n-input v-model:value="form.password" type="password" placeholder="可选" />
|
||||
<n-input
|
||||
v-model:value="form.password"
|
||||
type="password"
|
||||
show-password-on="click"
|
||||
placeholder="可选"
|
||||
/>
|
||||
</n-form-item-gi>
|
||||
</n-grid>
|
||||
<n-grid
|
||||
:cols="2"
|
||||
:x-gap="12"
|
||||
>
|
||||
<n-form-item-gi label="Clean Session">
|
||||
<n-switch v-model:value="form.cleanSession" />
|
||||
</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-item label="Clean Session">
|
||||
<n-switch v-model:value="form.cleanSession" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<template #footer>
|
||||
<div style="display: flex; justify-content: flex-end; gap: 8px">
|
||||
<n-button @click="$emit('update:show', false)">取消</n-button>
|
||||
<n-button type="primary" @click="handleSave">{{ isEdit ? '更新' : '保存' }}</n-button>
|
||||
<n-button
|
||||
@click="$emit('update:show', false)"
|
||||
>取消</n-button>
|
||||
<n-button
|
||||
type="primary"
|
||||
@click="handleSave"
|
||||
>{{ isEdit ? '更新' : '保存' }}</n-button>
|
||||
</div>
|
||||
</template>
|
||||
</n-modal>
|
||||
|
||||
@@ -1,25 +1,44 @@
|
||||
<script setup>
|
||||
import { NLayoutSider, NButton, NTag, useMessage, useDialog } from 'naive-ui'
|
||||
import { useMqttStore } from '../stores/mqtt.js'
|
||||
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'])
|
||||
const store = useMqttStore()
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
// 组件事件定义
|
||||
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)
|
||||
message.info('已断开连接: ' + server.name)
|
||||
await store.disconnectServer(server.id);
|
||||
message.info('已断开连接: ' + server.name);
|
||||
} else {
|
||||
await store.connectServer(server.id)
|
||||
await store.connectServer(server.id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑服务器
|
||||
* @param {import('@types/jsdoc').MqttServer} server - 服务器对象
|
||||
*/
|
||||
function handleEdit(server) {
|
||||
emit('edit-server', server)
|
||||
emit('edit-server', server);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除服务器,弹出确认对话框
|
||||
* @param {import('@types/jsdoc').MqttServer} server - 服务器对象
|
||||
*/
|
||||
function handleDelete(server) {
|
||||
dialog.warning({
|
||||
title: '确认删除',
|
||||
@@ -27,10 +46,54 @@ function handleDelete(server) {
|
||||
positiveText: '删除',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: async () => {
|
||||
await store.deleteServer(server.id)
|
||||
message.info('服务器已删除')
|
||||
}
|
||||
})
|
||||
await store.deleteServer(server.id);
|
||||
message.info('服务器已删除');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出服务器配置到本地 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>
|
||||
|
||||
@@ -43,17 +106,23 @@ function handleDelete(server) {
|
||||
show-trigger="bar"
|
||||
:native-scrollbar="true"
|
||||
content-class="sidebar-scroll-container"
|
||||
:content-style="{ height: '100%', overflow: 'hidden' }"
|
||||
:content-style="{
|
||||
height: '100%',
|
||||
overflow: 'hidden',
|
||||
}"
|
||||
>
|
||||
<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"
|
||||
@@ -65,12 +134,21 @@ function handleDelete(server) {
|
||||
<div class="server-item-top">
|
||||
<span class="server-name">{{ srv.name }}</span>
|
||||
<div class="server-actions">
|
||||
<n-button text size="tiny" @click.stop="handleEdit(srv)">
|
||||
<n-button
|
||||
text
|
||||
size="tiny"
|
||||
@click.stop="handleEdit(srv)"
|
||||
>
|
||||
<template #icon>
|
||||
<span style="font-size: 14px">✎</span>
|
||||
</template>
|
||||
</n-button>
|
||||
<n-button text size="tiny" type="error" @click.stop="handleDelete(srv)">
|
||||
<n-button
|
||||
text
|
||||
size="tiny"
|
||||
type="error"
|
||||
@click.stop="handleDelete(srv)"
|
||||
>
|
||||
<template #icon>
|
||||
<span style="font-size: 14px">✕</span>
|
||||
</template>
|
||||
@@ -81,60 +159,64 @@ function handleDelete(server) {
|
||||
<n-tag
|
||||
:bordered="false"
|
||||
size="small"
|
||||
:type="
|
||||
srv.status === 'connected'
|
||||
? 'success'
|
||||
: srv.status === 'connecting'
|
||||
? 'warning'
|
||||
: 'default'
|
||||
"
|
||||
:type="srv.status === 'connected' ? 'success' : srv.status === 'connecting' ? 'warning' : 'default'"
|
||||
>
|
||||
<template #icon>
|
||||
<span class="status-dot" :class="srv.status" />
|
||||
<span
|
||||
class="status-dot"
|
||||
:class="srv.status"
|
||||
/>
|
||||
</template>
|
||||
{{ srv.protocol }}://{{ srv.host }}:{{ srv.port }}
|
||||
</n-tag>
|
||||
</div>
|
||||
<div class="server-bottom">
|
||||
<span class="server-status-text">
|
||||
{{
|
||||
srv.status === 'connected'
|
||||
? '已连接'
|
||||
: srv.status === 'connecting'
|
||||
? '连接中...'
|
||||
: srv.status === 'error'
|
||||
? '连接失败'
|
||||
: '未连接'
|
||||
}}
|
||||
</span>
|
||||
<span
|
||||
class="server-status-text"
|
||||
>{{ srv.status === 'connected' ? '已连接' : srv.status === 'connecting' ? '连接中...' : srv.status === 'error' ? '连接失败' : '未连接' }}</span>
|
||||
<n-button
|
||||
size="tiny"
|
||||
:type="
|
||||
srv.status === 'connected'
|
||||
? 'error'
|
||||
: srv.status === 'connecting'
|
||||
? 'warning'
|
||||
: 'success'
|
||||
"
|
||||
:type="srv.status === 'connected' ? 'error' : srv.status === 'connecting' ? 'warning' : 'success'"
|
||||
:disabled="srv.status === 'connecting'"
|
||||
@click.stop="handleConnect(srv)"
|
||||
>
|
||||
{{
|
||||
srv.status === 'connected' ? '断开' : srv.status === 'connecting' ? '...' : '连接'
|
||||
}}
|
||||
</n-button>
|
||||
>{{ srv.status === 'connected' ? '断开' : srv.status === 'connecting' ? '...' : '连接' }}</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 侧边栏底部操作按钮 -->
|
||||
<div class="sidebar-footer">
|
||||
<n-button type="primary" block @click="$emit('add-server')"> + 添加服务器 </n-button>
|
||||
<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;
|
||||
@@ -160,7 +242,7 @@ function handleDelete(server) {
|
||||
.sidebar-logo {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: #18a058;
|
||||
background: var(--frost-primary-color);
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -196,15 +278,19 @@ function handleDelete(server) {
|
||||
margin-bottom: 4px;
|
||||
border: 1px solid transparent;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.server-item:hover {
|
||||
background: var(--n-color-hover);
|
||||
}
|
||||
&:hover {
|
||||
background: var(--n-color-hover);
|
||||
|
||||
.server-item.active {
|
||||
background: var(--n-color-pressed);
|
||||
border-color: #18a058;
|
||||
.server-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--n-color-pressed);
|
||||
border-color: var(--frost-primary-color);
|
||||
}
|
||||
}
|
||||
|
||||
.server-item-top {
|
||||
@@ -228,12 +314,19 @@ function handleDelete(server) {
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.server-item:hover .server-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.server-url {
|
||||
margin-top: 4px;
|
||||
overflow: hidden;
|
||||
|
||||
:deep(.n-tag) {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
:deep(.n-tag__content) {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.server-bottom {
|
||||
@@ -254,20 +347,23 @@ function handleDelete(server) {
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.status-dot.connected {
|
||||
background: #18a058;
|
||||
}
|
||||
.status-dot.connecting {
|
||||
background: #f0a020;
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
.status-dot.disconnected {
|
||||
background: #999;
|
||||
}
|
||||
.status-dot.error {
|
||||
background: #d03050;
|
||||
&.connected {
|
||||
background: var(--frost-success-color);
|
||||
}
|
||||
|
||||
&.connecting {
|
||||
background: var(--frost-warning-color);
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
|
||||
&.disconnected {
|
||||
background: var(--frost-color-text-tertiary);
|
||||
}
|
||||
|
||||
&.error {
|
||||
background: var(--frost-error-color);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
@@ -275,6 +371,7 @@ function handleDelete(server) {
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
@@ -285,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>
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
<script setup>
|
||||
import { reactive } from 'vue'
|
||||
|
||||
const versions = reactive({ ...window.electron.process.versions })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul class="versions">
|
||||
<li class="electron-version">Electron v{{ versions.electron }}</li>
|
||||
<li class="chrome-version">Chromium v{{ versions.chrome }}</li>
|
||||
<li class="node-version">Node v{{ versions.node }}</li>
|
||||
</ul>
|
||||
</template>
|
||||
@@ -1,5 +1,10 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import { createApp } from 'vue';
|
||||
|
||||
const app = createApp(App)
|
||||
app.mount('#app')
|
||||
import App from './App.vue';
|
||||
|
||||
/**
|
||||
* 创建并挂载 Vue 应用
|
||||
*/
|
||||
const app = createApp(App);
|
||||
|
||||
app.mount('#app');
|
||||
|
||||
@@ -1,205 +1,553 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
// 全局状态(单例)
|
||||
const servers = ref([])
|
||||
const activeServerId = ref(null)
|
||||
const messages = ref([])
|
||||
const publishHistory = ref([])
|
||||
const loading = ref(false)
|
||||
// ==================== 类型定义 ====================
|
||||
|
||||
// 清理函数集合
|
||||
let cleanupFns = []
|
||||
/**
|
||||
* 从公共类型定义文件引入 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
|
||||
*/
|
||||
|
||||
function setupListeners() {
|
||||
// 清理旧监听器
|
||||
cleanupFns.forEach((fn) => fn())
|
||||
cleanupFns = []
|
||||
/**
|
||||
* @typedef {Object} StatusChangeData 服务器状态变更事件数据
|
||||
* @property {string} id 服务器 id
|
||||
* @property {MqttServerStatus} status 新状态
|
||||
* @property {string} [error] 错误信息(状态为 error 时存在)
|
||||
*/
|
||||
|
||||
cleanupFns.push(
|
||||
window.api.onStatusChange((data) => {
|
||||
const srv = servers.value.find((s) => s.id === data.id)
|
||||
if (srv) {
|
||||
srv.status = data.status
|
||||
if (data.status === 'disconnected') {
|
||||
srv.topics.forEach((t) => {
|
||||
t.subscribed = false
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
/**
|
||||
* @typedef {Object} MessageEventData 消息接收事件数据
|
||||
* @property {string} serverId 所属服务器 id
|
||||
* @property {MqttMessage} message 消息对象
|
||||
*/
|
||||
|
||||
cleanupFns.push(
|
||||
window.api.onMessage((data) => {
|
||||
if (data.serverId === activeServerId.value) {
|
||||
messages.value.unshift(data.message)
|
||||
if (messages.value.length > 500) messages.value.pop()
|
||||
}
|
||||
})
|
||||
)
|
||||
/**
|
||||
* @typedef {Object} SubscriptionChangeData 订阅状态变更事件数据
|
||||
* @property {string} serverId 所属服务器 id
|
||||
* @property {string} topic 主题名称
|
||||
* @property {string} [topicId] 主题 id
|
||||
* @property {string} [comment] 主题备注
|
||||
* @property {boolean} subscribed 是否已订阅
|
||||
*/
|
||||
|
||||
cleanupFns.push(
|
||||
window.api.onSubscriptionChange((data) => {
|
||||
if (data.serverId === activeServerId.value) {
|
||||
const srv = servers.value.find((s) => s.id === data.serverId)
|
||||
if (srv) {
|
||||
const t = srv.topics.find((t) => t.topic === data.topic || t.id === data.topicId)
|
||||
if (t) {
|
||||
t.subscribed = data.subscribed
|
||||
if (data.topic && data.topic !== t.topic) {
|
||||
t.topic = data.topic
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
/**
|
||||
* @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() {
|
||||
|
||||
// 清理旧监听器
|
||||
cleanupFns.forEach((fn) => fn());
|
||||
cleanupFns = [];
|
||||
|
||||
cleanupFns.push(
|
||||
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((/** @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((/** @type {SubscriptionChangeData} */ data) => {
|
||||
if (data.serverId === activeServerId.value) {
|
||||
|
||||
let srv = servers.value.find((s) => {
|
||||
return s.id === data.serverId;
|
||||
});
|
||||
|
||||
if (srv) {
|
||||
|
||||
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() {
|
||||
const activeServer = computed(
|
||||
|
||||
/**
|
||||
* 当前选中的服务器
|
||||
* @type {import('vue').ComputedRef<MqttServer | null>}
|
||||
*/
|
||||
let activeServer = computed(
|
||||
() => servers.value.find((s) => s.id === activeServerId.value) || null
|
||||
)
|
||||
);
|
||||
|
||||
const connectedServers = computed(() => servers.value.filter((s) => s.status === 'connected'))
|
||||
/**
|
||||
* 已连接的服务器列表
|
||||
* @type {import('vue').ComputedRef<MqttServer[]>}
|
||||
*/
|
||||
let connectedServers = computed(() => servers.value.filter((s) => s.status === 'connected'));
|
||||
|
||||
const totalTopics = computed(() =>
|
||||
/**
|
||||
* 所有服务器主题总数
|
||||
* @type {import('vue').ComputedRef<number>}
|
||||
*/
|
||||
let totalTopics = computed(() =>
|
||||
servers.value.reduce((sum, s) => sum + (s.topics?.length || 0), 0)
|
||||
)
|
||||
);
|
||||
|
||||
const subscribedTopics = computed(() =>
|
||||
servers.value.reduce((sum, s) => sum + (s.topics?.filter((t) => t.subscribed)?.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),
|
||||
0
|
||||
)
|
||||
);
|
||||
|
||||
const totalMessages = computed(() => messages.value.length)
|
||||
/**
|
||||
* 当前活动服务器消息总数
|
||||
* @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
|
||||
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
const data = await window.api.getServers()
|
||||
servers.value = data
|
||||
if (data.length > 0 && !activeServerId.value) {
|
||||
activeServerId.value = data[0].id
|
||||
}
|
||||
setupListeners()
|
||||
|
||||
let data = await window.api.getServers();
|
||||
|
||||
servers.value = data;
|
||||
sortServers();
|
||||
servers.value.forEach((srv) => sortTopics(srv));
|
||||
setupListeners();
|
||||
|
||||
} catch (e) {
|
||||
console.error('初始化失败:', e)
|
||||
console.error('初始化失败:', e);
|
||||
} finally {
|
||||
loading.value = false
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// ==================== 服务器管理 ====================
|
||||
|
||||
/**
|
||||
* 设置当前活动服务器并刷新相关数据
|
||||
* @param {string|null} id - 服务器 id
|
||||
*/
|
||||
function setActiveServer(id) {
|
||||
activeServerId.value = id
|
||||
|
||||
activeServerId.value = id;
|
||||
|
||||
// 切换时刷新消息
|
||||
refreshMessages()
|
||||
refreshPublishHistory()
|
||||
refreshMessages();
|
||||
refreshPublishHistory();
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加服务器
|
||||
* @param {ServerConfig} config - 服务器配置
|
||||
* @returns {Promise<MqttServer>} 添加后的服务器对象
|
||||
*/
|
||||
async function addServer(config) {
|
||||
const server = await window.api.addServer(config)
|
||||
servers.value.push(server)
|
||||
|
||||
let server = await window.api.addServer(config);
|
||||
|
||||
servers.value.push(server);
|
||||
sortServers();
|
||||
|
||||
if (!activeServerId.value) {
|
||||
activeServerId.value = server.id
|
||||
activeServerId.value = server.id;
|
||||
}
|
||||
return server
|
||||
|
||||
return server;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新服务器配置
|
||||
* @param {string} id - 服务器 id
|
||||
* @param {ServerConfig} config - 新的服务器配置
|
||||
* @returns {Promise<MqttServer | null>} 更新后的服务器对象或 null
|
||||
*/
|
||||
async function updateServer(id, config) {
|
||||
const result = await window.api.updateServer(id, config)
|
||||
|
||||
let result = await window.api.updateServer(id, config);
|
||||
|
||||
if (result) {
|
||||
const idx = servers.value.findIndex((s) => s.id === id)
|
||||
let idx = servers.value.findIndex((s) => s.id === id);
|
||||
if (idx !== -1) {
|
||||
servers.value[idx] = { ...servers.value[idx], ...result }
|
||||
servers.value[idx] = { ...servers.value[idx], ...result };
|
||||
}
|
||||
sortServers();
|
||||
}
|
||||
return result
|
||||
|
||||
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)
|
||||
|
||||
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
|
||||
activeServerId.value = servers.value.length > 0 ? servers.value[0].id : null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出所有服务器配置
|
||||
* @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)
|
||||
return await window.api.connect(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 断开指定服务器
|
||||
* @param {string} id - 服务器 id
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function disconnectServer(id) {
|
||||
await window.api.disconnect(id)
|
||||
await window.api.disconnect(id);
|
||||
}
|
||||
|
||||
// ==================== 主题管理 ====================
|
||||
async function addTopic(serverId, topic, qos) {
|
||||
const 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) {
|
||||
const srv = servers.value.find((s) => s.id === serverId)
|
||||
let srv = servers.value.find((s) => s.id === serverId);
|
||||
if (srv) {
|
||||
srv.topics.push({ topic, qos, subscribed: false })
|
||||
srv.topics.push(result);
|
||||
sortTopics(srv);
|
||||
}
|
||||
}
|
||||
return result
|
||||
|
||||
return result;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除指定服务器的主题
|
||||
* @param {string} serverId - 服务器 id
|
||||
* @param {string} topic - 主题名称
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function removeTopic(serverId, topic) {
|
||||
await window.api.removeTopic(serverId, topic)
|
||||
const srv = servers.value.find((s) => s.id === serverId)
|
||||
|
||||
await window.api.removeTopic(serverId, topic);
|
||||
|
||||
let srv = servers.value.find((s) => s.id === serverId);
|
||||
|
||||
if (srv) {
|
||||
srv.topics = srv.topics.filter((t) => t.topic !== topic)
|
||||
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)
|
||||
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)
|
||||
return await window.api.unsubscribe(serverId, topic);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量订阅指定服务器下所有未订阅主题
|
||||
* @param {string} serverId - 服务器 id
|
||||
* @returns {Promise<boolean>} 是否发起批量订阅
|
||||
*/
|
||||
async function subscribeAllTopics(serverId) {
|
||||
return await window.api.subscribeAll(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) {
|
||||
const result = await window.api.publish(serverId, topic, payload, opts)
|
||||
|
||||
let result = await window.api.publish(serverId, topic, payload, opts);
|
||||
|
||||
if (result && serverId === activeServerId.value) {
|
||||
refreshPublishHistory()
|
||||
refreshPublishHistory();
|
||||
}
|
||||
return result
|
||||
|
||||
return result;
|
||||
|
||||
}
|
||||
|
||||
// ==================== 消息管理 ====================
|
||||
|
||||
/**
|
||||
* 刷新当前活动服务器的消息列表
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function refreshMessages() {
|
||||
if (!activeServerId.value) return
|
||||
messages.value = await window.api.getMessages(activeServerId.value)
|
||||
if (activeServerId.value) {
|
||||
messages.value = await window.api.getMessages(activeServerId.value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新当前活动服务器的发布历史
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function refreshPublishHistory() {
|
||||
if (!activeServerId.value) return
|
||||
publishHistory.value = await window.api.getPublishHistory(activeServerId.value)
|
||||
if (activeServerId.value) {
|
||||
publishHistory.value = await window.api.getPublishHistory(activeServerId.value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空当前活动服务器的消息列表
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function clearMessages() {
|
||||
if (!activeServerId.value) return
|
||||
await window.api.clearMessages(activeServerId.value)
|
||||
messages.value = []
|
||||
if (activeServerId.value) {
|
||||
await window.api.clearMessages(activeServerId.value);
|
||||
messages.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空当前活动服务器的发布历史
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function clearPublishHistory() {
|
||||
if (!activeServerId.value) return
|
||||
await window.api.clearPublishHistory(activeServerId.value)
|
||||
publishHistory.value = []
|
||||
if (activeServerId.value) {
|
||||
await window.api.clearPublishHistory(activeServerId.value);
|
||||
publishHistory.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -221,6 +569,8 @@ export function useMqttStore() {
|
||||
addServer,
|
||||
updateServer,
|
||||
deleteServer,
|
||||
exportServers,
|
||||
importServers,
|
||||
connectServer,
|
||||
disconnectServer,
|
||||
addTopic,
|
||||
@@ -233,6 +583,7 @@ export function useMqttStore() {
|
||||
refreshMessages,
|
||||
refreshPublishHistory,
|
||||
clearMessages,
|
||||
clearPublishHistory
|
||||
}
|
||||
clearPublishHistory,
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
@@ -1,60 +1,78 @@
|
||||
<script setup>
|
||||
import { useMqttStore } from '../stores/mqtt.js'
|
||||
import {
|
||||
NGrid,
|
||||
NGridItem,
|
||||
NCard,
|
||||
NStatistic,
|
||||
NTag,
|
||||
NDescriptions,
|
||||
NDescriptionsItem,
|
||||
NSpace,
|
||||
NButton,
|
||||
NList,
|
||||
NListItem
|
||||
} from 'naive-ui'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import { useMqttStore } from '../stores/mqtt.js';
|
||||
import { NGrid, NGridItem, NCard, NStatistic, NTag, NDescriptions, NDescriptionsItem, NSpace, NButton, NList, NListItem, useMessage } from 'naive-ui';
|
||||
|
||||
const store = useMqttStore()
|
||||
const msg = useMessage()
|
||||
// 全局状态与消息 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)
|
||||
emit('switch-tab', action);
|
||||
} else {
|
||||
store.setActiveServer(srv.id)
|
||||
emit('switch-tab', action)
|
||||
store.setActiveServer(srv.id);
|
||||
emit('switch-tab', action);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换当前服务器连接状态
|
||||
* @param {import('@types/jsdoc').MqttServer} srv - 服务器对象
|
||||
*/
|
||||
async function handleToggleConnection(srv) {
|
||||
if (srv.status === 'connected') {
|
||||
await store.disconnectServer(srv.id)
|
||||
msg.info('已断开连接: ' + srv.name)
|
||||
await store.disconnectServer(srv.id);
|
||||
msg.info('已断开连接: ' + srv.name);
|
||||
} else if (srv.status === 'disconnected' || srv.status === 'error') {
|
||||
await store.connectServer(srv.id)
|
||||
await store.connectServer(srv.id);
|
||||
}
|
||||
}
|
||||
|
||||
const emit = defineEmits(['switch-tab', 'edit-server'])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dashboard">
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<n-grid :cols="4" :x-gap="12" responsive="screen">
|
||||
<n-grid
|
||||
:cols="4"
|
||||
:x-gap="12"
|
||||
responsive="screen"
|
||||
>
|
||||
<n-grid-item>
|
||||
<n-card size="small" :bordered="true">
|
||||
<n-statistic label="服务器总数" :value="store.servers.value.length" />
|
||||
<n-card
|
||||
size="small"
|
||||
:bordered="true"
|
||||
>
|
||||
<n-statistic
|
||||
label="服务器总数"
|
||||
:value="store.servers.value.length"
|
||||
/>
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-card size="small" :bordered="true">
|
||||
<n-statistic label="已连接" :value="store.connectedServers.value.length" />
|
||||
<n-card
|
||||
size="small"
|
||||
:bordered="true"
|
||||
>
|
||||
<n-statistic
|
||||
label="已连接"
|
||||
:value="store.connectedServers.value.length"
|
||||
/>
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-card size="small" :bordered="true">
|
||||
<n-card
|
||||
size="small"
|
||||
:bordered="true"
|
||||
>
|
||||
<n-statistic
|
||||
label="已订阅主题"
|
||||
:value="`${store.subscribedTopics.value}/${store.totalTopics.value}`"
|
||||
@@ -62,8 +80,14 @@ const emit = defineEmits(['switch-tab', 'edit-server'])
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-card size="small" :bordered="true">
|
||||
<n-statistic label="消息总数" :value="store.messages.value.length" />
|
||||
<n-card
|
||||
size="small"
|
||||
:bordered="true"
|
||||
>
|
||||
<n-statistic
|
||||
label="消息总数"
|
||||
:value="store.messages.value.length"
|
||||
/>
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
@@ -77,29 +101,18 @@ const emit = defineEmits(['switch-tab', 'edit-server'])
|
||||
>
|
||||
<template #header-extra>
|
||||
<n-tag
|
||||
:type="
|
||||
store.activeServer.value.status === 'connected'
|
||||
? 'success'
|
||||
: store.activeServer.value.status === 'connecting'
|
||||
? 'warning'
|
||||
: 'error'
|
||||
"
|
||||
:type="store.activeServer.value.status === 'connected' ? 'success' : store.activeServer.value.status === 'connecting' ? 'warning' : 'error'"
|
||||
size="small"
|
||||
>
|
||||
{{
|
||||
store.activeServer.value.status === 'connected'
|
||||
? '已连接'
|
||||
: store.activeServer.value.status === 'connecting'
|
||||
? '连接中...'
|
||||
: '未连接'
|
||||
}}
|
||||
</n-tag>
|
||||
>{{ store.activeServer.value.status === 'connected' ? '已连接' : store.activeServer.value.status === 'connecting' ? '连接中...' : '未连接' }}</n-tag>
|
||||
</template>
|
||||
<n-descriptions :column="2" size="small" bordered>
|
||||
<n-descriptions
|
||||
class="server-descriptions"
|
||||
:column="2"
|
||||
size="small"
|
||||
bordered
|
||||
>
|
||||
<n-descriptions-item label="地址">
|
||||
{{ store.activeServer.value.protocol }}://{{ store.activeServer.value.host }}:{{
|
||||
store.activeServer.value.port
|
||||
}}
|
||||
{{ store.activeServer.value.protocol }}://{{ store.activeServer.value.host }}:{{ store.activeServer.value.port }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="客户端 ID">
|
||||
{{ store.activeServer.value.clientId }}
|
||||
@@ -116,52 +129,50 @@ const emit = defineEmits(['switch-tab', 'edit-server'])
|
||||
size="small"
|
||||
:type="store.activeServer.value.status === 'connected' ? 'error' : 'success'"
|
||||
@click="handleToggleConnection(store.activeServer.value)"
|
||||
>
|
||||
{{ store.activeServer.value.status === 'connected' ? '断开连接' : '连接服务器' }}
|
||||
</n-button>
|
||||
<n-button size="small" @click="$emit('edit-server', store.activeServer.value)"
|
||||
>编辑配置</n-button
|
||||
>
|
||||
<n-button size="small" @click="handleAction(store.activeServer.value, 'subscribe')"
|
||||
>管理订阅</n-button
|
||||
>
|
||||
<n-button size="small" @click="handleAction(store.activeServer.value, 'publish')"
|
||||
>发布消息</n-button
|
||||
>
|
||||
<n-button size="small" @click="handleAction(store.activeServer.value, 'messages')"
|
||||
>查看消息</n-button
|
||||
>
|
||||
>{{ store.activeServer.value.status === 'connected' ? '断开连接' : '连接服务器' }}</n-button>
|
||||
<n-button
|
||||
size="small"
|
||||
@click="$emit('edit-server', store.activeServer.value)"
|
||||
>编辑配置</n-button>
|
||||
<n-button
|
||||
size="small"
|
||||
@click="handleAction(store.activeServer.value, 'subscribe')"
|
||||
>管理订阅</n-button>
|
||||
<n-button
|
||||
size="small"
|
||||
@click="handleAction(store.activeServer.value, 'publish')"
|
||||
>发布消息</n-button>
|
||||
<n-button
|
||||
size="small"
|
||||
@click="handleAction(store.activeServer.value, 'messages')"
|
||||
>查看消息</n-button>
|
||||
</n-space>
|
||||
</n-card>
|
||||
|
||||
<!-- 所有服务器状态 -->
|
||||
<n-card title="所有服务器状态" size="small" style="margin-top: 12px">
|
||||
<n-card
|
||||
title="所有服务器状态"
|
||||
size="small"
|
||||
style="margin-top: 12px"
|
||||
>
|
||||
<n-list>
|
||||
<n-list-item v-for="srv in store.servers.value" :key="srv.id">
|
||||
<n-list-item
|
||||
v-for="srv in store.servers.value"
|
||||
:key="srv.id"
|
||||
>
|
||||
<template #prefix>
|
||||
<span class="status-dot" :class="srv.status" />
|
||||
<span
|
||||
class="status-dot"
|
||||
:class="srv.status"
|
||||
/>
|
||||
</template>
|
||||
<n-space align="center">
|
||||
<span style="font-weight: 500">{{ srv.name }}</span>
|
||||
<n-tag
|
||||
:bordered="false"
|
||||
size="small"
|
||||
:type="
|
||||
srv.status === 'connected'
|
||||
? 'success'
|
||||
: srv.status === 'connecting'
|
||||
? 'warning'
|
||||
: 'default'
|
||||
"
|
||||
>
|
||||
{{
|
||||
srv.status === 'connected'
|
||||
? '已连接'
|
||||
: srv.status === 'connecting'
|
||||
? '连接中...'
|
||||
: '未连接'
|
||||
}}
|
||||
</n-tag>
|
||||
:type="srv.status === 'connected' ? 'success' : srv.status === 'connecting' ? 'warning' : 'default'"
|
||||
>{{ srv.status === 'connected' ? '已连接' : srv.status === 'connecting' ? '连接中...' : '未连接' }}</n-tag>
|
||||
</n-space>
|
||||
<template #suffix>
|
||||
<n-button
|
||||
@@ -169,42 +180,60 @@ const emit = defineEmits(['switch-tab', 'edit-server'])
|
||||
:type="srv.status === 'connected' ? 'error' : 'success'"
|
||||
:disabled="srv.status === 'connecting'"
|
||||
@click="handleToggleConnection(srv)"
|
||||
>
|
||||
{{
|
||||
srv.status === 'connected' ? '断开' : srv.status === 'connecting' ? '...' : '连接'
|
||||
}}
|
||||
</n-button>
|
||||
>{{ srv.status === 'connected' ? '断开' : srv.status === 'connecting' ? '...' : '连接' }}</n-button>
|
||||
</template>
|
||||
</n-list-item>
|
||||
</n-list>
|
||||
</n-card>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
<style scoped lang="less">
|
||||
.dashboard {
|
||||
max-width: 900px;
|
||||
}
|
||||
|
||||
.server-descriptions {
|
||||
:deep(.n-descriptions-table) {
|
||||
table-layout: fixed;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:deep(.n-descriptions-table-header),
|
||||
:deep(.n-descriptions-table-content) {
|
||||
width: 25%;
|
||||
}
|
||||
|
||||
:deep(.n-descriptions-table-content) {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.status-dot.connected {
|
||||
background: #18a058;
|
||||
}
|
||||
.status-dot.connecting {
|
||||
background: #f0a020;
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
.status-dot.disconnected {
|
||||
background: #999;
|
||||
}
|
||||
.status-dot.error {
|
||||
background: #d03050;
|
||||
&.connected {
|
||||
background: var(--frost-success-color);
|
||||
}
|
||||
|
||||
&.connecting {
|
||||
background: var(--frost-warning-color);
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
|
||||
&.disconnected {
|
||||
background: var(--frost-color-text-tertiary);
|
||||
}
|
||||
|
||||
&.error {
|
||||
background: var(--frost-error-color);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
@@ -212,6 +241,7 @@ const emit = defineEmits(['switch-tab', 'edit-server'])
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
@@ -1,108 +1,229 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { useMqttStore } from '../stores/mqtt.js'
|
||||
import { NCard, NSpace, NInput, NButton, NEmpty } from 'naive-ui'
|
||||
import { ref, computed } from 'vue';
|
||||
import { useMqttStore } from '../stores/mqtt.js';
|
||||
import { NCard, NSelect, NButton, NEmpty, useMessage } from 'naive-ui';
|
||||
|
||||
const store = useMqttStore()
|
||||
// 全局状态与消息 API
|
||||
const store = useMqttStore();
|
||||
const message = useMessage();
|
||||
|
||||
const filter = ref('')
|
||||
// 已选中的过滤主题列表
|
||||
const filter = ref([]);
|
||||
|
||||
const filteredMessages = computed(() => {
|
||||
if (!filter.value.trim()) return store.messages.value
|
||||
const f = filter.value.trim().toLowerCase()
|
||||
return store.messages.value.filter((m) => m.topic.toLowerCase().includes(f))
|
||||
})
|
||||
|
||||
async function handleClear() {
|
||||
await store.clearMessages()
|
||||
/**
|
||||
* 复制主题到剪贴板
|
||||
* @param {string} topic - 主题名称
|
||||
*/
|
||||
async function copyTopic(topic) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(topic);
|
||||
message.success('主题已复制');
|
||||
} catch {
|
||||
message.error('复制失败');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 可过滤的主题选项(来自已配置主题和已接收消息)
|
||||
* @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 || filter.value.length === 0) {
|
||||
return store.messages.value;
|
||||
}
|
||||
|
||||
return store.messages.value.filter((m) =>
|
||||
filter.value.some((f) => topicMatchesFilter(f, m.topic))
|
||||
);
|
||||
|
||||
});
|
||||
|
||||
/**
|
||||
* 清空当前活动服务器的消息列表
|
||||
*/
|
||||
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)
|
||||
return JSON.stringify(JSON.parse(payload), null, 2);
|
||||
} catch {
|
||||
return payload
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="store.activeServer.value" class="messages-view">
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<div
|
||||
v-if="store.activeServer.value"
|
||||
class="messages-view"
|
||||
>
|
||||
<n-card
|
||||
size="small"
|
||||
:content-style="{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: 0,
|
||||
overflow: 'hidden'
|
||||
overflow: 'hidden',
|
||||
}"
|
||||
>
|
||||
<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 style="font-weight: 700">消息监控</span>
|
||||
<span style="font-size: 12px; opacity: 0.55; margin-left: 8px"
|
||||
>实时查看接收和发送的 MQTT 消息</span
|
||||
>
|
||||
<span>消息监控</span>
|
||||
<span
|
||||
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>
|
||||
<n-button
|
||||
size="small"
|
||||
@click="handleClear"
|
||||
>清空消息</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<n-empty v-if="filteredMessages.length === 0" description="等待消息...">
|
||||
<n-empty
|
||||
v-if="filteredMessages.length === 0"
|
||||
description="等待消息..."
|
||||
>
|
||||
<template #icon>
|
||||
<span style="font-size: 32px; opacity: 0.4">☰</span>
|
||||
</template>
|
||||
</n-empty>
|
||||
<div v-else ref="logRef" class="msg-log">
|
||||
<div v-for="m in filteredMessages" :key="m.id" class="msg-entry">
|
||||
<div
|
||||
v-else
|
||||
ref="logRef"
|
||||
class="msg-log"
|
||||
>
|
||||
<div
|
||||
v-for="m in filteredMessages"
|
||||
:key="m.id"
|
||||
class="msg-entry"
|
||||
>
|
||||
<div class="msg-header">
|
||||
<span class="msg-time">{{ m.time }}</span>
|
||||
<span class="msg-dir" :class="m.direction">{{
|
||||
m.direction === 'pub' ? 'PUB' : 'SUB'
|
||||
}}</span>
|
||||
<span class="msg-topic" :title="m.topic">{{ m.topic }}</span>
|
||||
<span class="msg-meta">QoS{{ m.qos }}{{ m.retain ? ' R' : '' }}</span>
|
||||
<span
|
||||
class="msg-dir"
|
||||
:class="m.direction"
|
||||
>{{ m.direction === 'pub' ? 'PUB' : 'SUB' }}</span>
|
||||
<span
|
||||
class="msg-topic"
|
||||
:title="m.topic"
|
||||
@click="copyTopic(m.topic)"
|
||||
>{{ m.topic }}</span>
|
||||
<span
|
||||
class="msg-meta"
|
||||
>QoS{{ m.qos }}{{ m.retain ? ' R' : '' }}</span>
|
||||
</div>
|
||||
<pre class="msg-payload">{{ formatPayload(m.payload) }}</pre>
|
||||
<pre
|
||||
class="msg-payload"
|
||||
>{{ formatPayload(m.payload) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</n-card>
|
||||
</div>
|
||||
<n-empty v-else description="请先在左侧选择一个 MQTT 服务器" />
|
||||
|
||||
<!-- 空状态 -->
|
||||
<n-empty
|
||||
v-else
|
||||
description="请先在左侧选择一个 MQTT 服务器"
|
||||
/>
|
||||
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
<style scoped lang="less">
|
||||
.messages-view {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.messages-view :deep(.n-card) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
:deep(.n-card) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.messages-view :deep(.n-card__content) {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
:deep(.n-card__content) {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.msg-log {
|
||||
background: #1e1e2e;
|
||||
background: var(--frost-color-bg-light);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
height: 100%;
|
||||
@@ -113,12 +234,12 @@ function formatPayload(payload) {
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
color: #cdd6f4;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.msg-entry {
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-bottom: 1px solid var(--frost-shadow-black-6);
|
||||
}
|
||||
|
||||
.msg-header {
|
||||
@@ -129,40 +250,51 @@ function formatPayload(payload) {
|
||||
}
|
||||
|
||||
.msg-time {
|
||||
color: #6c7086;
|
||||
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: #89b4fa;
|
||||
}
|
||||
.msg-dir.sub {
|
||||
color: #a6e3a1;
|
||||
}
|
||||
|
||||
.msg-topic {
|
||||
color: #f9e2af;
|
||||
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-payload {
|
||||
margin: 6px 0 0;
|
||||
padding: 8px 10px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #cdd6f4;
|
||||
background: var(--frost-shadow-black-4);
|
||||
color: #333;
|
||||
font: inherit;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.msg-meta {
|
||||
color: #6c7086;
|
||||
color: var(--frost-color-text-tertiary);
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -1,185 +1,305 @@
|
||||
<script setup>
|
||||
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'
|
||||
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';
|
||||
|
||||
const store = useMqttStore()
|
||||
const msg = useMessage()
|
||||
// 全局状态与消息 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')
|
||||
const formStorageKey = 'mqtt-client.publish-form'
|
||||
const publishTabStorageKey = 'mqtt-client.publish-active-tab'
|
||||
// 发布表单状态
|
||||
const topic = ref('');
|
||||
const payload = ref('');
|
||||
const qos = ref(1);
|
||||
const retain = ref(false);
|
||||
|
||||
let formReady = 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 {
|
||||
const savedForm = JSON.parse(localStorage.getItem(formStorageKey) || '{}')
|
||||
if (typeof savedForm.topic === 'string') topic.value = savedForm.topic
|
||||
if (typeof savedForm.payload === 'string') payload.value = savedForm.payload
|
||||
if ([0, 1, 2].includes(savedForm.qos)) qos.value = savedForm.qos
|
||||
if (typeof savedForm.retain === 'boolean') retain.value = savedForm.retain
|
||||
|
||||
let savedForm = JSON.parse(localStorage.getItem(formStorageKey) || '{}');
|
||||
|
||||
if (typeof savedForm.topic === 'string') {
|
||||
topic.value = savedForm.topic;
|
||||
}
|
||||
|
||||
if (typeof savedForm.payload === 'string') {
|
||||
payload.value = savedForm.payload;
|
||||
}
|
||||
|
||||
if ([0, 1, 2].includes(savedForm.qos)) {
|
||||
qos.value = savedForm.qos;
|
||||
}
|
||||
|
||||
if (typeof savedForm.retain === 'boolean') {
|
||||
retain.value = savedForm.retain;
|
||||
}
|
||||
|
||||
} catch {
|
||||
localStorage.removeItem(formStorageKey)
|
||||
localStorage.removeItem(formStorageKey);
|
||||
}
|
||||
|
||||
// 从 localStorage 恢复当前标签页
|
||||
try {
|
||||
const savedTab = localStorage.getItem(publishTabStorageKey)
|
||||
if (savedTab === 'publish' || savedTab === 'history') activePublishTab.value = savedTab
|
||||
|
||||
let savedTab = localStorage.getItem(publishTabStorageKey);
|
||||
|
||||
if (savedTab === 'publish' || savedTab === 'history') {
|
||||
activePublishTab.value = savedTab;
|
||||
}
|
||||
|
||||
} catch {
|
||||
localStorage.removeItem(publishTabStorageKey)
|
||||
localStorage.removeItem(publishTabStorageKey);
|
||||
}
|
||||
|
||||
// 表单变化时持久化
|
||||
watch([topic, payload, qos, retain], () => {
|
||||
if (!formReady) return
|
||||
|
||||
if (!formReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem(
|
||||
formStorageKey,
|
||||
JSON.stringify({
|
||||
topic: topic.value,
|
||||
payload: payload.value,
|
||||
qos: qos.value,
|
||||
retain: retain.value
|
||||
retain: retain.value,
|
||||
})
|
||||
)
|
||||
})
|
||||
);
|
||||
|
||||
});
|
||||
|
||||
// 标签页变化时持久化
|
||||
watch(activePublishTab, (tab) => {
|
||||
localStorage.setItem(publishTabStorageKey, tab)
|
||||
})
|
||||
localStorage.setItem(publishTabStorageKey, tab);
|
||||
});
|
||||
|
||||
formReady = true
|
||||
formReady = true;
|
||||
|
||||
// QoS 下拉选项
|
||||
const qosOptions = [
|
||||
{ label: 'QoS 0 - 最多一次', value: 0 },
|
||||
{ label: 'QoS 1 - 至少一次', value: 1 },
|
||||
{ label: 'QoS 2 - 恰好一次', value: 2 }
|
||||
]
|
||||
{ label: 'QoS 2 - 恰好一次', value: 2 },
|
||||
];
|
||||
|
||||
/**
|
||||
* 发布消息到当前服务器
|
||||
*/
|
||||
async function handlePublish() {
|
||||
const srv = store.activeServer.value
|
||||
if (!srv) return
|
||||
|
||||
let srv = store.activeServer.value;
|
||||
|
||||
if (!srv) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (srv.status !== 'connected') {
|
||||
msg.warning('请先连接服务器')
|
||||
return
|
||||
msg.warning('请先连接服务器');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!topic.value.trim()) {
|
||||
msg.warning('请输入目标主题')
|
||||
return
|
||||
msg.warning('请输入目标主题');
|
||||
return;
|
||||
}
|
||||
const result = await store.publishMessage(srv.id, topic.value.trim(), payload.value, {
|
||||
|
||||
let result = await store.publishMessage(srv.id, topic.value.trim(), payload.value, {
|
||||
qos: qos.value,
|
||||
retain: retain.value
|
||||
})
|
||||
retain: retain.value,
|
||||
});
|
||||
|
||||
if (result) {
|
||||
msg.success('消息已发布到: ' + topic.value)
|
||||
msg.success('消息已发布到: ' + topic.value);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化 Payload 为 JSON
|
||||
*/
|
||||
function handleFormatJson() {
|
||||
try {
|
||||
const obj = JSON.parse(payload.value)
|
||||
payload.value = JSON.stringify(obj, null, 2)
|
||||
msg.info('JSON 已格式化')
|
||||
|
||||
let obj = JSON.parse(payload.value);
|
||||
|
||||
payload.value = JSON.stringify(obj, null, 2);
|
||||
msg.info('JSON 已格式化');
|
||||
|
||||
} catch {
|
||||
msg.warning('无效的 JSON 格式')
|
||||
msg.warning('无效的 JSON 格式');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空发布历史
|
||||
*/
|
||||
async function handleClearHistory() {
|
||||
await store.clearPublishHistory()
|
||||
await store.clearPublishHistory();
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制主题到剪贴板
|
||||
* @param {string} topic - 主题名称
|
||||
*/
|
||||
async function copyTopic(topic) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(topic);
|
||||
msg.success('主题已复制');
|
||||
} catch {
|
||||
msg.error('复制失败');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="store.activeServer.value" class="publish-view">
|
||||
<n-tabs v-model:value="activePublishTab" type="line" size="small">
|
||||
<n-tab-pane name="publish" tab="发布消息">
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<div
|
||||
v-if="store.activeServer.value"
|
||||
class="publish-view"
|
||||
>
|
||||
<n-tabs
|
||||
v-model:value="activePublishTab"
|
||||
type="line"
|
||||
size="small"
|
||||
>
|
||||
<n-tab-pane
|
||||
name="publish"
|
||||
tab="发布消息"
|
||||
>
|
||||
<n-card
|
||||
size="small"
|
||||
:content-style="{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: 0,
|
||||
overflow: 'hidden'
|
||||
overflow: 'hidden',
|
||||
}"
|
||||
>
|
||||
<div class="publish-form">
|
||||
<n-form-item class="topic-field" label="目标主题 (Topic)">
|
||||
<n-input v-model:value="topic" placeholder="例如: sensor/device01/temperature" />
|
||||
<n-form-item
|
||||
class="topic-field"
|
||||
label="目标主题 (Topic)"
|
||||
>
|
||||
<n-input
|
||||
v-model:value="topic"
|
||||
placeholder="例如: sensor/device01/temperature"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-space align="center">
|
||||
<n-form-item label="QoS">
|
||||
<n-select v-model:value="qos" :options="qosOptions" style="width: 160px" />
|
||||
<n-select
|
||||
v-model:value="qos"
|
||||
:options="qosOptions"
|
||||
style="width: 160px"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="保留消息">
|
||||
<n-switch v-model:value="retain" />
|
||||
</n-form-item>
|
||||
</n-space>
|
||||
<n-form-item class="payload-field" label="消息内容 (Payload)">
|
||||
<n-form-item
|
||||
class="payload-field"
|
||||
label="消息内容 (Payload)"
|
||||
>
|
||||
<n-input
|
||||
v-model:value="payload"
|
||||
type="textarea"
|
||||
:resizable="false"
|
||||
:rows="1"
|
||||
placeholder='{"temperature": 25.6, "humidity": 68.3}'
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-space>
|
||||
<n-button type="primary" @click="handlePublish">发布消息</n-button>
|
||||
<n-button @click="handleFormatJson">格式化 JSON</n-button>
|
||||
<n-button
|
||||
type="primary"
|
||||
@click="handlePublish"
|
||||
>发布消息</n-button>
|
||||
<n-button
|
||||
@click="handleFormatJson"
|
||||
>格式化 JSON</n-button>
|
||||
</n-space>
|
||||
</div>
|
||||
</n-card>
|
||||
</n-tab-pane>
|
||||
|
||||
<n-tab-pane name="history" tab="发布历史">
|
||||
<n-tab-pane
|
||||
name="history"
|
||||
tab="发布历史"
|
||||
>
|
||||
<n-card
|
||||
size="small"
|
||||
:content-style="{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: 0,
|
||||
overflow: 'hidden'
|
||||
overflow: 'hidden',
|
||||
}"
|
||||
>
|
||||
<n-space style="margin-bottom: 12px">
|
||||
<n-button size="small" @click="handleClearHistory">清空</n-button>
|
||||
<n-button
|
||||
size="small"
|
||||
@click="handleClearHistory"
|
||||
>清空历史</n-button>
|
||||
</n-space>
|
||||
<n-empty v-if="store.publishHistory.value.length === 0" description="暂无发布记录" />
|
||||
<div v-else class="msg-log">
|
||||
<div v-for="m in store.publishHistory.value" :key="m.id || m.time" class="msg-entry">
|
||||
<n-empty
|
||||
v-if="store.publishHistory.value.length === 0"
|
||||
description="暂无发布记录"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="msg-log"
|
||||
>
|
||||
<div
|
||||
v-for="m in store.publishHistory.value"
|
||||
:key="m.id || m.time"
|
||||
class="msg-entry"
|
||||
>
|
||||
<div class="msg-header">
|
||||
<span class="msg-time">{{ m.time }}</span>
|
||||
<span class="msg-dir pub">PUB</span>
|
||||
<span class="msg-topic" :title="m.topic">{{ m.topic }}</span>
|
||||
<span class="msg-meta">QoS{{ m.qos }}{{ m.retain ? ' R' : '' }}</span>
|
||||
<span
|
||||
class="msg-time"
|
||||
>{{ m.time }}</span>
|
||||
<span
|
||||
class="msg-dir pub"
|
||||
>PUB</span>
|
||||
<span
|
||||
class="msg-topic"
|
||||
:title="m.topic"
|
||||
@click="copyTopic(m.topic)"
|
||||
>{{ m.topic }}</span>
|
||||
<span
|
||||
class="msg-meta"
|
||||
>QoS{{ m.qos }}{{ m.retain ? ' R' : '' }}</span>
|
||||
</div>
|
||||
<pre class="msg-payload">{{ m.payload }}</pre>
|
||||
<pre
|
||||
class="msg-payload"
|
||||
>{{ m.payload }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</n-card>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</div>
|
||||
<n-empty v-else description="请先在左侧选择一个 MQTT 服务器" />
|
||||
|
||||
<!-- 空状态 -->
|
||||
<n-empty
|
||||
v-else
|
||||
description="请先在左侧选择一个 MQTT 服务器"
|
||||
/>
|
||||
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
<style scoped lang="less">
|
||||
.publish-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -187,61 +307,61 @@ async function handleClearHistory() {
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.publish-view :deep(.n-tabs) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
:deep(.n-tabs) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.publish-view :deep(.n-tabs-nav) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
:deep(.n-tabs-nav) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.publish-view :deep(.n-tabs-pane-wrapper) {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
: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) {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
:deep(.n-tabs-tab-panels),
|
||||
:deep(.n-tabs-pane-wrapper) {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.publish-view :deep(.n-tab-pane) {
|
||||
box-sizing: border-box;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
:deep(.n-tab-pane) {
|
||||
box-sizing: border-box;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.publish-view :deep(.n-tab-pane > .n-card) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
:deep(.n-tab-pane > .n-card) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.publish-view :deep(.n-card__content) {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
:deep(.n-card__content) {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
|
||||
.publish-view :deep(.n-card__content > div) {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
> div {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.topic-field {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.topic-field :deep(.n-form-item-blank) {
|
||||
width: 100%;
|
||||
:deep(.n-form-item-blank) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.publish-form {
|
||||
@@ -249,34 +369,34 @@ async function handleClearHistory() {
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.publish-form .payload-field {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.payload-field {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.publish-form .payload-field :deep(.n-form-item-blank) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
:deep(.n-form-item-blank) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.publish-form .payload-field :deep(.n-input) {
|
||||
height: 100%;
|
||||
}
|
||||
:deep(.n-input) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.publish-form .payload-field :deep(.n-input__textarea-el),
|
||||
.publish-form .payload-field :deep(textarea) {
|
||||
resize: none !important;
|
||||
height: 100% !important;
|
||||
min-height: 0 !important;
|
||||
font-family: monospace;
|
||||
:deep(.n-input__textarea-el),
|
||||
:deep(textarea) {
|
||||
resize: none !important;
|
||||
height: 100% !important;
|
||||
min-height: 0 !important;
|
||||
font-family: monospace;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.msg-log {
|
||||
background: #1e1e2e;
|
||||
background: var(--frost-color-bg-light);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
height: 100%;
|
||||
@@ -286,12 +406,12 @@ async function handleClearHistory() {
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
color: #cdd6f4;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.msg-entry {
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-bottom: 1px solid var(--frost-shadow-black-6);
|
||||
}
|
||||
|
||||
.msg-header {
|
||||
@@ -302,37 +422,47 @@ async function handleClearHistory() {
|
||||
}
|
||||
|
||||
.msg-time {
|
||||
color: #6c7086;
|
||||
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);
|
||||
}
|
||||
}
|
||||
.msg-dir.pub {
|
||||
color: #89b4fa;
|
||||
}
|
||||
|
||||
.msg-topic {
|
||||
color: #f9e2af;
|
||||
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-payload {
|
||||
margin: 6px 0 0;
|
||||
padding: 8px 10px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #cdd6f4;
|
||||
background: var(--frost-shadow-black-4);
|
||||
color: #333;
|
||||
font: inherit;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.msg-meta {
|
||||
color: #6c7086;
|
||||
color: var(--frost-color-text-tertiary);
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,896 @@
|
||||
<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 oldStorageKey = 'mqtt-client.quick-send-list';
|
||||
|
||||
/**
|
||||
* @typedef {Object} QuickSendItem
|
||||
* @property {string} id
|
||||
* @property {string} topic
|
||||
* @property {string} comment
|
||||
* @property {string} payload
|
||||
* @property {0|1|2} qos
|
||||
* @property {boolean} retain
|
||||
*/
|
||||
|
||||
// 快速发送列表
|
||||
/** @type {import('vue').Ref<QuickSendItem[]>} */
|
||||
const quickList = ref([]);
|
||||
|
||||
// 列表是否已从本地文件加载完成
|
||||
const quickListLoaded = ref(false);
|
||||
|
||||
// 表单弹窗显隐
|
||||
const showModal = ref(false);
|
||||
const isEdit = ref(false);
|
||||
|
||||
// 表单数据
|
||||
const formId = ref('');
|
||||
const formTopic = ref('');
|
||||
const formComment = ref('');
|
||||
const formPayload = ref('');
|
||||
const formQos = ref(1);
|
||||
const formRetain = ref(false);
|
||||
|
||||
// 从旧版 localStorage 迁移数据
|
||||
function migrateFromLocalStorage() {
|
||||
try {
|
||||
let saved = JSON.parse(localStorage.getItem(oldStorageKey) || '[]');
|
||||
if (Array.isArray(saved) && saved.length > 0) {
|
||||
quickList.value = saved.filter((item) => {
|
||||
return item && typeof item.id === 'string' && typeof item.topic === 'string';
|
||||
});
|
||||
window.api.saveQuickSendList(JSON.parse(JSON.stringify(quickList.value)));
|
||||
}
|
||||
} catch {
|
||||
// 忽略旧数据解析错误
|
||||
} finally {
|
||||
localStorage.removeItem(oldStorageKey);
|
||||
}
|
||||
}
|
||||
|
||||
// 列表变化时持久化到本地文件
|
||||
watch(
|
||||
quickList,
|
||||
() => {
|
||||
if (!quickListLoaded.value) {
|
||||
return;
|
||||
}
|
||||
window.api.saveQuickSendList(JSON.parse(JSON.stringify(quickList.value))).catch((e) => {
|
||||
console.error('保存快速发送列表失败:', e);
|
||||
});
|
||||
},
|
||||
{ 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 = '';
|
||||
formComment.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;
|
||||
formComment.value = item.comment || '';
|
||||
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 comment = formComment.value.trim();
|
||||
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,
|
||||
comment,
|
||||
payload,
|
||||
qos,
|
||||
retain,
|
||||
};
|
||||
}
|
||||
|
||||
} else {
|
||||
|
||||
quickList.value.push({
|
||||
id: generateId(),
|
||||
topic,
|
||||
comment,
|
||||
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(async () => {
|
||||
try {
|
||||
let list = await window.api.getQuickSendList();
|
||||
if (Array.isArray(list)) {
|
||||
quickList.value = list.filter((item) => {
|
||||
return item && typeof item.id === 'string' && typeof item.topic === 'string';
|
||||
});
|
||||
}
|
||||
if (quickList.value.length === 0) {
|
||||
migrateFromLocalStorage();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载快速发送列表失败:', e);
|
||||
} finally {
|
||||
quickListLoaded.value = true;
|
||||
}
|
||||
|
||||
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>
|
||||
<div
|
||||
v-if="item.comment"
|
||||
class="quick-comment"
|
||||
:title="item.comment"
|
||||
>{{ item.comment }}</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-form-item label="备注">
|
||||
<n-input
|
||||
v-model:value="formComment"
|
||||
placeholder="可选,用于标识该快速消息的用途"
|
||||
/>
|
||||
</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: minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
padding-right: 4px;
|
||||
align-content: start;
|
||||
|
||||
&.columns-1 {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
&.columns-2 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
&.columns-3 {
|
||||
grid-template-columns: repeat(3, minmax(0, 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-comment {
|
||||
margin-top: 8px;
|
||||
color: var(--frost-color-text-secondary);
|
||||
font-size: 12px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.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,168 +1,289 @@
|
||||
<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 { ref, watch } from 'vue';
|
||||
import { useMqttStore } from '../stores/mqtt.js';
|
||||
import { NCard, NSpace, NFormItem, NInput, NSelect, NButton, NEmpty, NList, NListItem, NTag, useMessage } from 'naive-ui';
|
||||
|
||||
const store = useMqttStore()
|
||||
const msg = useMessage()
|
||||
// 全局状态与消息 API
|
||||
const store = useMqttStore();
|
||||
const msg = useMessage();
|
||||
|
||||
const topicInput = ref('')
|
||||
const qos = ref(2)
|
||||
const editingTopicId = ref(null)
|
||||
const editingTopic = ref('')
|
||||
const editingQos = ref(2)
|
||||
// 主题输入与 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 }
|
||||
]
|
||||
{ label: 'QoS 2 - 恰好一次', value: 2 },
|
||||
];
|
||||
|
||||
const formStorageKey = 'mqtt-client.subscribe-form'
|
||||
let formReady = false
|
||||
// 表单本地存储 key
|
||||
const formStorageKey = 'mqtt-client.subscribe-form';
|
||||
|
||||
let formReady = false;
|
||||
|
||||
// 从 localStorage 恢复表单输入
|
||||
try {
|
||||
const 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
|
||||
|
||||
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.removeItem(formStorageKey);
|
||||
}
|
||||
|
||||
// 表单变化时持久化到 localStorage
|
||||
watch([topicInput, qos], () => {
|
||||
if (!formReady) return
|
||||
|
||||
if (!formReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem(
|
||||
formStorageKey,
|
||||
JSON.stringify({ topicInput: topicInput.value, qos: qos.value })
|
||||
)
|
||||
})
|
||||
);
|
||||
|
||||
formReady = true
|
||||
});
|
||||
|
||||
formReady = true;
|
||||
|
||||
/**
|
||||
* 添加新主题
|
||||
*/
|
||||
async function handleAddTopic() {
|
||||
const topic = topicInput.value.trim()
|
||||
|
||||
let topic = topicInput.value.trim();
|
||||
|
||||
if (!topic) {
|
||||
msg.warning('请输入主题名称')
|
||||
return
|
||||
msg.warning('请输入主题名称');
|
||||
return;
|
||||
}
|
||||
const srv = store.activeServer.value
|
||||
if (!srv) return
|
||||
|
||||
let srv = store.activeServer.value;
|
||||
|
||||
if (!srv) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (srv.topics && srv.topics.find((t) => t.topic === topic)) {
|
||||
msg.warning('该主题已存在')
|
||||
return
|
||||
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 = ''
|
||||
msg.success('主题已添加' + (srv.status === 'connected' ? '并已订阅' : ''));
|
||||
topicInput.value = '';
|
||||
comment.value = '';
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除主题
|
||||
* @param {string} topic - 主题名称
|
||||
*/
|
||||
async function handleRemoveTopic(topic) {
|
||||
const srv = store.activeServer.value
|
||||
if (!srv) return
|
||||
await store.removeTopic(srv.id, topic)
|
||||
msg.info('主题已移除')
|
||||
|
||||
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
|
||||
if (!srv) return
|
||||
if (srv.status !== 'connected') {
|
||||
msg.warning('请先连接服务器')
|
||||
return
|
||||
|
||||
let srv = store.activeServer.value;
|
||||
|
||||
if (!srv) {
|
||||
return;
|
||||
}
|
||||
const t = srv.topics.find((t) => t.topic === topic)
|
||||
if (!t) return
|
||||
|
||||
if (srv.status !== 'connected') {
|
||||
msg.warning('请先连接服务器');
|
||||
return;
|
||||
}
|
||||
|
||||
let t = srv.topics.find((t) => t.topic === topic);
|
||||
|
||||
if (!t) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (t.subscribed) {
|
||||
await store.unsubscribeTopic(srv.id, topic)
|
||||
msg.info('已取消订阅: ' + topic)
|
||||
await store.unsubscribeTopic(srv.id, topic);
|
||||
msg.info('已取消订阅: ' + topic);
|
||||
} else {
|
||||
await store.subscribeTopic(srv.id, topic)
|
||||
msg.info('已订阅: ' + topic)
|
||||
await store.subscribeTopic(srv.id, topic);
|
||||
msg.info('已订阅: ' + topic);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量订阅当前服务器下所有未订阅主题
|
||||
*/
|
||||
async function handleSubscribeAll() {
|
||||
const srv = store.activeServer.value
|
||||
if (!srv) return
|
||||
|
||||
let srv = store.activeServer.value;
|
||||
|
||||
if (!srv) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (srv.status !== 'connected') {
|
||||
msg.warning('请先连接服务器')
|
||||
return
|
||||
msg.warning('请先连接服务器');
|
||||
return;
|
||||
}
|
||||
await store.subscribeAllTopics(srv.id)
|
||||
msg.success('已订阅全部主题')
|
||||
|
||||
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
|
||||
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
|
||||
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
|
||||
if (!srv) return
|
||||
const topicName = editingTopic.value.trim()
|
||||
if (!topicName) {
|
||||
msg.warning('请输入主题名称')
|
||||
return
|
||||
|
||||
let srv = store.activeServer.value;
|
||||
|
||||
if (!srv) {
|
||||
return;
|
||||
}
|
||||
|
||||
let topicName = editingTopic.value.trim();
|
||||
|
||||
if (!topicName) {
|
||||
msg.warning('请输入主题名称');
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
srv.topics.find(
|
||||
(item) => item.topic === topicName && (item.id || item.topic) !== editingTopicId.value
|
||||
)
|
||||
) {
|
||||
msg.warning('该主题已存在')
|
||||
return
|
||||
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()
|
||||
msg.success('主题已更新');
|
||||
cancelEdit();
|
||||
} else {
|
||||
msg.error('主题更新失败')
|
||||
msg.error('主题更新失败');
|
||||
}
|
||||
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="store.activeServer.value" class="subscribe-view">
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<div
|
||||
v-if="store.activeServer.value"
|
||||
class="subscribe-view"
|
||||
>
|
||||
|
||||
<!-- 添加主题 -->
|
||||
<n-card title="添加订阅主题" size="small">
|
||||
<n-card
|
||||
title="添加订阅主题"
|
||||
size="small"
|
||||
>
|
||||
<div class="topic-form">
|
||||
<n-form-item class="topic-field" label="主题 (Topic)">
|
||||
<n-form-item
|
||||
class="topic-field"
|
||||
label="主题 (Topic)"
|
||||
>
|
||||
<n-input
|
||||
v-model:value="topicInput"
|
||||
placeholder="例如: sensor/+/temperature"
|
||||
@keydown.enter="handleAddTopic"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item class="qos-field" label="QoS">
|
||||
<n-select v-model:value="qos" :options="qosOptions" />
|
||||
<n-form-item
|
||||
class="comment-field"
|
||||
label="备注"
|
||||
>
|
||||
<n-input
|
||||
v-model:value="comment"
|
||||
placeholder="可选备注"
|
||||
@keydown.enter="handleAddTopic"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-button class="topic-submit" type="primary" @click="handleAddTopic">
|
||||
+ 添加订阅
|
||||
</n-button>
|
||||
<n-form-item
|
||||
class="qos-field"
|
||||
label="QoS"
|
||||
>
|
||||
<n-select
|
||||
v-model:value="qos"
|
||||
:options="qosOptions"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-button
|
||||
class="topic-submit"
|
||||
type="primary"
|
||||
@click="handleAddTopic"
|
||||
>+ 添加订阅</n-button>
|
||||
</div>
|
||||
</n-card>
|
||||
|
||||
@@ -175,78 +296,137 @@ async function handleEditSave(t) {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: 0,
|
||||
overflow: 'hidden'
|
||||
overflow: 'hidden',
|
||||
}"
|
||||
>
|
||||
<template #header-extra>
|
||||
<n-button size="small" @click="handleSubscribeAll">全部订阅</n-button>
|
||||
<n-button
|
||||
size="small"
|
||||
@click="handleSubscribeAll"
|
||||
>全部订阅</n-button>
|
||||
</template>
|
||||
<n-empty
|
||||
v-if="!store.activeServer.value.topics || store.activeServer.value.topics.length === 0"
|
||||
description="暂无订阅主题,请在上方添加"
|
||||
/>
|
||||
<div v-else class="topic-list-scroll">
|
||||
<div
|
||||
v-else
|
||||
class="topic-list-scroll"
|
||||
>
|
||||
<n-list>
|
||||
<n-list-item
|
||||
v-for="t in store.activeServer.value.topics"
|
||||
:key="t.id || t.topic"
|
||||
class="topic-item"
|
||||
>
|
||||
<template v-if="editingTopicId !== (t.id || t.topic)" #prefix>
|
||||
<n-tag :type="t.subscribed ? 'success' : 'default'" size="small">
|
||||
QoS {{ t.qos }}
|
||||
</n-tag>
|
||||
<template #prefix>
|
||||
<n-tag
|
||||
class="topic-qos-tag"
|
||||
:type="t.subscribed ? 'success' : 'default'"
|
||||
size="small"
|
||||
>QoS {{ t.qos }}</n-tag>
|
||||
</template>
|
||||
<div v-if="editingTopicId === (t.id || t.topic)" class="topic-edit-row">
|
||||
<n-input
|
||||
v-model:value="editingTopic"
|
||||
size="small"
|
||||
placeholder="主题名称"
|
||||
@keydown.enter="handleEditSave(t)"
|
||||
/>
|
||||
<n-select
|
||||
v-model:value="editingQos"
|
||||
size="small"
|
||||
:options="qosOptions"
|
||||
style="width: 160px; flex-shrink: 0"
|
||||
/>
|
||||
<n-space :size="4" :wrap="false">
|
||||
<n-button size="tiny" type="primary" @click="handleEditSave(t)">保存</n-button>
|
||||
<n-button size="tiny" @click="cancelEdit">取消</n-button>
|
||||
</n-space>
|
||||
</div>
|
||||
<div v-else class="topic-name">
|
||||
<n-ellipsis>{{ t.topic }}</n-ellipsis>
|
||||
<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
|
||||
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"
|
||||
/>
|
||||
<n-space
|
||||
class="topic-edit-actions"
|
||||
:size="4"
|
||||
:wrap="false"
|
||||
>
|
||||
<n-button
|
||||
size="tiny"
|
||||
type="primary"
|
||||
@click="handleEditSave(t)"
|
||||
>保存</n-button>
|
||||
<n-button
|
||||
size="tiny"
|
||||
@click="cancelEdit"
|
||||
>取消</n-button>
|
||||
</n-space>
|
||||
</div>
|
||||
</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
|
||||
size="tiny"
|
||||
:type="t.subscribed ? 'error' : 'success'"
|
||||
@click="startEdit(t)"
|
||||
>编辑</n-button>
|
||||
<n-button
|
||||
class="topic-action-sub"
|
||||
size="tiny"
|
||||
:type="t.subscribed ? 'success' : 'primary'"
|
||||
@click="handleToggleSub(t.topic)"
|
||||
>
|
||||
{{ t.subscribed ? '取消订阅' : '订阅' }}
|
||||
</n-button>
|
||||
<n-button size="tiny" type="error" text @click="handleRemoveTopic(t.topic)">
|
||||
✕
|
||||
{{ t.subscribed ? '已订阅' : '未订阅' }}
|
||||
</n-button>
|
||||
<n-button
|
||||
class="topic-action-remove"
|
||||
size="tiny"
|
||||
type="error"
|
||||
text
|
||||
@click.stop="handleRemoveTopic(t.topic)"
|
||||
>✕</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</n-list-item>
|
||||
</n-list>
|
||||
</div>
|
||||
</n-card>
|
||||
|
||||
</div>
|
||||
<n-empty v-else description="请先在左侧选择一个 MQTT 服务器" />
|
||||
|
||||
<!-- 空状态 -->
|
||||
<n-empty
|
||||
v-else
|
||||
description="请先在左侧选择一个 MQTT 服务器"
|
||||
/>
|
||||
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
<style scoped lang="less">
|
||||
.subscribe-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -290,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) {
|
||||
width: 100%;
|
||||
.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;
|
||||
gap: 12px;
|
||||
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,16 @@
|
||||
# CHANGELOG
|
||||
|
||||
## [1.0.1] - 2026-07-21
|
||||
|
||||
### Added
|
||||
|
||||
- 快速发送列表持久化到本地文件 `frost-mqtt-data.json`。
|
||||
- 快速消息支持备注。
|
||||
|
||||
### Fixed
|
||||
|
||||
- 解决“快速发送”模块中,主题名称较长时,外层卡片缩小到一定程度后不能再随外层容器缩小而缩小的问题。
|
||||
|
||||
## [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`,并修复实际报告的问题。
|
||||