32 Commits

Author SHA1 Message Date
frost-zx 330a9a48fd chore: 更新版本信息(v1.0.0) 2026-07-21 22:33:16 +08:00
frost-zx e233911d5e docs: 添加 TODO.md 2026-07-21 22:29:29 +08:00
frost-zx 821b895d04 feat: 优化过滤主题功能,支持下拉选择 2026-07-21 22:28:55 +08:00
frost-zx 6a60b4daf4 docs: 更新代码风格说明 2026-07-21 21:18:09 +08:00
frost-zx e491dd78bf refactor: 整理代码,使用 Less 2026-07-21 21:17:43 +08:00
frost-zx 7ec0ad36ba chore: 安装 less 2026-07-21 21:11:35 +08:00
frost-zx 0828edf6b1 fix: 优化订阅主题列表的样式 2026-07-21 20:43:49 +08:00
frost-zx 7eb62ca37f feat: 增加订阅主题备注功能 2026-07-20 22:03:03 +08:00
frost-zx cd2f926ba7 docs: 添加代码风格说明 2026-07-20 21:14:00 +08:00
frost-zx 515fc2158b feat: 优化“已订阅主题列表”,自动对数据进行按主题名称升序排序 2026-07-20 00:38:09 +08:00
frost-zx a1106d4ce6 feat: 增加“快速发送”功能 2026-07-20 00:28:53 +08:00
frost-zx b23cf4d854 chore: 完善注释,添加 JSDoc 2026-07-19 23:08:58 +08:00
frost-zx 089bfaacf7 fix: 解决添加主题后进行连接,或已连接时添加主题,订阅主题列表的内容会出现错乱的问题 2026-07-19 20:03:06 +08:00
frost-zx 21683cb790 feat: 增加服务器信息导入、导出功能 2026-07-19 20:00:33 +08:00
frost-zx 2682c0a1d6 feat: 支持查看服务器密码 2026-07-19 19:42:48 +08:00
frost-zx 4b01ae9898 feat: 服务器列表自动按名称升序排序 2026-07-19 19:36:45 +08:00
frost-zx ededa0dc19 chore: 更新图标 2026-07-19 19:30:53 +08:00
frost-zx 1483712c93 chore: 更新项目信息 2026-07-19 18:48:00 +08:00
frost-zx e4122162ba feat: 增加自动重连功能 2026-07-19 18:36:03 +08:00
frost-zx 974dc89292 docs: 更新项目说明 2026-07-19 18:11:43 +08:00
frost-zx 611cb0c6d7 chore: 修改标题为“多功能 MQTT 客户端” 2026-07-19 18:07:49 +08:00
frost-zx 455833c9d4 chore: 修改主题配色 2026-07-19 17:34:09 +08:00
frost-zx 0c07a7c623 fix: 优化界面样式和布局 2026-07-19 17:29:02 +08:00
frost-zx 1bef01c0b2 chore: 移除不需要的内容 2026-07-19 17:15:38 +08:00
frost-zx 78797dee86 style: 整理代码格式 2026-07-19 17:15:28 +08:00
frost-zx 6bd13851d9 chore: 移除 prettier 2026-07-19 17:15:00 +08:00
frost-zx a62b9557ff chore: 将“发布历史”和“消息监控”中的记录内容改为浅色风格 2026-07-18 22:19:17 +08:00
frost-zx 1c23b85d92 feat: 支持复制消息记录中的主题名称 2026-07-18 22:08:19 +08:00
frost-zx 72ee175bff chore: 修改默认服务器列表 2026-07-18 22:02:53 +08:00
frost-zx 771d8447b4 fix: 首次进入界面时不需要选中服务器 2026-07-18 22:01:39 +08:00
frost-zx acee68ab58 fix: 优化“消息监控”标题样式 2026-07-18 22:01:21 +08:00
frost-zx 9c2a469e4d fix: 解决消息记录出现警告 Duplicate keys found during update 的问题 2026-07-18 21:57:56 +08:00
37 changed files with 4279 additions and 1394 deletions
+108
View File
@@ -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`
-6
View File
@@ -1,6 +0,0 @@
out
dist
pnpm-lock.yaml
LICENSE.md
tsconfig.json
tsconfig.*.json
-4
View File
@@ -1,4 +0,0 @@
singleQuote: true
semi: false
printWidth: 100
trailingComma: none
+1 -11
View File
@@ -1,11 +1 @@
{ {}
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
}
-34
View File
@@ -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
```
Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 16 KiB

+17 -11
View File
@@ -1,5 +1,5 @@
appId: com.electron.app appId: top.frost-zx.frost-mqtt-client
productName: frost-mqtt-client productName: Frost MQTT Client
directories: directories:
buildResources: build buildResources: build
files: files:
@@ -13,10 +13,15 @@ asarUnpack:
win: win:
executableName: frost-mqtt-client executableName: frost-mqtt-client
nsis: 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} shortcutName: ${productName}
uninstallDisplayName: ${productName} uninstallDisplayName: ${productName}
createDesktopShortcut: always
mac: mac:
entitlementsInherit: build/entitlements.mac.plist entitlementsInherit: build/entitlements.mac.plist
extendInfo: extendInfo:
@@ -26,16 +31,17 @@ mac:
- NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder. - NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder.
notarize: false notarize: false
dmg: dmg:
artifactName: ${name}-${version}.${ext} artifactName: ${name}-${version}-macOS-${arch}.${ext}
linux: linux:
target:
- AppImage
- snap
- deb
maintainer: electronjs.org
category: Utility category: Utility
artifactName: ${name}-${version}-Linux-${arch}.${ext}
maintainer: frost-zx.github.io
target:
# - AppImage
# - snap
- deb
appImage: appImage:
artifactName: ${name}-${version}.${ext} artifactName: ${name}-${version}-Linux-${arch}.${ext}
npmRebuild: false npmRebuild: false
publish: publish:
provider: generic provider: generic
+12 -10
View File
@@ -1,10 +1,11 @@
import eslintConfig from '@electron-toolkit/eslint-config' import eslintConfig from '@electron-toolkit/eslint-config'
import eslintConfigPrettier from '@electron-toolkit/eslint-config-prettier'
import eslintPluginVue from 'eslint-plugin-vue' import eslintPluginVue from 'eslint-plugin-vue'
import vueParser from 'vue-eslint-parser' import vueParser from 'vue-eslint-parser'
export default [ export default [
{ ignores: ['**/node_modules', '**/dist', '**/out'] }, {
ignores: ['**/node_modules', '**/dist', '**/out'],
},
eslintConfig, eslintConfig,
...eslintPluginVue.configs['flat/recommended'], ...eslintPluginVue.configs['flat/recommended'],
{ {
@@ -13,18 +14,19 @@ export default [
parser: vueParser, parser: vueParser,
parserOptions: { parserOptions: {
ecmaFeatures: { ecmaFeatures: {
jsx: true jsx: true,
},
extraFileExtensions: ['.vue'],
},
}, },
extraFileExtensions: ['.vue']
}
}
}, },
{ {
files: ['**/*.{js,jsx,vue}'], files: ['**/*.{js,jsx,vue}'],
rules: { rules: {
'vue/multi-word-component-names': 'off',
'vue/multiline-html-element-content-newline': 'off',
'vue/require-default-prop': 'off', 'vue/require-default-prop': 'off',
'vue/multi-word-component-names': 'off' 'vue/singleline-html-element-content-newline': 'off',
}
}, },
eslintConfigPrettier },
] ];
+24
View File
@@ -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"
]
}
+4 -6
View File
@@ -1,12 +1,11 @@
{ {
"name": "frost-mqtt-client", "name": "frost-mqtt-client",
"version": "1.0.0", "version": "1.0.0",
"description": "An Electron application with Vue", "description": "一个使用 AI 开发的多功能 MQTT 桌面客户端,基于 Electron + Vue 3 + Naive UI 构建。",
"main": "./out/main/index.js", "main": "./out/main/index.js",
"author": "example.com", "author": "Frost-ZX",
"homepage": "https://electron-vite.org", "homepage": "https://frost-zx.github.io/",
"scripts": { "scripts": {
"format": "prettier --write .",
"lint": "eslint --cache .", "lint": "eslint --cache .",
"start": "electron-vite preview", "start": "electron-vite preview",
"dev": "electron-vite dev", "dev": "electron-vite dev",
@@ -24,15 +23,14 @@
}, },
"devDependencies": { "devDependencies": {
"@electron-toolkit/eslint-config": "^2.1.0", "@electron-toolkit/eslint-config": "^2.1.0",
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
"@vitejs/plugin-vue": "^6.0.2", "@vitejs/plugin-vue": "^6.0.2",
"electron": "^39.2.6", "electron": "^39.2.6",
"electron-builder": "^26.0.12", "electron-builder": "^26.0.12",
"electron-vite": "^5.0.0", "electron-vite": "^5.0.0",
"eslint": "^9.39.1", "eslint": "^9.39.1",
"less": "^4.7.0",
"eslint-plugin-vue": "^10.6.2", "eslint-plugin-vue": "^10.6.2",
"naive-ui": "^2.44.1", "naive-ui": "^2.44.1",
"prettier": "^3.7.4",
"vite": "^7.2.6", "vite": "^7.2.6",
"vue": "^3.5.25", "vue": "^3.5.25",
"vue-eslint-parser": "^10.2.0" "vue-eslint-parser": "^10.2.0"
+182 -93
View File
@@ -21,12 +21,9 @@ importers:
'@electron-toolkit/eslint-config': '@electron-toolkit/eslint-config':
specifier: ^2.1.0 specifier: ^2.1.0
version: 2.1.0(eslint@9.39.5(jiti@2.7.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': '@vitejs/plugin-vue':
specifier: ^6.0.2 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: electron:
specifier: ^39.2.6 specifier: ^39.2.6
version: 39.8.10 version: 39.8.10
@@ -35,22 +32,22 @@ importers:
version: 26.15.3(electron-builder-squirrel-windows@26.15.3) version: 26.15.3(electron-builder-squirrel-windows@26.15.3)
electron-vite: electron-vite:
specifier: ^5.0.0 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: eslint:
specifier: ^9.39.1 specifier: ^9.39.1
version: 9.39.5(jiti@2.7.0) version: 9.39.5(jiti@2.7.0)
eslint-plugin-vue: eslint-plugin-vue:
specifier: ^10.6.2 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))) 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: naive-ui:
specifier: ^2.44.1 specifier: ^2.44.1
version: 2.44.1(vue@3.5.39) version: 2.44.1(vue@3.5.39)
prettier:
specifier: ^3.7.4
version: 3.9.5
vite: vite:
specifier: ^7.2.6 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: vue:
specifier: ^3.5.25 specifier: ^3.5.25
version: 3.5.39 version: 3.5.39
@@ -151,12 +148,6 @@ packages:
peerDependencies: peerDependencies:
vue: ^3.0.11 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': '@electron-toolkit/eslint-config@2.1.0':
resolution: {integrity: sha512-F/r45x5wDHs8r2RSkXwcrMYo9X4lkc/W+ZTlWzOzjXn7ncirMqVo256BCAogeNBWIxd24ePsSwmJXELiQZUZUg==} resolution: {integrity: sha512-F/r45x5wDHs8r2RSkXwcrMYo9X4lkc/W+ZTlWzOzjXn7ncirMqVo256BCAogeNBWIxd24ePsSwmJXELiQZUZUg==}
peerDependencies: peerDependencies:
@@ -638,10 +629,6 @@ packages:
resolution: {integrity: sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ==} resolution: {integrity: sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ==}
engines: {node: '>=14.18.0'} 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': '@rolldown/pluginutils@1.0.1':
resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
@@ -1104,6 +1091,10 @@ packages:
convert-source-map@2.0.0: convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} 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: core-util-is@1.0.3:
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
@@ -1136,6 +1127,22 @@ packages:
date-fns@4.4.0: date-fns@4.4.0:
resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==} 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: debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'} engines: {node: '>=6.0'}
@@ -1248,6 +1255,10 @@ packages:
err-code@2.0.3: err-code@2.0.3:
resolution: {integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==} 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: es-define-property@1.0.1:
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -1285,26 +1296,6 @@ packages:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'} 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: eslint-plugin-vue@10.9.2:
resolution: {integrity: sha512-4g7ZP3pYcuqd7Zp0pzUKcos0W+RkjBz4EGdhJ92FcYk6v03Ti/GK5NwjgsjxHK+98eXDbHeK7VtX1az7/8doZA==} resolution: {integrity: sha512-4g7ZP3pYcuqd7Zp0pzUKcos0W+RkjBz4EGdhJ92FcYk6v03Ti/GK5NwjgsjxHK+98eXDbHeK7VtX1az7/8doZA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -1398,9 +1389,6 @@ packages:
fast-deep-equal@3.1.3: fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} 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: fast-json-stable-stringify@2.1.0:
resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
@@ -1583,6 +1571,14 @@ packages:
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
engines: {node: '>= 14'} 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: ieee754@1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
@@ -1621,6 +1617,10 @@ packages:
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
is-what@4.1.16:
resolution: {integrity: sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==}
engines: {node: '>=12.13'}
isarray@1.0.0: isarray@1.0.0:
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
@@ -1699,6 +1699,11 @@ packages:
lazy-val@1.0.5: lazy-val@1.0.5:
resolution: {integrity: sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==} 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: levn@0.4.1:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
@@ -1733,6 +1738,10 @@ packages:
magic-string@0.30.21: magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} 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: matcher@3.0.0:
resolution: {integrity: sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==} resolution: {integrity: sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -1749,6 +1758,11 @@ packages:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
mime@1.6.0:
resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==}
engines: {node: '>=4'}
hasBin: true
mime@2.6.0: mime@2.6.0:
resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==}
engines: {node: '>=4.0.0'} engines: {node: '>=4.0.0'}
@@ -1800,6 +1814,9 @@ packages:
engines: {node: '>=16.0.0'} engines: {node: '>=16.0.0'}
hasBin: true hasBin: true
ms@2.0.0:
resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==}
ms@2.1.3: ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -1817,6 +1834,16 @@ packages:
natural-compare@1.4.0: natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} 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: node-abi@4.33.0:
resolution: {integrity: sha512-vLBWCKb+7LWsX+TbfzWOkw0W81m377tyx3hOweBTjO43CXZnRGS1/JPWs20fr0PgZyDXk6ROYrylsEycK8raDA==} resolution: {integrity: sha512-vLBWCKb+7LWsX+TbfzWOkw0W81m377tyx3hOweBTjO43CXZnRGS1/JPWs20fr0PgZyDXk6ROYrylsEycK8raDA==}
engines: {node: '>=22.12.0'} engines: {node: '>=22.12.0'}
@@ -1878,6 +1905,10 @@ packages:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'} engines: {node: '>=6'}
parse-node-version@1.0.1:
resolution: {integrity: sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA==}
engines: {node: '>= 0.10'}
path-exists@4.0.0: path-exists@4.0.0:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -1929,14 +1960,8 @@ packages:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
prettier-linter-helpers@1.0.1: probe-image-size@7.3.0:
resolution: {integrity: sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==} resolution: {integrity: sha512-7CaDeBwiAbh6ohXsvLbAZhO7wzsZAmaevfxe39qvCwRh8LyaZfDlBGGLU1CCTgrTLtCOdwBBhjOrIHaIIimHfQ==}
engines: {node: '>=6.0.0'}
prettier@3.9.5:
resolution: {integrity: sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==}
engines: {node: '>=14'}
hasBin: true
proc-log@6.1.0: proc-log@6.1.0:
resolution: {integrity: sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==} resolution: {integrity: sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==}
@@ -1960,6 +1985,9 @@ packages:
proper-lockfile@4.1.2: proper-lockfile@4.1.2:
resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==}
prr@1.0.1:
resolution: {integrity: sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==}
pump@3.0.4: pump@3.0.4:
resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==}
@@ -2042,6 +2070,9 @@ packages:
safe-buffer@5.2.1: safe-buffer@5.2.1:
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
sanitize-filename@1.6.4: sanitize-filename@1.6.4:
resolution: {integrity: sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg==} resolution: {integrity: sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg==}
@@ -2122,6 +2153,9 @@ packages:
resolution: {integrity: sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==} resolution: {integrity: sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
stream-parser@0.3.1:
resolution: {integrity: sha512-bJ/HgKq41nlKvlhccD5kaCr/P+Hu0wPNKPJOH7en+YrJu/9EgqUF+88w5Jb6KNcjOFMhfX4B2asfeAtIGuHObQ==}
string-width@4.2.3: string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -2148,10 +2182,6 @@ packages:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'} 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: tar@7.5.20:
resolution: {integrity: sha512-9FcyK4PA6+WbzlTM9WhQm6vB5W7cP7dUiPsv1g7YDwEQnQ1CGpK3MGlKk/ITVWMk05kHZuBhmVhiv8LZoy/PFQ==} resolution: {integrity: sha512-9FcyK4PA6+WbzlTM9WhQm6vB5W7cP7dUiPsv1g7YDwEQnQ1CGpK3MGlKk/ITVWMk05kHZuBhmVhiv8LZoy/PFQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -2511,15 +2541,6 @@ snapshots:
dependencies: dependencies:
vue: 3.5.39 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))': '@electron-toolkit/eslint-config@2.1.0(eslint@9.39.5(jiti@2.7.0))':
dependencies: dependencies:
'@eslint/js': 9.39.5 '@eslint/js': 9.39.5
@@ -2911,8 +2932,6 @@ snapshots:
tslib: 2.8.1 tslib: 2.8.1
webcrypto-core: 1.9.2 webcrypto-core: 1.9.2
'@pkgr/core@0.3.6': {}
'@rolldown/pluginutils@1.0.1': {} '@rolldown/pluginutils@1.0.1': {}
'@rollup/rollup-android-arm-eabi@4.62.2': '@rollup/rollup-android-arm-eabi@4.62.2':
@@ -3056,10 +3075,10 @@ snapshots:
'@types/node': 22.20.1 '@types/node': 22.20.1
optional: true 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: dependencies:
'@rolldown/pluginutils': 1.0.1 '@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: 3.5.39
'@vue/compiler-core@3.5.39': '@vue/compiler-core@3.5.39':
@@ -3383,6 +3402,10 @@ snapshots:
convert-source-map@2.0.0: {} convert-source-map@2.0.0: {}
copy-anything@3.0.5:
dependencies:
is-what: 4.1.16
core-util-is@1.0.3: {} core-util-is@1.0.3: {}
cross-dirname@0.1.0: cross-dirname@0.1.0:
@@ -3411,6 +3434,16 @@ snapshots:
date-fns@4.4.0: {} 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: debug@4.4.3:
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
@@ -3518,7 +3551,7 @@ snapshots:
electron-to-chromium@1.5.392: {} 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: dependencies:
'@babel/core': 7.29.7 '@babel/core': 7.29.7
'@babel/plugin-transform-arrow-functions': 7.29.7(@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 esbuild: 0.25.12
magic-string: 0.30.21 magic-string: 0.30.21
picocolors: 1.1.1 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: transitivePeerDependencies:
- supports-color - supports-color
@@ -3562,6 +3595,11 @@ snapshots:
err-code@2.0.3: {} err-code@2.0.3: {}
errno@0.1.8:
dependencies:
prr: 1.0.1
optional: true
es-define-property@1.0.1: {} es-define-property@1.0.1: {}
es-errors@1.3.0: {} es-errors@1.3.0: {}
@@ -3642,19 +3680,6 @@ snapshots:
escape-string-regexp@4.0.0: {} 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))): 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: dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.5(jiti@2.7.0)) '@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-deep-equal@3.1.3: {}
fast-diff@1.3.0: {}
fast-json-stable-stringify@2.1.0: {} fast-json-stable-stringify@2.1.0: {}
fast-levenshtein@2.0.6: {} fast-levenshtein@2.0.6: {}
@@ -3987,6 +4010,16 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - 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: {} ieee754@1.2.1: {}
ignore@5.3.2: {} ignore@5.3.2: {}
@@ -4015,6 +4048,8 @@ snapshots:
dependencies: dependencies:
is-extglob: 2.1.1 is-extglob: 2.1.1
is-what@4.1.16: {}
isarray@1.0.0: {} isarray@1.0.0: {}
isbinaryfile@4.0.10: {} isbinaryfile@4.0.10: {}
@@ -4074,6 +4109,21 @@ snapshots:
lazy-val@1.0.5: {} 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: levn@0.4.1:
dependencies: dependencies:
prelude-ls: 1.2.1 prelude-ls: 1.2.1
@@ -4105,6 +4155,9 @@ snapshots:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
make-dir@5.1.0:
optional: true
matcher@3.0.0: matcher@3.0.0:
dependencies: dependencies:
escape-string-regexp: 4.0.0 escape-string-regexp: 4.0.0
@@ -4118,6 +4171,9 @@ snapshots:
dependencies: dependencies:
mime-db: 1.52.0 mime-db: 1.52.0
mime@1.6.0:
optional: true
mime@2.6.0: {} mime@2.6.0: {}
mimic-response@1.0.1: {} mimic-response@1.0.1: {}
@@ -4183,6 +4239,9 @@ snapshots:
- supports-color - supports-color
- utf-8-validate - utf-8-validate
ms@2.0.0:
optional: true
ms@2.1.3: {} ms@2.1.3: {}
naive-ui@2.44.1(vue@3.5.39): naive-ui@2.44.1(vue@3.5.39):
@@ -4211,6 +4270,21 @@ snapshots:
natural-compare@1.4.0: {} 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: node-abi@4.33.0:
dependencies: dependencies:
semver: 7.7.4 semver: 7.7.4
@@ -4283,6 +4357,8 @@ snapshots:
dependencies: dependencies:
callsites: 3.1.0 callsites: 3.1.0
parse-node-version@1.0.1: {}
path-exists@4.0.0: {} path-exists@4.0.0: {}
path-is-absolute@1.0.1: {} path-is-absolute@1.0.1: {}
@@ -4330,11 +4406,14 @@ snapshots:
prelude-ls@1.2.1: {} prelude-ls@1.2.1: {}
prettier-linter-helpers@1.0.1: probe-image-size@7.3.0:
dependencies: dependencies:
fast-diff: 1.3.0 lodash.merge: 4.6.2
needle: 2.9.1
prettier@3.9.5: {} stream-parser: 0.3.1
transitivePeerDependencies:
- supports-color
optional: true
proc-log@6.1.0: {} proc-log@6.1.0: {}
@@ -4355,6 +4434,9 @@ snapshots:
retry: 0.12.0 retry: 0.12.0
signal-exit: 3.0.7 signal-exit: 3.0.7
prr@1.0.1:
optional: true
pump@3.0.4: pump@3.0.4:
dependencies: dependencies:
end-of-stream: 1.4.5 end-of-stream: 1.4.5
@@ -4469,6 +4551,9 @@ snapshots:
safe-buffer@5.2.1: {} safe-buffer@5.2.1: {}
safer-buffer@2.1.2:
optional: true
sanitize-filename@1.6.4: sanitize-filename@1.6.4:
dependencies: dependencies:
truncate-utf8-bytes: 1.0.2 truncate-utf8-bytes: 1.0.2
@@ -4528,6 +4613,13 @@ snapshots:
stat-mode@1.0.0: {} 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: string-width@4.2.3:
dependencies: dependencies:
emoji-regex: 8.0.0 emoji-regex: 8.0.0
@@ -4558,10 +4650,6 @@ snapshots:
dependencies: dependencies:
has-flag: 4.0.0 has-flag: 4.0.0
synckit@0.11.13:
dependencies:
'@pkgr/core': 0.3.6
tar@7.5.20: tar@7.5.20:
dependencies: dependencies:
'@isaacs/fs-minipass': 4.0.1 '@isaacs/fs-minipass': 4.0.1
@@ -4649,7 +4737,7 @@ snapshots:
evtd: 0.2.4 evtd: 0.2.4
vue: 3.5.39 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: dependencies:
esbuild: 0.28.1 esbuild: 0.28.1
fdir: 6.5.0(picomatch@4.0.5) fdir: 6.5.0(picomatch@4.0.5)
@@ -4661,6 +4749,7 @@ snapshots:
'@types/node': 26.1.1 '@types/node': 26.1.1
fsevents: 2.3.3 fsevents: 2.3.3
jiti: 2.7.0 jiti: 2.7.0
less: 4.7.0
vooks@0.2.12(vue@3.5.39): vooks@0.2.12(vue@3.5.39):
dependencies: dependencies:
Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 16 KiB

+1
View File
@@ -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

+52 -42
View File
@@ -1,11 +1,17 @@
import { app, shell, BrowserWindow } from 'electron' import { app, shell, BrowserWindow } from 'electron';
import { join } from 'path' import { join } from 'path';
import { electronApp, optimizer, is } from '@electron-toolkit/utils' import { electronApp, optimizer, is } from '@electron-toolkit/utils';
import icon from '../../resources/icon.png?asset' import { registerIpcHandlers } from './ipc-handlers.js';
import { registerIpcHandlers } from './ipc-handlers.js'
import icon from '../../resources/icon.png?asset';
/**
* 创建主窗口
* @returns {BrowserWindow} 主窗口实例
*/
function createWindow() { function createWindow() {
// Create the browser window.
// 创建浏览器窗口
const mainWindow = new BrowserWindow({ const mainWindow = new BrowserWindow({
width: 1200, width: 1200,
height: 800, height: 800,
@@ -16,62 +22,66 @@ function createWindow() {
...(process.platform === 'linux' ? { icon } : {}), ...(process.platform === 'linux' ? { icon } : {}),
webPreferences: { webPreferences: {
preload: join(__dirname, '../preload/index.js'), preload: join(__dirname, '../preload/index.js'),
sandbox: false sandbox: false,
} },
}) });
// 窗口准备就绪后显示,避免白屏
mainWindow.on('ready-to-show', () => { mainWindow.on('ready-to-show', () => {
mainWindow.show() mainWindow.show();
}) });
// 拦截新窗口打开请求,使用系统默认浏览器打开外部链接
mainWindow.webContents.setWindowOpenHandler((details) => { mainWindow.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url) shell.openExternal(details.url);
return { action: 'deny' } return { action: 'deny' };
}) });
// HMR for renderer base on electron-vite cli. // 基于 electron-vite cli 的热更新
// Load the remote URL for development or the local html file for production. // 开发环境加载远程 URL,生产环境加载本地 HTML 文件
if (is.dev && process.env['ELECTRON_RENDERER_URL']) { if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']) mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']);
} else { } else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html')) mainWindow.loadFile(join(__dirname, '../renderer/index.html'));
} }
} }
// This method will be called when Electron has finished // 当 Electron 完成初始化并准备好创建浏览器窗口时调用此方法
// initialization and is ready to create browser windows. // 某些 API 只能在此事件触发后使用
// Some APIs can only be used after this event occurs.
app.whenReady().then(() => { app.whenReady().then(() => {
// Set app user model id for windows
electronApp.setAppUserModelId('com.electron')
// Default open or close DevTools by F12 in development // 为 Windows 设置应用用户模型 ID
// and ignore CommandOrControl + R in production. electronApp.setAppUserModelId('com.electron');
// see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
// 开发环境通过 F12 打开/关闭 DevTools
// 生产环境忽略 CommandOrControl + R 刷新
// 详见 https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
app.on('browser-window-created', (_, window) => { app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window) optimizer.watchWindowShortcuts(window);
}) });
// 注册 IPC 处理器 // 注册 IPC 处理器
registerIpcHandlers() registerIpcHandlers();
createWindow() createWindow();
app.on('activate', function () { app.on('activate', function () {
// On macOS it's common to re-create a window in the app when the // macOS 上,当点击应用图标且没有其他窗口打开时,通常会重新创建一个窗口
// dock icon is clicked and there are no other windows open. if (BrowserWindow.getAllWindows().length === 0) {
if (BrowserWindow.getAllWindows().length === 0) createWindow() 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', () => { app.on('window-all-closed', () => {
if (process.platform !== 'darwin') { 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. // 也可以将它们放到单独的文件中,然后在这里引入
+85 -61
View File
@@ -1,18 +1,31 @@
import { ipcMain, BrowserWindow } from 'electron' import { ipcMain, BrowserWindow } from 'electron';
import mqttManager from './mqtt-manager.js'
import mqttManager from './mqtt-manager.js';
/**
* 注册所有 IPC 处理器
* 将渲染进程的请求转发给 MQTT 管理器,并把 MQTT 事件推送给渲染进程
*/
export function registerIpcHandlers() { export function registerIpcHandlers() {
// 获取主窗口(用于推送事件)
/**
* 获取当前所有窗口中的第一个主窗口
* @returns {BrowserWindow|null} 主窗口实例或 null
*/
function getMainWindow() { function getMainWindow() {
const windows = BrowserWindow.getAllWindows() const windows = BrowserWindow.getAllWindows();
return windows.length > 0 ? windows[0] : null return windows.length > 0 ? windows[0] : null;
} }
// 向渲染进程发送事件 /**
* 向渲染进程发送事件
* @param {string} channel - IPC 通道名称
* @param {*} data - 要发送的数据
*/
function sendToRenderer(channel, data) { function sendToRenderer(channel, data) {
const win = getMainWindow() const win = getMainWindow();
if (win && !win.isDestroyed()) { if (win && !win.isDestroyed()) {
win.webContents.send(channel, data) win.webContents.send(channel, data);
} }
} }
@@ -20,100 +33,111 @@ export function registerIpcHandlers() {
mqttManager.addListener((event, data) => { mqttManager.addListener((event, data) => {
switch (event) { switch (event) {
case 'status-change': case 'status-change':
sendToRenderer('mqtt:status-change', data) sendToRenderer('mqtt:status-change', data);
break break;
case 'message': case 'message':
sendToRenderer('mqtt:message', data) sendToRenderer('mqtt:message', data);
break break;
case 'subscription-change': case 'subscription-change':
sendToRenderer('mqtt:subscription-change', data) sendToRenderer('mqtt:subscription-change', data);
break break;
case 'subscription-error': case 'subscription-error':
sendToRenderer('mqtt:subscription-error', data) sendToRenderer('mqtt:subscription-error', data);
break break;
case 'publish-error': case 'publish-error':
sendToRenderer('mqtt:publish-error', data) sendToRenderer('mqtt:publish-error', data);
break break;
} }
}) });
// ==================== 服务器管理 ==================== // ==================== 服务器管理 ====================
ipcMain.handle('mqtt:get-servers', () => { ipcMain.handle('mqtt:get-servers', () => {
return mqttManager.getServers() return mqttManager.getServers();
}) });
ipcMain.handle('mqtt:add-server', (_, config) => { ipcMain.handle('mqtt:add-server', (_, config) => {
return mqttManager.addServer(config) return mqttManager.addServer(config);
}) });
ipcMain.handle('mqtt:update-server', (_, id, config) => { ipcMain.handle('mqtt:update-server', (_, id, config) => {
return mqttManager.updateServer(id, config) return mqttManager.updateServer(id, config);
}) });
ipcMain.handle('mqtt:delete-server', (_, id) => { ipcMain.handle('mqtt:delete-server', (_, id) => {
mqttManager.deleteServer(id) mqttManager.deleteServer(id);
return true return true;
}) });
ipcMain.handle('mqtt:export-servers', () => {
return mqttManager.exportServers();
});
ipcMain.handle('mqtt:import-servers', (_, serverList) => {
return mqttManager.importServers(serverList);
});
// ==================== 连接管理 ==================== // ==================== 连接管理 ====================
ipcMain.handle('mqtt:connect', (_, id) => { ipcMain.handle('mqtt:connect', (_, id) => {
return mqttManager.connect(id) return mqttManager.connect(id);
}) });
ipcMain.handle('mqtt:disconnect', (_, id) => { ipcMain.handle('mqtt:disconnect', (_, id) => {
mqttManager.disconnect(id) mqttManager.disconnect(id);
return true 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) => { ipcMain.handle('mqtt:remove-topic', (_, serverId, topic) => {
return mqttManager.removeTopic(serverId, topic) return mqttManager.removeTopic(serverId, topic);
}) });
ipcMain.handle('mqtt:subscribe', (_, serverId, topic) => { ipcMain.handle('mqtt:subscribe', (_, serverId, topic) => {
return mqttManager.subscribe(serverId, topic) return mqttManager.subscribe(serverId, topic);
}) });
ipcMain.handle('mqtt:unsubscribe', (_, serverId, topic) => { ipcMain.handle('mqtt:unsubscribe', (_, serverId, topic) => {
return mqttManager.unsubscribe(serverId, topic) return mqttManager.unsubscribe(serverId, topic);
}) });
ipcMain.handle('mqtt:subscribe-all', (_, serverId) => { ipcMain.handle('mqtt:subscribe-all', (_, serverId) => {
return mqttManager.subscribeAll(serverId) return mqttManager.subscribeAll(serverId);
}) });
ipcMain.handle('mqtt:update-topic', (_, serverId, topicId, topic, qos) => { ipcMain.handle('mqtt:update-topic', (_, serverId, topicId, topic, qos, comment) => {
return mqttManager.updateTopic(serverId, topicId, topic, qos) return mqttManager.updateTopic(serverId, topicId, topic, qos, comment);
}) });
// ==================== 消息发布 ==================== // ==================== 消息发布 ====================
ipcMain.handle('mqtt:publish', (_, serverId, topic, payload, opts) => { 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) => { ipcMain.handle('mqtt:get-messages', (_, serverId) => {
return mqttManager.getMessages(serverId) return mqttManager.getMessages(serverId);
}) });
ipcMain.handle('mqtt:get-publish-history', (_, serverId) => { ipcMain.handle('mqtt:get-publish-history', (_, serverId) => {
return mqttManager.getPublishHistory(serverId) return mqttManager.getPublishHistory(serverId);
}) });
ipcMain.handle('mqtt:clear-messages', (_, serverId) => { ipcMain.handle('mqtt:clear-messages', (_, serverId) => {
mqttManager.clearMessages(serverId) mqttManager.clearMessages(serverId);
return true return true;
}) });
ipcMain.handle('mqtt:clear-publish-history', (_, serverId) => { ipcMain.handle('mqtt:clear-publish-history', (_, serverId) => {
mqttManager.clearPublishHistory(serverId) mqttManager.clearPublishHistory(serverId);
return true return true;
}) });
// 兼容旧的 ping 测试
ipcMain.on('ping', () => console.log('pong'))
} }
File diff suppressed because it is too large Load Diff
+37 -31
View File
@@ -1,26 +1,33 @@
import { contextBridge, ipcRenderer } from 'electron' import { contextBridge, ipcRenderer } from 'electron';
import { electronAPI } from '@electron-toolkit/preload' import { electronAPI } from '@electron-toolkit/preload';
// Custom APIs for renderer /**
* 渲染进程可调用的自定义 API
* 封装了所有与主进程通信的 IPC 调用及事件监听
*/
const api = { const api = {
// 服务器管理 // 服务器管理
getServers: () => ipcRenderer.invoke('mqtt:get-servers'), getServers: () => ipcRenderer.invoke('mqtt:get-servers'),
addServer: (config) => ipcRenderer.invoke('mqtt:add-server', config), addServer: (config) => ipcRenderer.invoke('mqtt:add-server', config),
updateServer: (id, config) => ipcRenderer.invoke('mqtt:update-server', id, config), updateServer: (id, config) => ipcRenderer.invoke('mqtt:update-server', id, config),
deleteServer: (id) => ipcRenderer.invoke('mqtt:delete-server', id), deleteServer: (id) => ipcRenderer.invoke('mqtt:delete-server', id),
exportServers: () => ipcRenderer.invoke('mqtt:export-servers'),
importServers: (serverList) => ipcRenderer.invoke('mqtt:import-servers', serverList),
// 连接管理 // 连接管理
connect: (id) => ipcRenderer.invoke('mqtt:connect', id), connect: (id) => ipcRenderer.invoke('mqtt:connect', id),
disconnect: (id) => ipcRenderer.invoke('mqtt:disconnect', 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), removeTopic: (serverId, topic) => ipcRenderer.invoke('mqtt:remove-topic', serverId, topic),
subscribe: (serverId, topic) => ipcRenderer.invoke('mqtt:subscribe', serverId, topic), subscribe: (serverId, topic) => ipcRenderer.invoke('mqtt:subscribe', serverId, topic),
unsubscribe: (serverId, topic) => ipcRenderer.invoke('mqtt:unsubscribe', serverId, topic), unsubscribe: (serverId, topic) => ipcRenderer.invoke('mqtt:unsubscribe', serverId, topic),
subscribeAll: (serverId) => ipcRenderer.invoke('mqtt:subscribe-all', serverId), subscribeAll: (serverId) => ipcRenderer.invoke('mqtt:subscribe-all', serverId),
updateTopic: (serverId, topicId, topic, qos) => updateTopic: (serverId, topicId, topic, qos, comment) =>
ipcRenderer.invoke('mqtt:update-topic', serverId, topicId, topic, qos), ipcRenderer.invoke('mqtt:update-topic', serverId, topicId, topic, qos, comment),
// 消息发布 // 消息发布
publish: (serverId, topic, payload, opts) => publish: (serverId, topic, payload, opts) =>
@@ -34,43 +41,42 @@ const api = {
// 事件监听 // 事件监听
onStatusChange: (callback) => { onStatusChange: (callback) => {
const handler = (_, data) => callback(data) const handler = (_, data) => callback(data);
ipcRenderer.on('mqtt:status-change', handler) ipcRenderer.on('mqtt:status-change', handler);
return () => ipcRenderer.removeListener('mqtt:status-change', handler) return () => ipcRenderer.removeListener('mqtt:status-change', handler);
}, },
onMessage: (callback) => { onMessage: (callback) => {
const handler = (_, data) => callback(data) const handler = (_, data) => callback(data);
ipcRenderer.on('mqtt:message', handler) ipcRenderer.on('mqtt:message', handler);
return () => ipcRenderer.removeListener('mqtt:message', handler) return () => ipcRenderer.removeListener('mqtt:message', handler);
}, },
onSubscriptionChange: (callback) => { onSubscriptionChange: (callback) => {
const handler = (_, data) => callback(data) const handler = (_, data) => callback(data);
ipcRenderer.on('mqtt:subscription-change', handler) ipcRenderer.on('mqtt:subscription-change', handler);
return () => ipcRenderer.removeListener('mqtt:subscription-change', handler) return () => ipcRenderer.removeListener('mqtt:subscription-change', handler);
}, },
onSubscriptionError: (callback) => { onSubscriptionError: (callback) => {
const handler = (_, data) => callback(data) const handler = (_, data) => callback(data);
ipcRenderer.on('mqtt:subscription-error', handler) ipcRenderer.on('mqtt:subscription-error', handler);
return () => ipcRenderer.removeListener('mqtt:subscription-error', handler) return () => ipcRenderer.removeListener('mqtt:subscription-error', handler);
}, },
onPublishError: (callback) => { onPublishError: (callback) => {
const handler = (_, data) => callback(data) const handler = (_, data) => callback(data);
ipcRenderer.on('mqtt:publish-error', handler) ipcRenderer.on('mqtt:publish-error', handler);
return () => ipcRenderer.removeListener('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) { if (process.contextIsolated) {
try { try {
contextBridge.exposeInMainWorld('electron', electronAPI) contextBridge.exposeInMainWorld('electron', electronAPI);
contextBridge.exposeInMainWorld('api', api) contextBridge.exposeInMainWorld('api', api);
} catch (error) { } catch (error) {
console.error(error) console.error(error);
} }
} else { } else {
window.electron = electronAPI window.electron = electronAPI;
window.api = api window.api = api;
} }
+3 -3
View File
@@ -1,14 +1,14 @@
<!doctype html> <!DOCTYPE html>
<html> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Frost MQTT Client</title> <title>Frost MQTT Client</title>
<meta <meta
http-equiv="Content-Security-Policy" 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:" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws: wss: mqtt: mqtts:"
/> />
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
<script type="module" src="/src/main.js"></script> <script type="module" src="/src/main.js"></script>
+179 -62
View File
@@ -1,74 +1,143 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue';
import { import { NLayout, NMessageProvider, NDialogProvider, NConfigProvider, NTabs, NTabPane, zhCN, dateZhCN } from 'naive-ui';
NLayout, import { useMqttStore } from './stores/mqtt.js';
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'
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 store = useMqttStore();
const editServerData = ref(null)
// 主题覆盖配置
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 () => { onMounted(async () => {
await store.init() await store.init();
}) });
/**
* 打开新增服务器弹窗
*/
function handleAddServer() { function handleAddServer() {
editServerData.value = null editServerData.value = null;
showServerModal.value = true showServerModal.value = true;
} }
/**
* 打开编辑服务器弹窗
* @param {import('@types/jsdoc').MqttServer} server - 要编辑的服务器对象
*/
function handleEditServer(server) { function handleEditServer(server) {
editServerData.value = server editServerData.value = server;
showServerModal.value = true showServerModal.value = true;
} }
/**
* 服务器保存后的回调
*/
function handleServerSaved() { function handleServerSaved() {
// 刷新数据 // 刷新数据
} }
/**
* 切换标签页
* @param {string} tab - 标签页名称
*/
function handleSwitchTab(tab) { function handleSwitchTab(tab) {
activeTab.value = tab activeTab.value = tab;
} }
</script> </script>
<template> <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-message-provider placement="bottom">
<n-dialog-provider> <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-layout content-class="main-layout-content">
<n-tabs v-model:value="activeTab" type="line" size="medium" style="padding: 0 16px"> <n-tabs
<n-tab-pane name="dashboard" tab="仪表盘"> v-model:value="activeTab"
type="line"
size="medium"
style="padding: 0 16px"
>
<n-tab-pane
name="dashboard"
tab="仪表盘"
>
<div class="tab-content dashboard-content"> <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> </div>
</n-tab-pane> </n-tab-pane>
<n-tab-pane name="subscribe" tab="主题订阅"> <n-tab-pane
name="subscribe"
tab="主题订阅"
>
<div class="tab-content"> <div class="tab-content">
<Subscribe /> <Subscribe />
</div> </div>
</n-tab-pane> </n-tab-pane>
<n-tab-pane name="messages" tab="消息中心"> <n-tab-pane
name="messages"
tab="消息中心"
>
<div class="tab-content"> <div class="tab-content">
<div class="message-center-layout"> <div class="message-center-layout">
<section class="publish-panel"> <section class="publish-panel">
@@ -80,6 +149,14 @@ function handleSwitchTab(tab) {
</div> </div>
</div> </div>
</n-tab-pane> </n-tab-pane>
<n-tab-pane
name="quick-send"
tab="快速发送"
>
<div class="tab-content">
<QuickSend />
</div>
</n-tab-pane>
</n-tabs> </n-tabs>
</n-layout> </n-layout>
@@ -88,16 +165,46 @@ function handleSwitchTab(tab) {
:edit-server="editServerData" :edit-server="editServerData"
@saved="handleServerSaved" @saved="handleServerSaved"
/> />
</n-layout> </n-layout>
</n-dialog-provider> </n-dialog-provider>
</n-message-provider> </n-message-provider>
</n-config-provider> </n-config-provider>
</template> </template>
<style> <style lang="less">
html, :root {
body, --frost-primary-color: #0ea5a0;
#app { --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; margin: 0;
padding: 0; padding: 0;
height: 100%; height: 100%;
@@ -110,12 +217,20 @@ body,
.tab-content { .tab-content {
box-sizing: border-box; box-sizing: border-box;
padding: 12px 0; padding: 0 0 16px 0;
height: calc(100vh - 50px); height: calc(100vh - 50px);
overflow: hidden; overflow: hidden;
&.dashboard-content {
display: flex;
flex-direction: column;
overflow: hidden;
}
} }
.dashboard-content { .dashboard-scroll-wrapper {
flex: 1;
min-height: 0;
overflow-y: auto; overflow-y: auto;
overscroll-behavior: contain; overscroll-behavior: contain;
} }
@@ -126,30 +241,27 @@ body,
gap: 12px; gap: 12px;
height: 100%; height: 100%;
min-width: 0; min-width: 0;
}
.publish-panel, .publish-panel,
.messages-panel { .messages-panel {
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
} }
.publish-panel { .publish-panel {
box-sizing: border-box; box-sizing: border-box;
height: 100%; height: 100%;
min-height: 0; min-height: 0;
padding-right: 4px;
overflow: hidden; overflow: hidden;
} }
.messages-panel { .messages-panel {
height: 100%; height: 100%;
min-height: 0; min-height: 0;
overflow: hidden; overflow: hidden;
} }
@media (max-width: 900px) { @media (max-width: 900px) {
.message-center-layout {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
@@ -159,22 +271,27 @@ body,
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: rgba(144, 147, 153, 0.5) transparent; scrollbar-color: rgba(144, 147, 153, 0.5) transparent;
} }
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 8px; width: 8px;
height: 8px; height: 8px;
}
::-webkit-scrollbar-track { &-track {
background-color: transparent; background-color: transparent;
} }
::-webkit-scrollbar-thumb {
&-thumb {
background-color: rgba(144, 147, 153, 0.5); background-color: rgba(144, 147, 153, 0.5);
border-radius: 4px; border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover { &:hover {
background-color: rgba(144, 147, 153, 0.8); background-color: rgba(144, 147, 153, 0.8);
} }
::-webkit-scrollbar-corner { }
&-corner {
display: none; display: none;
background-color: transparent; background-color: transparent;
}
} }
</style> </style>
+158 -63
View File
@@ -1,31 +1,22 @@
<script setup> <script setup>
import { ref, watch } from 'vue' import { ref, watch } from 'vue';
import { import { NModal, NForm, NGrid, NFormItemGi, NInput, NInputNumber, NSelect, NSwitch, NButton, useMessage } from 'naive-ui';
NModal, import { useMqttStore } from '../stores/mqtt.js';
NForm,
NFormItem,
NGrid,
NFormItemGi,
NInput,
NInputNumber,
NSelect,
NSwitch,
NButton,
useMessage
} from 'naive-ui'
import { useMqttStore } from '../stores/mqtt.js'
// 弹窗显示状态与编辑数据
const props = defineProps({ const props = defineProps({
show: Boolean, show: Boolean,
editServer: Object editServer: Object,
}) });
const emit = defineEmits(['update:show', 'saved']) // 更新 show、保存完成事件
const emit = defineEmits(['update:show', 'saved']);
const store = useMqttStore() const store = useMqttStore();
const msg = useMessage() const msg = useMessage();
const formRef = ref(null) // 表单引用与数据
const formRef = ref(null);
const form = ref({ const form = ref({
name: '', name: '',
host: '', host: '',
@@ -35,30 +26,39 @@ const form = ref({
username: '', username: '',
password: '', password: '',
keepAlive: 60, keepAlive: 60,
cleanSession: true cleanSession: true,
}) connectTimeout: 10000,
reconnect: true,
reconnectInterval: 5000,
});
// 表单校验规则
const rules = { const rules = {
name: { required: true, message: '请输入服务器名称', trigger: 'blur' }, name: { required: true, message: '请输入服务器名称', trigger: 'blur' },
host: { 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 = [ const protocolOptions = [
{ label: 'mqtt:// (默认)', value: 'mqtt' }, { label: 'mqtt:// (默认)', value: 'mqtt' },
{ label: 'mqtts:// (TLS)', value: 'mqtts' }, { label: 'mqtts:// (TLS)', value: 'mqtts' },
{ label: 'ws:// (WebSocket)', value: 'ws' }, { 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( watch(
() => props.show, () => props.show,
(val) => { (val) => {
if (val) { if (val) {
if (props.editServer) { if (props.editServer) {
isEdit.value = true isEdit.value = true;
form.value = { form.value = {
name: props.editServer.name, name: props.editServer.name,
host: props.editServer.host, host: props.editServer.host,
@@ -66,12 +66,15 @@ watch(
protocol: props.editServer.protocol || 'mqtt', protocol: props.editServer.protocol || 'mqtt',
clientId: props.editServer.clientId || '', clientId: props.editServer.clientId || '',
username: props.editServer.username || '', username: props.editServer.username || '',
password: '', password: props.editServer.password || '',
keepAlive: props.editServer.keepAlive || 60, 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 { } else {
isEdit.value = false isEdit.value = false;
form.value = { form.value = {
name: '', name: '',
host: '', host: '',
@@ -81,61 +84,107 @@ watch(
username: '', username: '',
password: '', password: '',
keepAlive: 60, keepAlive: 60,
cleanSession: true cleanSession: true,
connectTimeout: 10000,
reconnect: true,
reconnectInterval: 5000,
};
} }
} }
} }
} );
)
/**
* 保存服务器配置
*/
async function handleSave() { async function handleSave() {
try { try {
await formRef.value?.validate() await formRef.value?.validate();
} catch { } catch {
return return;
} }
try { try {
if (isEdit.value && props.editServer) { if (isEdit.value && props.editServer) {
await store.updateServer(props.editServer.id, { ...form.value }) await store.updateServer(props.editServer.id, { ...form.value });
msg.success('服务器配置已更新') msg.success('服务器配置已更新');
} else { } else {
await store.addServer({ ...form.value }) await store.addServer({ ...form.value });
msg.success('服务器已添加') msg.success('服务器已添加');
} }
emit('saved')
emit('update:show', false) emit('saved');
emit('update:show', false);
} catch (e) { } catch (e) {
msg.error('操作失败: ' + (e.message || '未知错误')) msg.error('操作失败: ' + (e.message || '未知错误'));
} }
} }
</script> </script>
<template> <template>
<n-modal <n-modal
:show="show"
:mask-closable="false" :mask-closable="false"
preset="card" :show="show"
:title="isEdit ? '编辑 MQTT 服务器' : '添加 MQTT 服务器'" :title="isEdit ? '编辑 MQTT 服务器' : '添加 MQTT 服务器'"
preset="card"
style="width: 520px; max-width: 90vw" style="width: 520px; max-width: 90vw"
@update:show="$emit('update:show', $event)" @update:show="$emit('update:show', $event)"
> >
<n-form ref="formRef" :model="form" :rules="rules" label-placement="top" size="medium"> <n-form
<n-grid :cols="2" :x-gap="12"> ref="formRef"
<n-form-item-gi label="服务器名称" path="name"> :model="form"
<n-input v-model:value="form.name" placeholder="例如: 本地开发服务器" /> :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>
<n-form-item-gi label="主机地址" path="host"> <n-form-item-gi
<n-input v-model:value="form.host" placeholder="例如: broker.emqx.io" /> label="主机地址"
path="host"
>
<n-input
v-model:value="form.host"
placeholder="例如: broker.emqx.io"
/>
</n-form-item-gi> </n-form-item-gi>
<n-form-item-gi label="端口" path="port"> <n-form-item-gi
<n-input-number v-model:value="form.port" :min="1" :max="65535" style="width: 100%" /> 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>
<n-form-item-gi label="协议"> <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>
<n-form-item-gi label="客户端 ID"> <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>
<n-form-item-gi label="Keep Alive (秒)"> <n-form-item-gi label="Keep Alive (秒)">
<n-input-number <n-input-number
@@ -146,20 +195,66 @@ async function handleSave() {
/> />
</n-form-item-gi> </n-form-item-gi>
<n-form-item-gi label="用户名"> <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>
<n-form-item-gi label="密码"> <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-form-item-gi>
</n-grid> </n-grid>
<n-form-item label="Clean Session"> <n-grid
:cols="2"
:x-gap="12"
>
<n-form-item-gi label="Clean Session">
<n-switch v-model:value="form.cleanSession" /> <n-switch v-model:value="form.cleanSession" />
</n-form-item> </n-form-item-gi>
<n-form-item-gi label="自动重连">
<n-switch v-model:value="form.reconnect" />
</n-form-item-gi>
<n-form-item-gi
label="连接超时 (毫秒)"
path="connectTimeout"
>
<n-input-number
v-model:value="form.connectTimeout"
:min="1000"
:max="120000"
:step="1000"
style="width: 100%"
/>
</n-form-item-gi>
<n-form-item-gi
label="重连间隔 (毫秒)"
path="reconnectInterval"
>
<n-input-number
v-model:value="form.reconnectInterval"
:min="1000"
:max="300000"
:step="1000"
:disabled="!form.reconnect"
style="width: 100%"
/>
</n-form-item-gi>
</n-grid>
</n-form> </n-form>
<template #footer> <template #footer>
<div style="display: flex; justify-content: flex-end; gap: 8px"> <div style="display: flex; justify-content: flex-end; gap: 8px">
<n-button @click="$emit('update:show', false)">取消</n-button> <n-button
<n-button type="primary" @click="handleSave">{{ isEdit ? '更新' : '保存' }}</n-button> @click="$emit('update:show', false)"
>取消</n-button>
<n-button
type="primary"
@click="handleSave"
>{{ isEdit ? '更新' : '保存' }}</n-button>
</div> </div>
</template> </template>
</n-modal> </n-modal>
+176 -72
View File
@@ -1,25 +1,44 @@
<script setup> <script setup>
import { NLayoutSider, NButton, NTag, useMessage, useDialog } from 'naive-ui' import { ref } from 'vue';
import { useMqttStore } from '../stores/mqtt.js' 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 emit = defineEmits(['add-server', 'edit-server']);
const message = useMessage()
const dialog = useDialog()
// 全局状态与消息/对话框 API
const store = useMqttStore();
const message = useMessage();
const dialog = useDialog();
// 文件输入控件引用,用于导入配置
const fileInput = ref(null);
/**
* 切换服务器连接状态
* @param {import('@types/jsdoc').MqttServer} server - 服务器对象
*/
async function handleConnect(server) { async function handleConnect(server) {
if (server.status === 'connected') { if (server.status === 'connected') {
await store.disconnectServer(server.id) await store.disconnectServer(server.id);
message.info('已断开连接: ' + server.name) message.info('已断开连接: ' + server.name);
} else { } else {
await store.connectServer(server.id) await store.connectServer(server.id);
} }
} }
/**
* 编辑服务器
* @param {import('@types/jsdoc').MqttServer} server - 服务器对象
*/
function handleEdit(server) { function handleEdit(server) {
emit('edit-server', server) emit('edit-server', server);
} }
/**
* 删除服务器,弹出确认对话框
* @param {import('@types/jsdoc').MqttServer} server - 服务器对象
*/
function handleDelete(server) { function handleDelete(server) {
dialog.warning({ dialog.warning({
title: '确认删除', title: '确认删除',
@@ -27,10 +46,54 @@ function handleDelete(server) {
positiveText: '删除', positiveText: '删除',
negativeText: '取消', negativeText: '取消',
onPositiveClick: async () => { onPositiveClick: async () => {
await store.deleteServer(server.id) await store.deleteServer(server.id);
message.info('服务器已删除') 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> </script>
@@ -43,17 +106,23 @@ function handleDelete(server) {
show-trigger="bar" show-trigger="bar"
:native-scrollbar="true" :native-scrollbar="true"
content-class="sidebar-scroll-container" content-class="sidebar-scroll-container"
:content-style="{ height: '100%', overflow: 'hidden' }" :content-style="{
height: '100%',
overflow: 'hidden',
}"
> >
<div class="sidebar-container"> <div class="sidebar-container">
<!-- 侧边栏头部 Logo 与标题 -->
<div class="sidebar-header"> <div class="sidebar-header">
<div class="sidebar-logo">F</div> <div class="sidebar-logo">F</div>
<div> <div>
<div class="sidebar-title">Frost MQTT Client</div> <div class="sidebar-title">Frost MQTT Client</div>
<div class="sidebar-subtitle">服务器 MQTT 客户端</div> <div class="sidebar-subtitle">功能 MQTT 客户端</div>
</div> </div>
</div> </div>
<!-- 服务器列表 -->
<div class="server-list"> <div class="server-list">
<div <div
v-for="srv in store.servers.value" v-for="srv in store.servers.value"
@@ -65,12 +134,21 @@ function handleDelete(server) {
<div class="server-item-top"> <div class="server-item-top">
<span class="server-name">{{ srv.name }}</span> <span class="server-name">{{ srv.name }}</span>
<div class="server-actions"> <div class="server-actions">
<n-button text size="tiny" @click.stop="handleEdit(srv)"> <n-button
text
size="tiny"
@click.stop="handleEdit(srv)"
>
<template #icon> <template #icon>
<span style="font-size: 14px">&#9998;</span> <span style="font-size: 14px">&#9998;</span>
</template> </template>
</n-button> </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> <template #icon>
<span style="font-size: 14px">&#10005;</span> <span style="font-size: 14px">&#10005;</span>
</template> </template>
@@ -81,60 +159,64 @@ function handleDelete(server) {
<n-tag <n-tag
:bordered="false" :bordered="false"
size="small" size="small"
:type=" :type="srv.status === 'connected' ? 'success' : srv.status === 'connecting' ? 'warning' : 'default'"
srv.status === 'connected'
? 'success'
: srv.status === 'connecting'
? 'warning'
: 'default'
"
> >
<template #icon> <template #icon>
<span class="status-dot" :class="srv.status" /> <span
class="status-dot"
:class="srv.status"
/>
</template> </template>
{{ srv.protocol }}://{{ srv.host }}:{{ srv.port }} {{ srv.protocol }}://{{ srv.host }}:{{ srv.port }}
</n-tag> </n-tag>
</div> </div>
<div class="server-bottom"> <div class="server-bottom">
<span class="server-status-text"> <span
{{ class="server-status-text"
srv.status === 'connected' >{{ srv.status === 'connected' ? '已连接' : srv.status === 'connecting' ? '连接中...' : srv.status === 'error' ? '连接失败' : '未连接' }}</span>
? '已连接'
: srv.status === 'connecting'
? '连接中...'
: srv.status === 'error'
? '连接失败'
: '未连接'
}}
</span>
<n-button <n-button
size="tiny" size="tiny"
:type=" :type="srv.status === 'connected' ? 'error' : srv.status === 'connecting' ? 'warning' : 'success'"
srv.status === 'connected'
? 'error'
: srv.status === 'connecting'
? 'warning'
: 'success'
"
:disabled="srv.status === 'connecting'" :disabled="srv.status === 'connecting'"
@click.stop="handleConnect(srv)" @click.stop="handleConnect(srv)"
> >{{ srv.status === 'connected' ? '断开' : srv.status === 'connecting' ? '...' : '连接' }}</n-button>
{{
srv.status === 'connected' ? '断开' : srv.status === 'connecting' ? '...' : '连接'
}}
</n-button>
</div> </div>
</div> </div>
</div> </div>
<!-- 侧边栏底部操作按钮 -->
<div class="sidebar-footer"> <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> </div>
<input
ref="fileInput"
type="file"
accept=".json,application/json"
style="display: none"
@change="handleImportFile"
>
</div>
</div> </div>
</n-layout-sider> </n-layout-sider>
</template> </template>
<style scoped> <style scoped lang="less">
:deep(.sidebar-scroll-container) { :deep(.sidebar-scroll-container) {
height: 100%; height: 100%;
overflow: hidden !important; overflow: hidden !important;
@@ -160,7 +242,7 @@ function handleDelete(server) {
.sidebar-logo { .sidebar-logo {
width: 32px; width: 32px;
height: 32px; height: 32px;
background: #18a058; background: var(--frost-primary-color);
border-radius: 6px; border-radius: 6px;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -196,15 +278,19 @@ function handleDelete(server) {
margin-bottom: 4px; margin-bottom: 4px;
border: 1px solid transparent; border: 1px solid transparent;
transition: all 0.15s; transition: all 0.15s;
}
.server-item:hover { &:hover {
background: var(--n-color-hover); background: var(--n-color-hover);
}
.server-item.active { .server-actions {
opacity: 1;
}
}
&.active {
background: var(--n-color-pressed); background: var(--n-color-pressed);
border-color: #18a058; border-color: var(--frost-primary-color);
}
} }
.server-item-top { .server-item-top {
@@ -228,12 +314,19 @@ function handleDelete(server) {
transition: opacity 0.15s; transition: opacity 0.15s;
} }
.server-item:hover .server-actions {
opacity: 1;
}
.server-url { .server-url {
margin-top: 4px; 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 { .server-bottom {
@@ -254,20 +347,23 @@ function handleDelete(server) {
height: 6px; height: 6px;
border-radius: 50%; border-radius: 50%;
margin-right: 2px; margin-right: 2px;
}
.status-dot.connected { &.connected {
background: #18a058; background: var(--frost-success-color);
} }
.status-dot.connecting {
background: #f0a020; &.connecting {
background: var(--frost-warning-color);
animation: pulse 1s infinite; animation: pulse 1s infinite;
} }
.status-dot.disconnected {
background: #999; &.disconnected {
} background: var(--frost-color-text-tertiary);
.status-dot.error { }
background: #d03050;
&.error {
background: var(--frost-error-color);
}
} }
@keyframes pulse { @keyframes pulse {
@@ -275,6 +371,7 @@ function handleDelete(server) {
100% { 100% {
opacity: 1; opacity: 1;
} }
50% { 50% {
opacity: 0.3; opacity: 0.3;
} }
@@ -285,4 +382,11 @@ function handleDelete(server) {
border-top: 1px solid var(--n-border-color); border-top: 1px solid var(--n-border-color);
flex-shrink: 0; flex-shrink: 0;
} }
.sidebar-import-export {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: 8px;
}
</style> </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>
+9 -4
View File
@@ -1,5 +1,10 @@
import { createApp } from 'vue' import { createApp } from 'vue';
import App from './App.vue'
const app = createApp(App) import App from './App.vue';
app.mount('#app')
/**
* 创建并挂载 Vue 应用
*/
const app = createApp(App);
app.mount('#app');
+466 -115
View File
@@ -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() { /**
// 清理旧监听器 * @typedef {Object} StatusChangeData 服务器状态变更事件数据
cleanupFns.forEach((fn) => fn()) * @property {string} id 服务器 id
cleanupFns = [] * @property {MqttServerStatus} status 新状态
* @property {string} [error] 错误信息状态为 error 时存在
*/
cleanupFns.push( /**
window.api.onStatusChange((data) => { * @typedef {Object} MessageEventData 消息接收事件数据
const srv = servers.value.find((s) => s.id === data.id) * @property {string} serverId 所属服务器 id
if (srv) { * @property {MqttMessage} message 消息对象
srv.status = data.status */
if (data.status === 'disconnected') {
srv.topics.forEach((t) => {
t.subscribed = false
})
}
}
})
)
cleanupFns.push( /**
window.api.onMessage((data) => { * @typedef {Object} SubscriptionChangeData 订阅状态变更事件数据
if (data.serverId === activeServerId.value) { * @property {string} serverId 所属服务器 id
messages.value.unshift(data.message) * @property {string} topic 主题名称
if (messages.value.length > 500) messages.value.pop() * @property {string} [topicId] 主题 id
} * @property {string} [comment] 主题备注
}) * @property {boolean} subscribed 是否已订阅
) */
cleanupFns.push( /**
window.api.onSubscriptionChange((data) => { * @typedef {Object} MqttStore
if (data.serverId === activeServerId.value) { * @property {import('vue').Ref<MqttServer[]>} servers 所有服务器列表
const srv = servers.value.find((s) => s.id === data.serverId) * @property {import('vue').Ref<string | null>} activeServerId 当前活动服务器 ID
if (srv) { * @property {import('vue').ComputedRef<MqttServer | null>} activeServer 当前活动服务器对象
const t = srv.topics.find((t) => t.topic === data.topic || t.id === data.topicId) * @property {import('vue').Ref<MqttMessage[]>} messages 当前活动服务器消息列表
if (t) { * @property {import('vue').Ref<PublishRecord[]>} publishHistory 当前活动服务器发布历史
t.subscribed = data.subscribed * @property {import('vue').Ref<boolean>} loading 全局加载状态
if (data.topic && data.topic !== t.topic) { * @property {import('vue').ComputedRef<MqttServer[]>} connectedServers 已连接服务器列表
t.topic = data.topic * @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() { export function useMqttStore() {
const activeServer = computed(
/**
* 当前选中的服务器
* @type {import('vue').ComputedRef<MqttServer | null>}
*/
let activeServer = computed(
() => servers.value.find((s) => s.id === activeServerId.value) || null () => 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) 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() { async function init() {
loading.value = true
loading.value = true;
try { try {
const data = await window.api.getServers()
servers.value = data let data = await window.api.getServers();
if (data.length > 0 && !activeServerId.value) {
activeServerId.value = data[0].id servers.value = data;
} sortServers();
setupListeners() servers.value.forEach((srv) => sortTopics(srv));
setupListeners();
} catch (e) { } catch (e) {
console.error('初始化失败:', e) console.error('初始化失败:', e);
} finally { } finally {
loading.value = false loading.value = false;
} }
} }
// ==================== 服务器管理 ==================== // ==================== 服务器管理 ====================
/**
* 设置当前活动服务器并刷新相关数据
* @param {string|null} id - 服务器 id
*/
function setActiveServer(id) { function setActiveServer(id) {
activeServerId.value = id
activeServerId.value = id;
// 切换时刷新消息 // 切换时刷新消息
refreshMessages() refreshMessages();
refreshPublishHistory() refreshPublishHistory();
} }
/**
* 添加服务器
* @param {ServerConfig} config - 服务器配置
* @returns {Promise<MqttServer>} 添加后的服务器对象
*/
async function addServer(config) { 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) { 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) { async function updateServer(id, config) {
const result = await window.api.updateServer(id, config)
let result = await window.api.updateServer(id, config);
if (result) { if (result) {
const idx = servers.value.findIndex((s) => s.id === id) let idx = servers.value.findIndex((s) => s.id === id);
if (idx !== -1) { 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) { 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) { 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) { 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) { 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) { if (result) {
const srv = servers.value.find((s) => s.id === serverId) let srv = servers.value.find((s) => s.id === serverId);
if (srv) { 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) { 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) { 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) { 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) { 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) { 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) { 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) { if (result && serverId === activeServerId.value) {
refreshPublishHistory() refreshPublishHistory();
} }
return result
return result;
} }
// ==================== 消息管理 ==================== // ==================== 消息管理 ====================
/**
* 刷新当前活动服务器的消息列表
* @returns {Promise<void>}
*/
async function refreshMessages() { async function refreshMessages() {
if (!activeServerId.value) return if (activeServerId.value) {
messages.value = await window.api.getMessages(activeServerId.value) messages.value = await window.api.getMessages(activeServerId.value);
}
} }
/**
* 刷新当前活动服务器的发布历史
* @returns {Promise<void>}
*/
async function refreshPublishHistory() { async function refreshPublishHistory() {
if (!activeServerId.value) return if (activeServerId.value) {
publishHistory.value = await window.api.getPublishHistory(activeServerId.value) publishHistory.value = await window.api.getPublishHistory(activeServerId.value);
}
} }
/**
* 清空当前活动服务器的消息列表
* @returns {Promise<void>}
*/
async function clearMessages() { async function clearMessages() {
if (!activeServerId.value) return if (activeServerId.value) {
await window.api.clearMessages(activeServerId.value) await window.api.clearMessages(activeServerId.value);
messages.value = [] messages.value = [];
}
} }
/**
* 清空当前活动服务器的发布历史
* @returns {Promise<void>}
*/
async function clearPublishHistory() { async function clearPublishHistory() {
if (!activeServerId.value) return if (activeServerId.value) {
await window.api.clearPublishHistory(activeServerId.value) await window.api.clearPublishHistory(activeServerId.value);
publishHistory.value = [] publishHistory.value = [];
}
} }
return { return {
@@ -221,6 +569,8 @@ export function useMqttStore() {
addServer, addServer,
updateServer, updateServer,
deleteServer, deleteServer,
exportServers,
importServers,
connectServer, connectServer,
disconnectServer, disconnectServer,
addTopic, addTopic,
@@ -233,6 +583,7 @@ export function useMqttStore() {
refreshMessages, refreshMessages,
refreshPublishHistory, refreshPublishHistory,
clearMessages, clearMessages,
clearPublishHistory clearPublishHistory,
} };
} }
+135 -105
View File
@@ -1,60 +1,78 @@
<script setup> <script setup>
import { useMqttStore } from '../stores/mqtt.js' import { useMqttStore } from '../stores/mqtt.js';
import { import { NGrid, NGridItem, NCard, NStatistic, NTag, NDescriptions, NDescriptionsItem, NSpace, NButton, NList, NListItem, useMessage } from 'naive-ui';
NGrid,
NGridItem,
NCard,
NStatistic,
NTag,
NDescriptions,
NDescriptionsItem,
NSpace,
NButton,
NList,
NListItem
} from 'naive-ui'
import { useMessage } from 'naive-ui'
const store = useMqttStore() // API
const msg = useMessage() 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) { function handleAction(srv, action) {
if (srv.id === store.activeServerId.value) { if (srv.id === store.activeServerId.value) {
emit('switch-tab', action) emit('switch-tab', action);
} else { } else {
store.setActiveServer(srv.id) store.setActiveServer(srv.id);
emit('switch-tab', action) emit('switch-tab', action);
} }
} }
/**
* 切换当前服务器连接状态
* @param {import('@types/jsdoc').MqttServer} srv - 服务器对象
*/
async function handleToggleConnection(srv) { async function handleToggleConnection(srv) {
if (srv.status === 'connected') { if (srv.status === 'connected') {
await store.disconnectServer(srv.id) await store.disconnectServer(srv.id);
msg.info('已断开连接: ' + srv.name) msg.info('已断开连接: ' + srv.name);
} else if (srv.status === 'disconnected' || srv.status === 'error') { } 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> </script>
<template> <template>
<div class="dashboard"> <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-grid-item>
<n-card size="small" :bordered="true"> <n-card
<n-statistic label="服务器总数" :value="store.servers.value.length" /> size="small"
:bordered="true"
>
<n-statistic
label="服务器总数"
:value="store.servers.value.length"
/>
</n-card> </n-card>
</n-grid-item> </n-grid-item>
<n-grid-item> <n-grid-item>
<n-card size="small" :bordered="true"> <n-card
<n-statistic label="已连接" :value="store.connectedServers.value.length" /> size="small"
:bordered="true"
>
<n-statistic
label="已连接"
:value="store.connectedServers.value.length"
/>
</n-card> </n-card>
</n-grid-item> </n-grid-item>
<n-grid-item> <n-grid-item>
<n-card size="small" :bordered="true"> <n-card
size="small"
:bordered="true"
>
<n-statistic <n-statistic
label="已订阅主题" label="已订阅主题"
:value="`${store.subscribedTopics.value}/${store.totalTopics.value}`" :value="`${store.subscribedTopics.value}/${store.totalTopics.value}`"
@@ -62,8 +80,14 @@ const emit = defineEmits(['switch-tab', 'edit-server'])
</n-card> </n-card>
</n-grid-item> </n-grid-item>
<n-grid-item> <n-grid-item>
<n-card size="small" :bordered="true"> <n-card
<n-statistic label="消息总数" :value="store.messages.value.length" /> size="small"
:bordered="true"
>
<n-statistic
label="消息总数"
:value="store.messages.value.length"
/>
</n-card> </n-card>
</n-grid-item> </n-grid-item>
</n-grid> </n-grid>
@@ -77,29 +101,18 @@ const emit = defineEmits(['switch-tab', 'edit-server'])
> >
<template #header-extra> <template #header-extra>
<n-tag <n-tag
:type=" :type="store.activeServer.value.status === 'connected' ? 'success' : store.activeServer.value.status === 'connecting' ? 'warning' : 'error'"
store.activeServer.value.status === 'connected'
? 'success'
: store.activeServer.value.status === 'connecting'
? 'warning'
: 'error'
"
size="small" 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> </template>
<n-descriptions :column="2" size="small" bordered> <n-descriptions
class="server-descriptions"
:column="2"
size="small"
bordered
>
<n-descriptions-item label="地址"> <n-descriptions-item label="地址">
{{ store.activeServer.value.protocol }}://{{ store.activeServer.value.host }}:{{ {{ store.activeServer.value.protocol }}://{{ store.activeServer.value.host }}:{{ store.activeServer.value.port }}
store.activeServer.value.port
}}
</n-descriptions-item> </n-descriptions-item>
<n-descriptions-item label="客户端 ID"> <n-descriptions-item label="客户端 ID">
{{ store.activeServer.value.clientId }} {{ store.activeServer.value.clientId }}
@@ -116,52 +129,50 @@ const emit = defineEmits(['switch-tab', 'edit-server'])
size="small" size="small"
:type="store.activeServer.value.status === 'connected' ? 'error' : 'success'" :type="store.activeServer.value.status === 'connected' ? 'error' : 'success'"
@click="handleToggleConnection(store.activeServer.value)" @click="handleToggleConnection(store.activeServer.value)"
> >{{ store.activeServer.value.status === 'connected' ? '断开连接' : '连接服务器' }}</n-button>
{{ store.activeServer.value.status === 'connected' ? '断开连接' : '连接服务器' }} <n-button
</n-button> size="small"
<n-button size="small" @click="$emit('edit-server', store.activeServer.value)" @click="$emit('edit-server', store.activeServer.value)"
>编辑配置</n-button >编辑配置</n-button>
> <n-button
<n-button size="small" @click="handleAction(store.activeServer.value, 'subscribe')" size="small"
>管理订阅</n-button @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, 'publish')"
<n-button size="small" @click="handleAction(store.activeServer.value, 'messages')" >发布消息</n-button>
>查看消息</n-button <n-button
> size="small"
@click="handleAction(store.activeServer.value, 'messages')"
>查看消息</n-button>
</n-space> </n-space>
</n-card> </n-card>
<!-- 所有服务器状态 --> <!-- 所有服务器状态 -->
<n-card title="所有服务器状态" size="small" style="margin-top: 12px"> <n-card
title="所有服务器状态"
size="small"
style="margin-top: 12px"
>
<n-list> <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> <template #prefix>
<span class="status-dot" :class="srv.status" /> <span
class="status-dot"
:class="srv.status"
/>
</template> </template>
<n-space align="center"> <n-space align="center">
<span style="font-weight: 500">{{ srv.name }}</span> <span style="font-weight: 500">{{ srv.name }}</span>
<n-tag <n-tag
:bordered="false" :bordered="false"
size="small" size="small"
:type=" :type="srv.status === 'connected' ? 'success' : srv.status === 'connecting' ? 'warning' : 'default'"
srv.status === 'connected' >{{ srv.status === 'connected' ? '已连接' : srv.status === 'connecting' ? '连接中...' : '未连接' }}</n-tag>
? 'success'
: srv.status === 'connecting'
? 'warning'
: 'default'
"
>
{{
srv.status === 'connected'
? '已连接'
: srv.status === 'connecting'
? '连接中...'
: '未连接'
}}
</n-tag>
</n-space> </n-space>
<template #suffix> <template #suffix>
<n-button <n-button
@@ -169,42 +180,60 @@ const emit = defineEmits(['switch-tab', 'edit-server'])
:type="srv.status === 'connected' ? 'error' : 'success'" :type="srv.status === 'connected' ? 'error' : 'success'"
:disabled="srv.status === 'connecting'" :disabled="srv.status === 'connecting'"
@click="handleToggleConnection(srv)" @click="handleToggleConnection(srv)"
> >{{ srv.status === 'connected' ? '断开' : srv.status === 'connecting' ? '...' : '连接' }}</n-button>
{{
srv.status === 'connected' ? '断开' : srv.status === 'connecting' ? '...' : '连接'
}}
</n-button>
</template> </template>
</n-list-item> </n-list-item>
</n-list> </n-list>
</n-card> </n-card>
</div> </div>
</template> </template>
<style scoped> <style scoped lang="less">
.dashboard { .dashboard {
max-width: 900px; 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 { .status-dot {
display: inline-block; display: inline-block;
width: 8px; width: 8px;
height: 8px; height: 8px;
border-radius: 50%; border-radius: 50%;
}
.status-dot.connected { &.connected {
background: #18a058; background: var(--frost-success-color);
} }
.status-dot.connecting {
background: #f0a020; &.connecting {
background: var(--frost-warning-color);
animation: pulse 1s infinite; animation: pulse 1s infinite;
} }
.status-dot.disconnected {
background: #999; &.disconnected {
} background: var(--frost-color-text-tertiary);
.status-dot.error { }
background: #d03050;
&.error {
background: var(--frost-error-color);
}
} }
@keyframes pulse { @keyframes pulse {
@@ -212,6 +241,7 @@ const emit = defineEmits(['switch-tab', 'edit-server'])
100% { 100% {
opacity: 1; opacity: 1;
} }
50% { 50% {
opacity: 0.3; opacity: 0.3;
} }
+189 -57
View File
@@ -1,108 +1,229 @@
<script setup> <script setup>
import { ref, computed } from 'vue' import { ref, computed } from 'vue';
import { useMqttStore } from '../stores/mqtt.js' import { useMqttStore } from '../stores/mqtt.js';
import { NCard, NSpace, NInput, NButton, NEmpty } from 'naive-ui' 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() * @param {string} topic - 主题名称
return store.messages.value.filter((m) => m.topic.toLowerCase().includes(f)) */
}) async function copyTopic(topic) {
try {
async function handleClear() { await navigator.clipboard.writeText(topic);
await store.clearMessages() 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) { function formatPayload(payload) {
try { try {
return JSON.stringify(JSON.parse(payload), null, 2) return JSON.stringify(JSON.parse(payload), null, 2);
} catch { } catch {
return payload return payload;
} }
} }
</script> </script>
<template> <template>
<div v-if="store.activeServer.value" class="messages-view">
<!-- 内容区域 -->
<div
v-if="store.activeServer.value"
class="messages-view"
>
<n-card <n-card
size="small" size="small"
:content-style="{ :content-style="{
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
minHeight: 0, minHeight: 0,
overflow: 'hidden' overflow: 'hidden',
}" }"
> >
<template #header> <template #header>
<n-space align="center" justify="space-between" style="width: 100%"> <div
<div> style="display: flex; flex-direction: column; gap: 8px; width: 100%"
<span style="font-weight: 700">消息监控</span>
<span style="font-size: 12px; opacity: 0.55; margin-left: 8px"
>实时查看接收和发送的 MQTT 消息</span
> >
<div>
<span>消息监控</span>
<span
style="font-size: 12px; opacity: 0.55; margin-left: 8px"
>实时查看接收和发送的 MQTT 消息</span>
</div> </div>
<n-space> <div
<n-input style="display: flex; align-items: center; gap: 8px; width: 100%"
>
<n-select
v-model:value="filter" v-model:value="filter"
size="small" size="small"
placeholder="过滤主题..." placeholder="过滤主题..."
style="width: 180px" style="flex: 1 1 auto; max-width: 480px; min-width: 0"
multiple
filterable
clearable clearable
max-tag-count="responsive"
:options="topicOptions"
/> />
<n-button size="small" @click="handleClear">清空</n-button> <n-button
</n-space> size="small"
</n-space> @click="handleClear"
>清空消息</n-button>
</div>
</div>
</template> </template>
<n-empty v-if="filteredMessages.length === 0" description="等待消息..."> <n-empty
v-if="filteredMessages.length === 0"
description="等待消息..."
>
<template #icon> <template #icon>
<span style="font-size: 32px; opacity: 0.4">&#9776;</span> <span style="font-size: 32px; opacity: 0.4">&#9776;</span>
</template> </template>
</n-empty> </n-empty>
<div v-else ref="logRef" class="msg-log"> <div
<div v-for="m in filteredMessages" :key="m.id" class="msg-entry"> v-else
ref="logRef"
class="msg-log"
>
<div
v-for="m in filteredMessages"
:key="m.id"
class="msg-entry"
>
<div class="msg-header"> <div class="msg-header">
<span class="msg-time">{{ m.time }}</span> <span class="msg-time">{{ m.time }}</span>
<span class="msg-dir" :class="m.direction">{{ <span
m.direction === 'pub' ? 'PUB' : 'SUB' class="msg-dir"
}}</span> :class="m.direction"
<span class="msg-topic" :title="m.topic">{{ m.topic }}</span> >{{ m.direction === 'pub' ? 'PUB' : 'SUB' }}</span>
<span class="msg-meta">QoS{{ m.qos }}{{ m.retain ? ' R' : '' }}</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> </div>
<pre class="msg-payload">{{ formatPayload(m.payload) }}</pre> <pre
class="msg-payload"
>{{ formatPayload(m.payload) }}</pre>
</div> </div>
</div> </div>
</n-card> </n-card>
</div> </div>
<n-empty v-else description="请先在左侧选择一个 MQTT 服务器" />
<!-- 空状态 -->
<n-empty
v-else
description="请先在左侧选择一个 MQTT 服务器"
/>
</template> </template>
<style scoped> <style scoped lang="less">
.messages-view { .messages-view {
width: 100%; width: 100%;
height: 100%; height: 100%;
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
}
.messages-view :deep(.n-card) { :deep(.n-card) {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
min-height: 0; min-height: 0;
} }
.messages-view :deep(.n-card__content) { :deep(.n-card__content) {
flex: 1 1 auto; flex: 1 1 auto;
min-height: 0; min-height: 0;
overflow: hidden; overflow: hidden;
}
} }
.msg-log { .msg-log {
background: #1e1e2e; background: var(--frost-color-bg-light);
border-radius: 6px; border-radius: 6px;
padding: 12px; padding: 12px;
height: 100%; height: 100%;
@@ -113,12 +234,12 @@ function formatPayload(payload) {
font-family: monospace; font-family: monospace;
font-size: 12px; font-size: 12px;
line-height: 1.7; line-height: 1.7;
color: #cdd6f4; color: #333;
} }
.msg-entry { .msg-entry {
padding: 8px 0; padding: 8px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.08); border-bottom: 1px solid var(--frost-shadow-black-6);
} }
.msg-header { .msg-header {
@@ -129,40 +250,51 @@ function formatPayload(payload) {
} }
.msg-time { .msg-time {
color: #6c7086; color: var(--frost-color-text-tertiary);
flex-shrink: 0; flex-shrink: 0;
} }
.msg-dir { .msg-dir {
flex-shrink: 0; flex-shrink: 0;
font-weight: 700; font-weight: 700;
width: 28px; 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 { .msg-topic {
color: #f9e2af; color: var(--frost-color-topic);
min-width: 0; min-width: 0;
flex: 1; flex: 1;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
cursor: pointer;
&:hover {
text-decoration: underline;
}
} }
.msg-payload { .msg-payload {
margin: 6px 0 0; margin: 6px 0 0;
padding: 8px 10px; padding: 8px 10px;
border-radius: 4px; border-radius: 4px;
background: rgba(255, 255, 255, 0.05); background: var(--frost-shadow-black-4);
color: #cdd6f4; color: #333;
font: inherit; font: inherit;
white-space: pre-wrap; white-space: pre-wrap;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.msg-meta { .msg-meta {
color: #6c7086; color: var(--frost-color-text-tertiary);
font-size: 11px; font-size: 11px;
flex-shrink: 0; flex-shrink: 0;
} }
+251 -121
View File
@@ -1,185 +1,305 @@
<script setup> <script setup>
import { ref, watch } from 'vue' import { ref, watch } from 'vue';
import { useMqttStore } from '../stores/mqtt.js' import { useMqttStore } from '../stores/mqtt.js';
import { import { NCard, NSpace, NFormItem, NInput, NSelect, NSwitch, NButton, NEmpty, NTabs, NTabPane, useMessage } from 'naive-ui';
NCard,
NSpace,
NFormItem,
NInput,
NSelect,
NSwitch,
NButton,
NEmpty,
NTabs,
NTabPane,
useMessage
} from 'naive-ui'
const store = useMqttStore() // API
const msg = useMessage() const store = useMqttStore();
const msg = useMessage();
const topic = ref('') //
const payload = ref('') const topic = ref('');
const qos = ref(1) const payload = ref('');
const retain = ref(false) const qos = ref(1);
const activePublishTab = ref('publish') const retain = ref(false);
const formStorageKey = 'mqtt-client.publish-form'
const publishTabStorageKey = 'mqtt-client.publish-active-tab'
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 { try {
const savedForm = JSON.parse(localStorage.getItem(formStorageKey) || '{}')
if (typeof savedForm.topic === 'string') topic.value = savedForm.topic let savedForm = JSON.parse(localStorage.getItem(formStorageKey) || '{}');
if (typeof savedForm.payload === 'string') payload.value = savedForm.payload
if ([0, 1, 2].includes(savedForm.qos)) qos.value = savedForm.qos if (typeof savedForm.topic === 'string') {
if (typeof savedForm.retain === 'boolean') retain.value = savedForm.retain 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 { } catch {
localStorage.removeItem(formStorageKey) localStorage.removeItem(formStorageKey);
} }
// localStorage
try { 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 { } catch {
localStorage.removeItem(publishTabStorageKey) localStorage.removeItem(publishTabStorageKey);
} }
//
watch([topic, payload, qos, retain], () => { watch([topic, payload, qos, retain], () => {
if (!formReady) return
if (!formReady) {
return;
}
localStorage.setItem( localStorage.setItem(
formStorageKey, formStorageKey,
JSON.stringify({ JSON.stringify({
topic: topic.value, topic: topic.value,
payload: payload.value, payload: payload.value,
qos: qos.value, qos: qos.value,
retain: retain.value retain: retain.value,
}) })
) );
})
});
//
watch(activePublishTab, (tab) => { watch(activePublishTab, (tab) => {
localStorage.setItem(publishTabStorageKey, tab) localStorage.setItem(publishTabStorageKey, tab);
}) });
formReady = true formReady = true;
// QoS
const qosOptions = [ const qosOptions = [
{ label: 'QoS 0 - 最多一次', value: 0 }, { label: 'QoS 0 - 最多一次', value: 0 },
{ label: 'QoS 1 - 至少一次', value: 1 }, { label: 'QoS 1 - 至少一次', value: 1 },
{ label: 'QoS 2 - 恰好一次', value: 2 } { label: 'QoS 2 - 恰好一次', value: 2 },
] ];
/**
* 发布消息到当前服务器
*/
async function handlePublish() { async function handlePublish() {
const srv = store.activeServer.value
if (!srv) return let srv = store.activeServer.value;
if (!srv) {
return;
}
if (srv.status !== 'connected') { if (srv.status !== 'connected') {
msg.warning('请先连接服务器') msg.warning('请先连接服务器');
return return;
} }
if (!topic.value.trim()) { if (!topic.value.trim()) {
msg.warning('请输入目标主题') msg.warning('请输入目标主题');
return 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, qos: qos.value,
retain: retain.value retain: retain.value,
}) });
if (result) { if (result) {
msg.success('消息已发布到: ' + topic.value) msg.success('消息已发布到: ' + topic.value);
} }
} }
/**
* 格式化 Payload JSON
*/
function handleFormatJson() { function handleFormatJson() {
try { try {
const obj = JSON.parse(payload.value)
payload.value = JSON.stringify(obj, null, 2) let obj = JSON.parse(payload.value);
msg.info('JSON 已格式化')
payload.value = JSON.stringify(obj, null, 2);
msg.info('JSON 已格式化');
} catch { } catch {
msg.warning('无效的 JSON 格式') msg.warning('无效的 JSON 格式');
} }
} }
/**
* 清空发布历史
*/
async function handleClearHistory() { 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> </script>
<template> <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 <n-card
size="small" size="small"
:content-style="{ :content-style="{
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
minHeight: 0, minHeight: 0,
overflow: 'hidden' overflow: 'hidden',
}" }"
> >
<div class="publish-form"> <div class="publish-form">
<n-form-item class="topic-field" label="目标主题 (Topic)"> <n-form-item
<n-input v-model:value="topic" placeholder="例如: sensor/device01/temperature" /> class="topic-field"
label="目标主题 (Topic)"
>
<n-input
v-model:value="topic"
placeholder="例如: sensor/device01/temperature"
/>
</n-form-item> </n-form-item>
<n-space align="center"> <n-space align="center">
<n-form-item label="QoS"> <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>
<n-form-item label="保留消息"> <n-form-item label="保留消息">
<n-switch v-model:value="retain" /> <n-switch v-model:value="retain" />
</n-form-item> </n-form-item>
</n-space> </n-space>
<n-form-item class="payload-field" label="消息内容 (Payload)"> <n-form-item
class="payload-field"
label="消息内容 (Payload)"
>
<n-input <n-input
v-model:value="payload" v-model:value="payload"
type="textarea" type="textarea"
:resizable="false" :resizable="false"
:rows="1" :rows="1"
placeholder='{"temperature": 25.6, "humidity": 68.3}'
/> />
</n-form-item> </n-form-item>
<n-space> <n-space>
<n-button type="primary" @click="handlePublish">发布消息</n-button> <n-button
<n-button @click="handleFormatJson">格式化 JSON</n-button> type="primary"
@click="handlePublish"
>发布消息</n-button>
<n-button
@click="handleFormatJson"
>格式化 JSON</n-button>
</n-space> </n-space>
</div> </div>
</n-card> </n-card>
</n-tab-pane> </n-tab-pane>
<n-tab-pane name="history" tab="发布历史"> <n-tab-pane
name="history"
tab="发布历史"
>
<n-card <n-card
size="small" size="small"
:content-style="{ :content-style="{
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
minHeight: 0, minHeight: 0,
overflow: 'hidden' overflow: 'hidden',
}" }"
> >
<n-space style="margin-bottom: 12px"> <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-space>
<n-empty v-if="store.publishHistory.value.length === 0" description="暂无发布记录" /> <n-empty
<div v-else class="msg-log"> v-if="store.publishHistory.value.length === 0"
<div v-for="m in store.publishHistory.value" :key="m.id || m.time" class="msg-entry"> 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"> <div class="msg-header">
<span class="msg-time">{{ m.time }}</span> <span
<span class="msg-dir pub">PUB</span> class="msg-time"
<span class="msg-topic" :title="m.topic">{{ m.topic }}</span> >{{ m.time }}</span>
<span class="msg-meta">QoS{{ m.qos }}{{ m.retain ? ' R' : '' }}</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> </div>
<pre class="msg-payload">{{ m.payload }}</pre> <pre
class="msg-payload"
>{{ m.payload }}</pre>
</div> </div>
</div> </div>
</n-card> </n-card>
</n-tab-pane> </n-tab-pane>
</n-tabs> </n-tabs>
</div> </div>
<n-empty v-else description="请先在左侧选择一个 MQTT 服务器" />
<!-- 空状态 -->
<n-empty
v-else
description="请先在左侧选择一个 MQTT 服务器"
/>
</template> </template>
<style scoped> <style scoped lang="less">
.publish-view { .publish-view {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -187,61 +307,61 @@ async function handleClearHistory() {
height: 100%; height: 100%;
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
}
.publish-view :deep(.n-tabs) { :deep(.n-tabs) {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
min-height: 0; min-height: 0;
} }
.publish-view :deep(.n-tabs-nav) { :deep(.n-tabs-nav) {
flex-shrink: 0; flex-shrink: 0;
} }
.publish-view :deep(.n-tabs-pane-wrapper) { :deep(.n-tabs-pane-wrapper) {
flex: 1 1 auto; flex: 1 1 auto;
min-height: 0; min-height: 0;
overflow: hidden; overflow: hidden;
} }
.publish-view :deep(.n-tabs-tab-panels), :deep(.n-tabs-tab-panels),
.publish-view :deep(.n-tabs-pane-wrapper) { :deep(.n-tabs-pane-wrapper) {
height: 100%; height: 100%;
min-height: 0; min-height: 0;
} }
.publish-view :deep(.n-tab-pane) { :deep(.n-tab-pane) {
box-sizing: border-box; box-sizing: border-box;
height: 100%; height: 100%;
min-height: 0; min-height: 0;
} }
.publish-view :deep(.n-tab-pane > .n-card) { :deep(.n-tab-pane > .n-card) {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
min-height: 0; min-height: 0;
} }
.publish-view :deep(.n-card__content) { :deep(.n-card__content) {
flex: 1 1 auto; flex: 1 1 auto;
min-height: 0; min-height: 0;
overflow: hidden; overflow: hidden;
}
.publish-view :deep(.n-card__content > div) { > div {
height: 100%; height: 100%;
min-height: 0; min-height: 0;
}
}
} }
.topic-field { .topic-field {
width: 100%; width: 100%;
}
.topic-field :deep(.n-form-item-blank) { :deep(.n-form-item-blank) {
width: 100%; width: 100%;
}
} }
.publish-form { .publish-form {
@@ -249,34 +369,34 @@ async function handleClearHistory() {
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
min-height: 0; min-height: 0;
}
.publish-form .payload-field { .payload-field {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
}
.publish-form .payload-field :deep(.n-form-item-blank) { :deep(.n-form-item-blank) {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
} }
.publish-form .payload-field :deep(.n-input) { :deep(.n-input) {
height: 100%; height: 100%;
} }
.publish-form .payload-field :deep(.n-input__textarea-el), :deep(.n-input__textarea-el),
.publish-form .payload-field :deep(textarea) { :deep(textarea) {
resize: none !important; resize: none !important;
height: 100% !important; height: 100% !important;
min-height: 0 !important; min-height: 0 !important;
font-family: monospace; font-family: monospace;
}
}
} }
.msg-log { .msg-log {
background: #1e1e2e; background: var(--frost-color-bg-light);
border-radius: 6px; border-radius: 6px;
padding: 12px; padding: 12px;
height: 100%; height: 100%;
@@ -286,12 +406,12 @@ async function handleClearHistory() {
font-family: monospace; font-family: monospace;
font-size: 12px; font-size: 12px;
line-height: 1.7; line-height: 1.7;
color: #cdd6f4; color: #333;
} }
.msg-entry { .msg-entry {
padding: 8px 0; padding: 8px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.08); border-bottom: 1px solid var(--frost-shadow-black-6);
} }
.msg-header { .msg-header {
@@ -302,37 +422,47 @@ async function handleClearHistory() {
} }
.msg-time { .msg-time {
color: #6c7086; color: var(--frost-color-text-tertiary);
flex-shrink: 0; flex-shrink: 0;
} }
.msg-dir { .msg-dir {
flex-shrink: 0; flex-shrink: 0;
font-weight: 700; font-weight: 700;
width: 28px; width: 28px;
&.pub {
color: var(--frost-info-color);
}
} }
.msg-dir.pub {
color: #89b4fa;
}
.msg-topic { .msg-topic {
color: #f9e2af; color: var(--frost-color-topic);
min-width: 0; min-width: 0;
flex: 1; flex: 1;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
cursor: pointer;
&:hover {
text-decoration: underline;
}
} }
.msg-payload { .msg-payload {
margin: 6px 0 0; margin: 6px 0 0;
padding: 8px 10px; padding: 8px 10px;
border-radius: 4px; border-radius: 4px;
background: rgba(255, 255, 255, 0.05); background: var(--frost-shadow-black-4);
color: #cdd6f4; color: #333;
font: inherit; font: inherit;
white-space: pre-wrap; white-space: pre-wrap;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.msg-meta { .msg-meta {
color: #6c7086; color: var(--frost-color-text-tertiary);
font-size: 11px; font-size: 11px;
flex-shrink: 0; flex-shrink: 0;
} }
+843
View File
@@ -0,0 +1,843 @@
<script setup>
import { ref, computed, watch, onMounted, onUnmounted } from 'vue';
import { useMqttStore } from '../stores/mqtt.js';
import { NCard, NSpace, NFormItem, NInput, NSelect, NSwitch, NButton, NEmpty, NModal, NForm, NGrid, NGridItem, NCheckbox, useMessage, useDialog } from 'naive-ui';
// API
const store = useMqttStore();
const msg = useMessage();
const dialog = useDialog();
// localStorage key
const storageKey = 'mqtt-client.quick-send-list';
/**
* @typedef {Object} QuickSendItem
* @property {string} id
* @property {string} topic
* @property {string} payload
* @property {0|1|2} qos
* @property {boolean} retain
*/
//
/** @type {import('vue').Ref<QuickSendItem[]>} */
const quickList = ref([]);
//
const showModal = ref(false);
const isEdit = ref(false);
//
const formId = ref('');
const formTopic = ref('');
const formPayload = ref('');
const formQos = ref(1);
const formRetain = ref(false);
// localStorage
try {
let saved = JSON.parse(localStorage.getItem(storageKey) || '[]');
if (Array.isArray(saved)) {
quickList.value = saved.filter((item) => {
return item && typeof item.id === 'string' && typeof item.topic === 'string';
});
}
} catch {
localStorage.removeItem(storageKey);
}
//
watch(
quickList,
() => {
localStorage.setItem(storageKey, JSON.stringify(quickList.value));
},
{ deep: true }
);
// QoS
const qosOptions = [
{ label: 'QoS 0 - 最多一次', value: 0 },
{ label: 'QoS 1 - 至少一次', value: 1 },
{ label: 'QoS 2 - 恰好一次', value: 2 },
];
//
const hasActiveServer = computed(() => Boolean(store.activeServer.value));
//
/** @type {import('vue').Ref<number>} */
const dragIndex = ref(-1);
/** @type {import('vue').Ref<QuickSendItem | null>} */
const dragItem = ref(null);
//
/** @type {import('vue').Ref<HTMLElement | null>} */
const containerRef = ref(null);
const columnCount = ref(1);
/** @type {ResizeObserver | null} */
let resizeObserver = null;
//
/** @type {import('vue').Ref<string[]>} */
const selectedIds = ref([]);
/** @type {import('vue').Ref<HTMLInputElement | null>} */
const fileInput = ref(null);
//
const isAllSelected = computed(() => {
return quickList.value.length > 0 && selectedIds.value.length === quickList.value.length;
});
//
const hasSelected = computed(() => selectedIds.value.length > 0);
/**
* 生成唯一 id
* @returns {string}
*/
function generateId() {
return Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
}
/**
* 打开新增弹窗
*/
function handleAdd() {
isEdit.value = false;
formId.value = '';
formTopic.value = '';
formPayload.value = '';
formQos.value = 1;
formRetain.value = false;
showModal.value = true;
}
/**
* 打开编辑弹窗
* @param {QuickSendItem} item
*/
function handleEdit(item) {
isEdit.value = true;
formId.value = item.id;
formTopic.value = item.topic;
formPayload.value = item.payload;
formQos.value = item.qos;
formRetain.value = item.retain;
showModal.value = true;
}
/**
* 保存快速发送项
*/
function handleSave() {
let topic = formTopic.value.trim();
if (!topic) {
msg.warning('请输入目标主题');
return;
}
let payload = formPayload.value;
let qos = formQos.value;
let retain = formRetain.value;
if (isEdit.value) {
let idx = quickList.value.findIndex((item) => item.id === formId.value);
if (idx !== -1) {
quickList.value[idx] = {
...quickList.value[idx],
topic,
payload,
qos,
retain,
};
}
} else {
quickList.value.push({
id: generateId(),
topic,
payload,
qos,
retain,
});
}
showModal.value = false;
}
/**
* 删除快速发送项
* @param {QuickSendItem} item
*/
function handleDelete(item) {
dialog.warning({
title: '确认删除',
content: `确定要删除主题 "${item.topic}" 的快速消息吗?`,
positiveText: '删除',
negativeText: '取消',
onPositiveClick: () => {
quickList.value = quickList.value.filter((i) => i.id !== item.id);
},
});
}
/**
* 开始拖拽
* @param {DragEvent} e
* @param {QuickSendItem} item
* @param {number} index
*/
function handleDragStart(e, item, index) {
dragIndex.value = index;
dragItem.value = item;
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', item.id);
}
}
/**
* 拖拽经过目标项
* @param {DragEvent} e
* @param {number} index
*/
function handleDragOver(e, index) {
e.preventDefault();
if (e.dataTransfer) {
e.dataTransfer.dropEffect = 'move';
}
if (dragIndex.value === -1 || dragIndex.value === index) {
return;
}
}
/**
* 放置拖拽项
* @param {DragEvent} e
* @param {number} index
*/
function handleDrop(e, index) {
e.preventDefault();
if (dragIndex.value === -1 || dragIndex.value === index) {
return;
}
let item = quickList.value[dragIndex.value];
quickList.value.splice(dragIndex.value, 1);
quickList.value.splice(index, 0, item);
}
/**
* 拖拽结束
*/
function handleDragEnd() {
dragIndex.value = -1;
dragItem.value = null;
}
/**
* 发送消息到当前服务器
* @param {QuickSendItem} item
*/
async function handleSend(item) {
let srv = store.activeServer.value;
if (!srv) {
msg.warning('请先在左侧选择一个 MQTT 服务器');
return;
}
if (srv.status !== 'connected') {
msg.warning('请先连接服务器');
return;
}
let result = await store.publishMessage(srv.id, item.topic, item.payload, {
qos: item.qos,
retain: item.retain,
});
if (result) {
msg.success('消息已发布到: ' + item.topic);
}
}
/**
* 复制主题到剪贴板
* @param {string} topic
*/
async function copyTopic(topic) {
try {
await navigator.clipboard.writeText(topic);
msg.success('主题已复制');
} catch {
msg.error('复制失败');
}
}
/**
* 格式化消息内容为 JSON
*/
function handleFormatJson() {
try {
let obj = JSON.parse(formPayload.value);
formPayload.value = JSON.stringify(obj, null, 2);
msg.info('JSON 已格式化');
} catch {
msg.warning('无效的 JSON 格式');
}
}
/**
* 选中或取消选中单个快速消息
* @param {string} id
* @param {boolean} checked
*/
function handleSelectItem(id, checked) {
if (checked) {
selectedIds.value.push(id);
} else {
selectedIds.value = selectedIds.value.filter((i) => i !== id);
}
}
/**
* 切换全选状态
*/
function handleToggleSelectAll() {
if (isAllSelected.value) {
selectedIds.value = [];
} else {
selectedIds.value = quickList.value.map((item) => item.id);
}
}
/**
* 批量删除选中的快速消息
*/
function handleBatchDelete() {
if (!hasSelected.value) {
return;
}
dialog.warning({
title: '确认批量删除',
content: `确定要删除选中的 ${selectedIds.value.length} 条快速消息吗?`,
positiveText: '删除',
negativeText: '取消',
onPositiveClick: () => {
quickList.value = quickList.value.filter((item) => !selectedIds.value.includes(item.id));
selectedIds.value = [];
},
});
}
/**
* 导出快速消息列表为 JSON 文件
*/
function handleExport() {
let data = quickList.value;
let blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
let url = URL.createObjectURL(blob);
let a = document.createElement('a');
a.href = url;
a.download = `frost-mqtt-quick-send-${new Date().toISOString().slice(0, 10)}.json`;
a.click();
URL.revokeObjectURL(url);
msg.success('快速消息已导出');
}
/**
* 触发文件导入点击
*/
function handleImportClick() {
fileInput.value?.click();
}
/**
* 处理导入的 JSON 文件
* @param {Event} event
*/
function handleImportFile(event) {
let file = event.target.files?.[0];
if (!file) {
return;
}
let reader = new FileReader();
reader.onload = (e) => {
try {
let data = JSON.parse(String(e.target?.result || ''));
if (!Array.isArray(data)) {
msg.error('导入失败: 文件内容不是数组');
return;
}
let validItems = data.filter((item) => {
return item && typeof item.id === 'string' && typeof item.topic === 'string';
});
if (validItems.length === 0) {
msg.warning('未找到有效的快速消息');
return;
}
dialog.create({
title: '选择导入方式',
content: `检测到 ${validItems.length} 条快速消息,请选择导入方式:`,
positiveText: '追加',
negativeText: '覆盖',
class: 'quick-send-dialog',
onPositiveClick: () => {
let existingIds = new Set(quickList.value.map((item) => item.id));
let itemsToAdd = validItems.map((item) => {
if (existingIds.has(item.id)) {
return { ...item, id: generateId() };
}
return item;
});
quickList.value.push(...itemsToAdd);
selectedIds.value = [];
msg.success(`已追加 ${itemsToAdd.length} 条快速消息`);
},
onNegativeClick: () => {
quickList.value = validItems;
selectedIds.value = [];
msg.success(`已覆盖为导入的 ${validItems.length} 条快速消息`);
},
});
} catch (err) {
msg.error('导入失败: ' + (err.message || '未知错误'));
}
};
reader.onerror = () => {
msg.error('读取文件失败');
};
reader.readAsText(file);
event.target.value = '';
}
function updateColumnCount(width) {
if (width >= 1520) {
columnCount.value = 3;
} else if (width >= 720) {
columnCount.value = 2;
} else {
columnCount.value = 1;
}
}
onMounted(() => {
resizeObserver = new ResizeObserver((entries) => {
for (let entry of entries) {
updateColumnCount(entry.contentRect.width);
}
});
if (containerRef.value) {
resizeObserver.observe(containerRef.value);
updateColumnCount(containerRef.value.clientWidth);
}
});
onUnmounted(() => {
if (resizeObserver) {
resizeObserver.disconnect();
resizeObserver = null;
}
});
</script>
<template>
<div
ref="containerRef"
class="quick-send-view"
>
<n-card
size="small"
:content-style="{
display: 'flex',
flexDirection: 'column',
minHeight: 0,
overflow: 'hidden',
}"
>
<n-space
style="margin-bottom: 12px"
align="center"
>
<n-button
type="primary"
@click="handleAdd"
>新增快速消息</n-button>
<n-button
size="small"
:disabled="quickList.length === 0"
@click="handleToggleSelectAll"
>{{ isAllSelected ? '取消全选' : '全选' }}</n-button>
<n-button
size="small"
type="error"
ghost
:disabled="!hasSelected"
@click="handleBatchDelete"
>批量删除</n-button>
<n-button
size="small"
@click="handleImportClick"
>导入</n-button>
<n-button
size="small"
@click="handleExport"
>导出</n-button>
</n-space>
<n-empty
v-if="quickList.length === 0"
description="暂无快速发送消息,点击上方按钮添加"
/>
<div
v-else
class="quick-list"
:class="`columns-${columnCount}`"
>
<div
v-for="(item, index) in quickList"
:key="item.id"
class="quick-item"
:class="{ dragging: dragIndex === index }"
draggable="true"
@dragstart="handleDragStart($event, item, index)"
@dragover.prevent="handleDragOver($event, index)"
@drop.prevent="handleDrop($event, index)"
@dragend="handleDragEnd"
>
<div class="quick-header">
<n-checkbox
class="quick-checkbox"
:checked="selectedIds.includes(item.id)"
@update:checked="(checked) => handleSelectItem(item.id, checked)"
@click.stop
/>
<div class="quick-topic-wrap">
<span class="quick-dir pub">PUB</span>
<span
class="quick-topic"
:title="item.topic"
@click="copyTopic(item.topic)"
>{{ item.topic }}</span>
<span class="quick-meta">QoS{{ item.qos }}{{ item.retain ? ' R' : '' }}</span>
</div>
<n-space>
<n-button
size="tiny"
:disabled="!hasActiveServer"
type="primary"
@click="handleSend(item)"
>发送</n-button>
<n-button
size="tiny"
@click="handleEdit(item)"
>编辑</n-button>
<n-button
size="tiny"
type="error"
ghost
@click="handleDelete(item)"
>删除</n-button>
</n-space>
</div>
<pre
v-if="item.payload"
class="quick-payload"
>{{ item.payload }}</pre>
</div>
</div>
<input
ref="fileInput"
type="file"
accept=".json,application/json"
style="display: none"
@change="handleImportFile"
>
</n-card>
<n-modal
v-model:show="showModal"
class="quick-send-modal"
:title="isEdit ? '编辑快速消息' : '新增快速消息'"
preset="card"
style="width: 520px; max-width: 90vw"
:bordered="false"
:segmented="{ content: true }"
>
<n-form
label-placement="top"
:show-feedback="false"
>
<n-form-item label="目标主题">
<n-input
v-model:value="formTopic"
placeholder="例如: sensor/device01/temperature"
/>
</n-form-item>
<n-grid
cols="2"
x-gap="12"
>
<n-grid-item>
<n-form-item label="QoS">
<n-select
v-model:value="formQos"
:options="qosOptions"
/>
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="保留消息">
<n-switch v-model:value="formRetain" />
</n-form-item>
</n-grid-item>
</n-grid>
<n-form-item label="消息内容">
<div class="payload-field">
<n-input
v-model:value="formPayload"
type="textarea"
:resizable="false"
:rows="6"
placeholder="输入要发送的消息内容"
/>
<n-button
size="small"
@click="handleFormatJson"
>格式化 JSON</n-button>
</div>
</n-form-item>
</n-form>
<template #footer>
<n-space justify="end">
<n-button @click="showModal = false">取消</n-button>
<n-button
type="primary"
@click="handleSave"
>保存</n-button>
</n-space>
</template>
</n-modal>
</div>
</template>
<style scoped lang="less">
.quick-send-view {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
:deep(.n-card) {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
:deep(.n-card__content) {
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
}
}
.quick-list {
flex: 1;
min-height: 0;
overflow-y: auto;
display: grid;
grid-template-columns: 1fr;
gap: 12px;
padding-right: 4px;
align-content: start;
&.columns-1 {
grid-template-columns: 1fr;
}
&.columns-2 {
grid-template-columns: repeat(2, 1fr);
}
&.columns-3 {
grid-template-columns: repeat(3, 1fr);
}
}
.payload-field {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
:deep(.n-input) {
width: 100%;
}
.n-button {
align-self: flex-end;
}
}
.quick-item {
border: 1px solid var(--frost-shadow-black-8);
border-radius: 6px;
padding: 12px;
background: var(--frost-color-bg-item);
cursor: grab;
transition: box-shadow 0.2s, opacity 0.2s;
&.dragging {
opacity: 0.6;
box-shadow: 0 4px 12px var(--frost-shadow-black-12);
cursor: grabbing;
}
}
.quick-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
min-width: 0;
}
.quick-checkbox {
flex-shrink: 0;
margin-top: 2px;
:deep(.n-checkbox-box) {
width: 16px;
height: 16px;
}
}
.quick-topic-wrap {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
flex: 1;
flex-wrap: wrap;
}
.quick-dir {
flex-shrink: 0;
font-weight: 700;
width: 28px;
font-size: 12px;
&.pub {
color: var(--frost-info-color);
}
}
.quick-topic {
color: var(--frost-color-topic);
min-width: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
font-family: monospace;
font-size: 13px;
&:hover {
text-decoration: underline;
}
}
.quick-meta {
color: var(--frost-color-text-tertiary);
font-size: 11px;
flex-shrink: 0;
font-family: monospace;
}
.quick-payload {
margin: 10px 0 0;
padding: 8px 10px;
border-radius: 4px;
background: var(--frost-shadow-black-4);
color: #333;
font: inherit;
white-space: pre-wrap;
overflow-wrap: anywhere;
font-family: monospace;
font-size: 12px;
line-height: 1.6;
max-height: 120px;
overflow-y: auto;
}
.quick-send-modal {
.n-form-item {
margin-bottom: 20px;
&__label {
margin-bottom: 8px;
}
&:last-child {
margin-bottom: 0;
}
}
.n-grid {
margin-bottom: 20px;
}
}
.quick-send-dialog {
:deep(.n-dialog__icon) {
color: var(--frost-primary-color);
}
}
</style>
+434 -131
View File
@@ -1,168 +1,289 @@
<script setup> <script setup>
import { ref, watch } from 'vue' import { ref, watch } from 'vue';
import { useMqttStore } from '../stores/mqtt.js' import { useMqttStore } from '../stores/mqtt.js';
import { import { NCard, NSpace, NFormItem, NInput, NSelect, NButton, NEmpty, NList, NListItem, NTag, useMessage } from 'naive-ui';
NCard,
NSpace,
NFormItem,
NInput,
NSelect,
NButton,
NEmpty,
NList,
NListItem,
NTag,
NEllipsis,
useMessage
} from 'naive-ui'
const store = useMqttStore() // API
const msg = useMessage() const store = useMqttStore();
const msg = useMessage();
const topicInput = ref('') // QoS
const qos = ref(2) const topicInput = ref('');
const editingTopicId = ref(null) const qos = ref(2);
const editingTopic = ref('') const comment = ref('');
const editingQos = ref(2)
//
const editingTopicId = ref(null);
const editingTopic = ref('');
const editingQos = ref(2);
const editingComment = ref('');
// QoS
const qosOptions = [ const qosOptions = [
{ label: 'QoS 0 - 最多一次', value: 0 }, { label: 'QoS 0 - 最多一次', value: 0 },
{ label: 'QoS 1 - 至少一次', value: 1 }, { label: 'QoS 1 - 至少一次', value: 1 },
{ label: 'QoS 2 - 恰好一次', value: 2 } { label: 'QoS 2 - 恰好一次', value: 2 },
] ];
const formStorageKey = 'mqtt-client.subscribe-form' // key
let formReady = false const formStorageKey = 'mqtt-client.subscribe-form';
let formReady = false;
// localStorage
try { try {
const savedForm = JSON.parse(localStorage.getItem(formStorageKey) || '{}')
if (typeof savedForm.topicInput === 'string') topicInput.value = savedForm.topicInput let savedForm = JSON.parse(localStorage.getItem(formStorageKey) || '{}');
if ([0, 1, 2].includes(savedForm.qos)) qos.value = savedForm.qos
if (typeof savedForm.topicInput === 'string') {
topicInput.value = savedForm.topicInput;
}
if ([0, 1, 2].includes(savedForm.qos)) {
qos.value = savedForm.qos;
}
} catch { } catch {
localStorage.removeItem(formStorageKey) localStorage.removeItem(formStorageKey);
} }
// localStorage
watch([topicInput, qos], () => { watch([topicInput, qos], () => {
if (!formReady) return
if (!formReady) {
return;
}
localStorage.setItem( localStorage.setItem(
formStorageKey, formStorageKey,
JSON.stringify({ topicInput: topicInput.value, qos: qos.value }) JSON.stringify({ topicInput: topicInput.value, qos: qos.value })
) );
})
formReady = true });
formReady = true;
/**
* 添加新主题
*/
async function handleAddTopic() { async function handleAddTopic() {
const topic = topicInput.value.trim()
let topic = topicInput.value.trim();
if (!topic) { if (!topic) {
msg.warning('请输入主题名称') msg.warning('请输入主题名称');
return 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)) { if (srv.topics && srv.topics.find((t) => t.topic === topic)) {
msg.warning('该主题已存在') msg.warning('该主题已存在');
return 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) { if (result) {
msg.success('主题已添加' + (srv.status === 'connected' ? '并已订阅' : '')) msg.success('主题已添加' + (srv.status === 'connected' ? '并已订阅' : ''));
topicInput.value = '' topicInput.value = '';
comment.value = '';
} }
} }
/**
* 移除主题
* @param {string} topic - 主题名称
*/
async function handleRemoveTopic(topic) { async function handleRemoveTopic(topic) {
const srv = store.activeServer.value
if (!srv) return let srv = store.activeServer.value;
await store.removeTopic(srv.id, topic)
msg.info('主题已移除') if (!srv) {
return;
}
await store.removeTopic(srv.id, topic);
msg.info('主题已移除');
} }
/**
* 切换单个主题订阅状态
* @param {string} topic - 主题名称
*/
async function handleToggleSub(topic) { async function handleToggleSub(topic) {
const srv = store.activeServer.value
if (!srv) return let srv = store.activeServer.value;
if (srv.status !== 'connected') {
msg.warning('请先连接服务器') if (!srv) {
return 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) { if (t.subscribed) {
await store.unsubscribeTopic(srv.id, topic) await store.unsubscribeTopic(srv.id, topic);
msg.info('已取消订阅: ' + topic) msg.info('已取消订阅: ' + topic);
} else { } else {
await store.subscribeTopic(srv.id, topic) await store.subscribeTopic(srv.id, topic);
msg.info('已订阅: ' + topic) msg.info('已订阅: ' + topic);
} }
} }
/**
* 批量订阅当前服务器下所有未订阅主题
*/
async function handleSubscribeAll() { async function handleSubscribeAll() {
const srv = store.activeServer.value
if (!srv) return let srv = store.activeServer.value;
if (!srv) {
return;
}
if (srv.status !== 'connected') { if (srv.status !== 'connected') {
msg.warning('请先连接服务器') msg.warning('请先连接服务器');
return return;
} }
await store.subscribeAllTopics(srv.id)
msg.success('已订阅全部主题') await store.subscribeAllTopics(srv.id);
msg.success('已订阅全部主题');
} }
/**
* 进入主题编辑状态
* @param {import('@types/jsdoc').MqttTopic} t - 主题对象
*/
function startEdit(t) { function startEdit(t) {
editingTopicId.value = t.id || t.topic editingTopicId.value = t.id || t.topic;
editingTopic.value = t.topic editingTopic.value = t.topic;
editingQos.value = t.qos ?? 2 editingQos.value = t.qos ?? 2;
editingComment.value = t.comment ?? '';
} }
/**
* 取消主题编辑
*/
function cancelEdit() { function cancelEdit() {
editingTopicId.value = null editingTopicId.value = null;
editingTopic.value = '' editingTopic.value = '';
editingQos.value = 2 editingQos.value = 2;
editingComment.value = '';
} }
/**
* 保存主题编辑
* @param {import('@types/jsdoc').MqttTopic} t - 主题对象
*/
async function handleEditSave(t) { async function handleEditSave(t) {
const srv = store.activeServer.value
if (!srv) return let srv = store.activeServer.value;
const topicName = editingTopic.value.trim()
if (!topicName) { if (!srv) {
msg.warning('请输入主题名称') return;
return
} }
let topicName = editingTopic.value.trim();
if (!topicName) {
msg.warning('请输入主题名称');
return;
}
if ( if (
srv.topics.find( srv.topics.find(
(item) => item.topic === topicName && (item.id || item.topic) !== editingTopicId.value (item) => item.topic === topicName && (item.id || item.topic) !== editingTopicId.value
) )
) { ) {
msg.warning('该主题已存在') msg.warning('该主题已存在');
return 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) { if (result) {
msg.success('主题已更新') msg.success('主题已更新');
cancelEdit() cancelEdit();
} else { } else {
msg.error('主题更新失败') msg.error('主题更新失败');
} }
} }
</script> </script>
<template> <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"> <div class="topic-form">
<n-form-item class="topic-field" label="主题 (Topic)"> <n-form-item
class="topic-field"
label="主题 (Topic)"
>
<n-input <n-input
v-model:value="topicInput" v-model:value="topicInput"
placeholder="例如: sensor/+/temperature" placeholder="例如: sensor/+/temperature"
@keydown.enter="handleAddTopic" @keydown.enter="handleAddTopic"
/> />
</n-form-item> </n-form-item>
<n-form-item class="qos-field" label="QoS"> <n-form-item
<n-select v-model:value="qos" :options="qosOptions" /> class="comment-field"
label="备注"
>
<n-input
v-model:value="comment"
placeholder="可选备注"
@keydown.enter="handleAddTopic"
/>
</n-form-item> </n-form-item>
<n-button class="topic-submit" type="primary" @click="handleAddTopic"> <n-form-item
+ 添加订阅 class="qos-field"
</n-button> 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> </div>
</n-card> </n-card>
@@ -175,78 +296,137 @@ async function handleEditSave(t) {
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
minHeight: 0, minHeight: 0,
overflow: 'hidden' overflow: 'hidden',
}" }"
> >
<template #header-extra> <template #header-extra>
<n-button size="small" @click="handleSubscribeAll">全部订阅</n-button> <n-button
size="small"
@click="handleSubscribeAll"
>全部订阅</n-button>
</template> </template>
<n-empty <n-empty
v-if="!store.activeServer.value.topics || store.activeServer.value.topics.length === 0" v-if="!store.activeServer.value.topics || store.activeServer.value.topics.length === 0"
description="暂无订阅主题,请在上方添加" description="暂无订阅主题,请在上方添加"
/> />
<div v-else class="topic-list-scroll"> <div
v-else
class="topic-list-scroll"
>
<n-list> <n-list>
<n-list-item <n-list-item
v-for="t in store.activeServer.value.topics" v-for="t in store.activeServer.value.topics"
:key="t.id || t.topic" :key="t.id || t.topic"
class="topic-item" class="topic-item"
> >
<template v-if="editingTopicId !== (t.id || t.topic)" #prefix> <template #prefix>
<n-tag :type="t.subscribed ? 'success' : 'default'" size="small"> <n-tag
QoS {{ t.qos }} class="topic-qos-tag"
</n-tag> :type="t.subscribed ? 'success' : 'default'"
size="small"
>QoS {{ t.qos }}</n-tag>
</template> </template>
<div v-if="editingTopicId === (t.id || t.topic)" class="topic-edit-row"> <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 <n-input
v-model:value="editingTopic" v-model:value="editingTopic"
class="topic-edit-topic"
size="small" size="small"
placeholder="主题名称" placeholder="主题名称"
@keydown.enter="handleEditSave(t)" @keydown.enter="handleEditSave(t)"
/> />
<n-input
v-model:value="editingComment"
class="topic-edit-comment"
size="small"
placeholder="备注"
/>
<n-select <n-select
v-model:value="editingQos" v-model:value="editingQos"
class="topic-edit-qos"
size="small" size="small"
:options="qosOptions" :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>
<template #suffix>
<n-space <n-space
v-if="editingTopicId !== (t.id || t.topic)" class="topic-edit-actions"
class="topic-actions"
:size="4" :size="4"
:wrap="false" :wrap="false"
> >
<n-button size="tiny" @click="startEdit(t)">编辑</n-button>
<n-button <n-button
size="tiny" size="tiny"
:type="t.subscribed ? 'error' : 'success'" type="primary"
@click="handleEditSave(t)"
>保存</n-button>
<n-button
size="tiny"
@click="cancelEdit"
>取消</n-button>
</n-space>
</div>
</div>
<template #suffix>
<n-space
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
class="topic-action-sub"
size="tiny"
:type="t.subscribed ? 'success' : 'primary'"
@click="handleToggleSub(t.topic)" @click="handleToggleSub(t.topic)"
> >
{{ t.subscribed ? '取消订阅' : '订阅' }} {{ t.subscribed ? '订阅' : '订阅' }}
</n-button>
<n-button size="tiny" type="error" text @click="handleRemoveTopic(t.topic)">
&#10005;
</n-button> </n-button>
<n-button
class="topic-action-remove"
size="tiny"
type="error"
text
@click.stop="handleRemoveTopic(t.topic)"
>&#10005;</n-button>
</n-space> </n-space>
</template> </template>
</n-list-item> </n-list-item>
</n-list> </n-list>
</div> </div>
</n-card> </n-card>
</div> </div>
<n-empty v-else description="请先在左侧选择一个 MQTT 服务器" />
<!-- 空状态 -->
<n-empty
v-else
description="请先在左侧选择一个 MQTT 服务器"
/>
</template> </template>
<style scoped> <style scoped lang="less">
.subscribe-view { .subscribe-view {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -290,45 +470,168 @@ async function handleEditSave(t) {
width: 160px; width: 160px;
} }
.comment-field {
flex: 1 1 180px;
width: auto;
}
.topic-submit { .topic-submit {
flex: 0 0 auto; flex: 0 0 auto;
} }
.topic-field :deep(.n-form-item-blank), .topic-field,
.qos-field :deep(.n-form-item-blank) { .qos-field,
.comment-field {
:deep(.n-form-item-blank) {
width: 100%; 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; display: flex;
align-items: center; align-items: center;
height: 100%;
min-width: 0; min-width: 0;
gap: 12px; gap: 12px;
}
}
.topic-content {
position: relative;
display: flex;
align-items: center;
flex: 1;
width: 100%;
height: 100%;
min-width: 0;
overflow: hidden;
} }
.topic-name { .topic-name {
min-width: 0; display: flex;
flex: 1; 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; 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 { .topic-actions {
flex-shrink: 0; 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 { .topic-edit-row {
position: absolute;
inset: 0;
display: flex; display: flex;
align-items: center; align-items: center;
flex-wrap: wrap; flex-wrap: nowrap;
gap: 8px; 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; min-width: 0;
} }
.topic-edit-row .n-input { .topic-edit-comment {
flex: 1; flex: 1 1 auto;
min-width: 180px; min-width: 0;
}
.topic-edit-qos {
flex: 0 0 auto;
width: 160px;
min-width: 80px;
} }
@media (max-width: 720px) { @media (max-width: 720px) {
+118
View File
@@ -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';
}
+1 -1
View File
@@ -813,7 +813,7 @@
<div class="sidebar-logo">F</div> <div class="sidebar-logo">F</div>
<div> <div>
<div class="sidebar-title">Frost MQTT Client</div> <div class="sidebar-title">Frost MQTT Client</div>
<div class="sidebar-subtitle">服务器管理客户端</div> <div class="sidebar-subtitle">功能 MQTT 客户端</div>
</div> </div>
</div> </div>
+5
View File
@@ -0,0 +1,5 @@
# CHANGELOG
## [1.0.0] - 2026-07-21
- 初始版本发布。
+4
View File
@@ -0,0 +1,4 @@
# TODO
- [X] 过滤主题功能支持下拉
- [X] 订阅主题备注
+52
View File
@@ -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 空格缩进。
- 元素标签内文本简短时,将文本与结束标签放在同一行;较长表达式或动态内容可单独成行。
- 样式使用 LessVue 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`,并修复实际报告的问题。