36 Commits

Author SHA1 Message Date
frost-zx f2b7af5824 chore: 更新版本信息(v1.0.1) 2026-07-21 23:37:59 +08:00
frost-zx 0f89e142e9 feat: 快速消息支持备注 2026-07-21 23:36:22 +08:00
frost-zx 9723495f12 feat: 快速发送列表持久化到本地文件 frost-mqtt-data.json 2026-07-21 23:22:18 +08:00
frost-zx 4aaf584451 fix: 解决“快速发送”模块中,主题名称较长时,外层卡片缩小到一定程度后不能再随外层容器缩小而缩小的问题 2026-07-21 23:05:11 +08:00
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 4391 additions and 1395 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
productName: frost-mqtt-client
appId: top.frost-zx.frost-mqtt-client
productName: Frost MQTT Client
directories:
buildResources: build
files:
@@ -13,10 +13,15 @@ asarUnpack:
win:
executableName: frost-mqtt-client
nsis:
artifactName: ${name}-${version}-setup.${ext}
allowToChangeInstallationDirectory: true
artifactName: ${name}-${version}-Windows-${arch}-Setup.${ext}
createDesktopShortcut: always
createStartMenuShortcut: true
oneClick: false
perMachine: false
runAfterFinish: false
shortcutName: ${productName}
uninstallDisplayName: ${productName}
createDesktopShortcut: always
mac:
entitlementsInherit: build/entitlements.mac.plist
extendInfo:
@@ -26,16 +31,17 @@ mac:
- NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder.
notarize: false
dmg:
artifactName: ${name}-${version}.${ext}
artifactName: ${name}-${version}-macOS-${arch}.${ext}
linux:
target:
- AppImage
- snap
- deb
maintainer: electronjs.org
category: Utility
artifactName: ${name}-${version}-Linux-${arch}.${ext}
maintainer: frost-zx.github.io
target:
# - AppImage
# - snap
- deb
appImage:
artifactName: ${name}-${version}.${ext}
artifactName: ${name}-${version}-Linux-${arch}.${ext}
npmRebuild: false
publish:
provider: generic
+12 -10
View File
@@ -1,10 +1,11 @@
import eslintConfig from '@electron-toolkit/eslint-config'
import eslintConfigPrettier from '@electron-toolkit/eslint-config-prettier'
import eslintPluginVue from 'eslint-plugin-vue'
import vueParser from 'vue-eslint-parser'
export default [
{ ignores: ['**/node_modules', '**/dist', '**/out'] },
{
ignores: ['**/node_modules', '**/dist', '**/out'],
},
eslintConfig,
...eslintPluginVue.configs['flat/recommended'],
{
@@ -13,18 +14,19 @@ export default [
parser: vueParser,
parserOptions: {
ecmaFeatures: {
jsx: true
jsx: true,
},
extraFileExtensions: ['.vue'],
},
},
extraFileExtensions: ['.vue']
}
}
},
{
files: ['**/*.{js,jsx,vue}'],
rules: {
'vue/multi-word-component-names': 'off',
'vue/multiline-html-element-content-newline': 'off',
'vue/require-default-prop': 'off',
'vue/multi-word-component-names': 'off'
}
'vue/singleline-html-element-content-newline': 'off',
},
eslintConfigPrettier
]
},
];
+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"
]
}
+5 -7
View File
@@ -1,12 +1,11 @@
{
"name": "frost-mqtt-client",
"version": "1.0.0",
"description": "An Electron application with Vue",
"version": "1.0.1",
"description": "一个使用 AI 开发的多功能 MQTT 桌面客户端,基于 Electron + Vue 3 + Naive UI 构建。",
"main": "./out/main/index.js",
"author": "example.com",
"homepage": "https://electron-vite.org",
"author": "Frost-ZX",
"homepage": "https://frost-zx.github.io/",
"scripts": {
"format": "prettier --write .",
"lint": "eslint --cache .",
"start": "electron-vite preview",
"dev": "electron-vite dev",
@@ -24,15 +23,14 @@
},
"devDependencies": {
"@electron-toolkit/eslint-config": "^2.1.0",
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
"@vitejs/plugin-vue": "^6.0.2",
"electron": "^39.2.6",
"electron-builder": "^26.0.12",
"electron-vite": "^5.0.0",
"eslint": "^9.39.1",
"less": "^4.7.0",
"eslint-plugin-vue": "^10.6.2",
"naive-ui": "^2.44.1",
"prettier": "^3.7.4",
"vite": "^7.2.6",
"vue": "^3.5.25",
"vue-eslint-parser": "^10.2.0"
+182 -93
View File
@@ -21,12 +21,9 @@ importers:
'@electron-toolkit/eslint-config':
specifier: ^2.1.0
version: 2.1.0(eslint@9.39.5(jiti@2.7.0))
'@electron-toolkit/eslint-config-prettier':
specifier: ^3.0.0
version: 3.0.0(eslint@9.39.5(jiti@2.7.0))(prettier@3.9.5)
'@vitejs/plugin-vue':
specifier: ^6.0.2
version: 6.0.8(vite@7.3.6(@types/node@26.1.1)(jiti@2.7.0))(vue@3.5.39)
version: 6.0.8(vite@7.3.6(@types/node@26.1.1)(jiti@2.7.0)(less@4.7.0))(vue@3.5.39)
electron:
specifier: ^39.2.6
version: 39.8.10
@@ -35,22 +32,22 @@ importers:
version: 26.15.3(electron-builder-squirrel-windows@26.15.3)
electron-vite:
specifier: ^5.0.0
version: 5.0.0(vite@7.3.6(@types/node@26.1.1)(jiti@2.7.0))
version: 5.0.0(vite@7.3.6(@types/node@26.1.1)(jiti@2.7.0)(less@4.7.0))
eslint:
specifier: ^9.39.1
version: 9.39.5(jiti@2.7.0)
eslint-plugin-vue:
specifier: ^10.6.2
version: 10.9.2(eslint@9.39.5(jiti@2.7.0))(vue-eslint-parser@10.4.1(eslint@9.39.5(jiti@2.7.0)))
less:
specifier: ^4.7.0
version: 4.7.0
naive-ui:
specifier: ^2.44.1
version: 2.44.1(vue@3.5.39)
prettier:
specifier: ^3.7.4
version: 3.9.5
vite:
specifier: ^7.2.6
version: 7.3.6(@types/node@26.1.1)(jiti@2.7.0)
version: 7.3.6(@types/node@26.1.1)(jiti@2.7.0)(less@4.7.0)
vue:
specifier: ^3.5.25
version: 3.5.39
@@ -151,12 +148,6 @@ packages:
peerDependencies:
vue: ^3.0.11
'@electron-toolkit/eslint-config-prettier@3.0.0':
resolution: {integrity: sha512-YapmIOVkbYdHLuTa+ad1SAVtcqYL9A/SJsc7cxQokmhcwAwonGevNom37jBf9slXegcZ/Slh01I/JARG1yhNFw==}
peerDependencies:
eslint: '>= 9.0.0'
prettier: '>= 3.0.0'
'@electron-toolkit/eslint-config@2.1.0':
resolution: {integrity: sha512-F/r45x5wDHs8r2RSkXwcrMYo9X4lkc/W+ZTlWzOzjXn7ncirMqVo256BCAogeNBWIxd24ePsSwmJXELiQZUZUg==}
peerDependencies:
@@ -638,10 +629,6 @@ packages:
resolution: {integrity: sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ==}
engines: {node: '>=14.18.0'}
'@pkgr/core@0.3.6':
resolution: {integrity: sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==}
engines: {node: ^14.18.0 || >=16.0.0}
'@rolldown/pluginutils@1.0.1':
resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
@@ -1104,6 +1091,10 @@ packages:
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
copy-anything@3.0.5:
resolution: {integrity: sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==}
engines: {node: '>=12.13'}
core-util-is@1.0.3:
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
@@ -1136,6 +1127,22 @@ packages:
date-fns@4.4.0:
resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==}
debug@2.6.9:
resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==}
peerDependencies:
supports-color: '*'
peerDependenciesMeta:
supports-color:
optional: true
debug@3.2.7:
resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==}
peerDependencies:
supports-color: '*'
peerDependenciesMeta:
supports-color:
optional: true
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
@@ -1248,6 +1255,10 @@ packages:
err-code@2.0.3:
resolution: {integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==}
errno@0.1.8:
resolution: {integrity: sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==}
hasBin: true
es-define-property@1.0.1:
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
engines: {node: '>= 0.4'}
@@ -1285,26 +1296,6 @@ packages:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'}
eslint-config-prettier@10.1.8:
resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==}
hasBin: true
peerDependencies:
eslint: '>=7.0.0'
eslint-plugin-prettier@5.5.6:
resolution: {integrity: sha512-ifetmTcxWfz+4qRW3pH/ujdTq2jQIj59AxJMIN26K5avYgU8dxycUETQonWiW+wPrYXA0j3Try0l1CnwVQtDqQ==}
engines: {node: ^14.18.0 || >=16.0.0}
peerDependencies:
'@types/eslint': '>=8.0.0'
eslint: '>=8.0.0'
eslint-config-prettier: '>= 7.0.0 <10.0.0 || >=10.1.0'
prettier: '>=3.0.0'
peerDependenciesMeta:
'@types/eslint':
optional: true
eslint-config-prettier:
optional: true
eslint-plugin-vue@10.9.2:
resolution: {integrity: sha512-4g7ZP3pYcuqd7Zp0pzUKcos0W+RkjBz4EGdhJ92FcYk6v03Ti/GK5NwjgsjxHK+98eXDbHeK7VtX1az7/8doZA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -1398,9 +1389,6 @@ packages:
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
fast-diff@1.3.0:
resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==}
fast-json-stable-stringify@2.1.0:
resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
@@ -1583,6 +1571,14 @@ packages:
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
engines: {node: '>= 14'}
iconv-lite@0.4.24:
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
engines: {node: '>=0.10.0'}
iconv-lite@0.6.3:
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
engines: {node: '>=0.10.0'}
ieee754@1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
@@ -1621,6 +1617,10 @@ packages:
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
engines: {node: '>=0.10.0'}
is-what@4.1.16:
resolution: {integrity: sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==}
engines: {node: '>=12.13'}
isarray@1.0.0:
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
@@ -1699,6 +1699,11 @@ packages:
lazy-val@1.0.5:
resolution: {integrity: sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==}
less@4.7.0:
resolution: {integrity: sha512-i7dAlT3+boO0mMh1G4cex0vz1lLAScmBbikm1VEDNv+cy0ore1CUo2UtX8m3N9QLE5WYDr4ISbiCRzHNGyFkrA==}
engines: {node: '>=18'}
hasBin: true
levn@0.4.1:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
@@ -1733,6 +1738,10 @@ packages:
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
make-dir@5.1.0:
resolution: {integrity: sha512-IfpFq6UM39dUNiphpA6uDezNx/AvWyhwfICWPR3t1VspkgkMZrL+Rk1RbN1bx+aeNYwOrqGJgEgV3yotk+ZUVw==}
engines: {node: '>=18'}
matcher@3.0.0:
resolution: {integrity: sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==}
engines: {node: '>=10'}
@@ -1749,6 +1758,11 @@ packages:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
mime@1.6.0:
resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==}
engines: {node: '>=4'}
hasBin: true
mime@2.6.0:
resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==}
engines: {node: '>=4.0.0'}
@@ -1800,6 +1814,9 @@ packages:
engines: {node: '>=16.0.0'}
hasBin: true
ms@2.0.0:
resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==}
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -1817,6 +1834,16 @@ packages:
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
needle@2.9.1:
resolution: {integrity: sha512-6R9fqJ5Zcmf+uYaFgdIHmLwNldn5HbK8L5ybn7Uz+ylX/rnOsSp1AHcvQSrCaFN+qNM1wpymHqD7mVasEOlHGQ==}
engines: {node: '>= 4.4.x'}
hasBin: true
needle@3.5.0:
resolution: {integrity: sha512-jaQyPKKk2YokHrEg+vFDYxXIHTCBgiZwSHOoVx/8V3GIBS8/VN6NdVRmg8q1ERtPkMvmOvebsgga4sAj5hls/w==}
engines: {node: '>= 4.4.x'}
hasBin: true
node-abi@4.33.0:
resolution: {integrity: sha512-vLBWCKb+7LWsX+TbfzWOkw0W81m377tyx3hOweBTjO43CXZnRGS1/JPWs20fr0PgZyDXk6ROYrylsEycK8raDA==}
engines: {node: '>=22.12.0'}
@@ -1878,6 +1905,10 @@ packages:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
parse-node-version@1.0.1:
resolution: {integrity: sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA==}
engines: {node: '>= 0.10'}
path-exists@4.0.0:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
engines: {node: '>=8'}
@@ -1929,14 +1960,8 @@ packages:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
prettier-linter-helpers@1.0.1:
resolution: {integrity: sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==}
engines: {node: '>=6.0.0'}
prettier@3.9.5:
resolution: {integrity: sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==}
engines: {node: '>=14'}
hasBin: true
probe-image-size@7.3.0:
resolution: {integrity: sha512-7CaDeBwiAbh6ohXsvLbAZhO7wzsZAmaevfxe39qvCwRh8LyaZfDlBGGLU1CCTgrTLtCOdwBBhjOrIHaIIimHfQ==}
proc-log@6.1.0:
resolution: {integrity: sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==}
@@ -1960,6 +1985,9 @@ packages:
proper-lockfile@4.1.2:
resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==}
prr@1.0.1:
resolution: {integrity: sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==}
pump@3.0.4:
resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==}
@@ -2042,6 +2070,9 @@ packages:
safe-buffer@5.2.1:
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
sanitize-filename@1.6.4:
resolution: {integrity: sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg==}
@@ -2122,6 +2153,9 @@ packages:
resolution: {integrity: sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==}
engines: {node: '>= 6'}
stream-parser@0.3.1:
resolution: {integrity: sha512-bJ/HgKq41nlKvlhccD5kaCr/P+Hu0wPNKPJOH7en+YrJu/9EgqUF+88w5Jb6KNcjOFMhfX4B2asfeAtIGuHObQ==}
string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
@@ -2148,10 +2182,6 @@ packages:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'}
synckit@0.11.13:
resolution: {integrity: sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==}
engines: {node: ^14.18.0 || >=16.0.0}
tar@7.5.20:
resolution: {integrity: sha512-9FcyK4PA6+WbzlTM9WhQm6vB5W7cP7dUiPsv1g7YDwEQnQ1CGpK3MGlKk/ITVWMk05kHZuBhmVhiv8LZoy/PFQ==}
engines: {node: '>=18'}
@@ -2511,15 +2541,6 @@ snapshots:
dependencies:
vue: 3.5.39
'@electron-toolkit/eslint-config-prettier@3.0.0(eslint@9.39.5(jiti@2.7.0))(prettier@3.9.5)':
dependencies:
eslint: 9.39.5(jiti@2.7.0)
eslint-config-prettier: 10.1.8(eslint@9.39.5(jiti@2.7.0))
eslint-plugin-prettier: 5.5.6(eslint-config-prettier@10.1.8(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0))(prettier@3.9.5)
prettier: 3.9.5
transitivePeerDependencies:
- '@types/eslint'
'@electron-toolkit/eslint-config@2.1.0(eslint@9.39.5(jiti@2.7.0))':
dependencies:
'@eslint/js': 9.39.5
@@ -2911,8 +2932,6 @@ snapshots:
tslib: 2.8.1
webcrypto-core: 1.9.2
'@pkgr/core@0.3.6': {}
'@rolldown/pluginutils@1.0.1': {}
'@rollup/rollup-android-arm-eabi@4.62.2':
@@ -3056,10 +3075,10 @@ snapshots:
'@types/node': 22.20.1
optional: true
'@vitejs/plugin-vue@6.0.8(vite@7.3.6(@types/node@26.1.1)(jiti@2.7.0))(vue@3.5.39)':
'@vitejs/plugin-vue@6.0.8(vite@7.3.6(@types/node@26.1.1)(jiti@2.7.0)(less@4.7.0))(vue@3.5.39)':
dependencies:
'@rolldown/pluginutils': 1.0.1
vite: 7.3.6(@types/node@26.1.1)(jiti@2.7.0)
vite: 7.3.6(@types/node@26.1.1)(jiti@2.7.0)(less@4.7.0)
vue: 3.5.39
'@vue/compiler-core@3.5.39':
@@ -3383,6 +3402,10 @@ snapshots:
convert-source-map@2.0.0: {}
copy-anything@3.0.5:
dependencies:
is-what: 4.1.16
core-util-is@1.0.3: {}
cross-dirname@0.1.0:
@@ -3411,6 +3434,16 @@ snapshots:
date-fns@4.4.0: {}
debug@2.6.9:
dependencies:
ms: 2.0.0
optional: true
debug@3.2.7:
dependencies:
ms: 2.1.3
optional: true
debug@4.4.3:
dependencies:
ms: 2.1.3
@@ -3518,7 +3551,7 @@ snapshots:
electron-to-chromium@1.5.392: {}
electron-vite@5.0.0(vite@7.3.6(@types/node@26.1.1)(jiti@2.7.0)):
electron-vite@5.0.0(vite@7.3.6(@types/node@26.1.1)(jiti@2.7.0)(less@4.7.0)):
dependencies:
'@babel/core': 7.29.7
'@babel/plugin-transform-arrow-functions': 7.29.7(@babel/core@7.29.7)
@@ -3526,7 +3559,7 @@ snapshots:
esbuild: 0.25.12
magic-string: 0.30.21
picocolors: 1.1.1
vite: 7.3.6(@types/node@26.1.1)(jiti@2.7.0)
vite: 7.3.6(@types/node@26.1.1)(jiti@2.7.0)(less@4.7.0)
transitivePeerDependencies:
- supports-color
@@ -3562,6 +3595,11 @@ snapshots:
err-code@2.0.3: {}
errno@0.1.8:
dependencies:
prr: 1.0.1
optional: true
es-define-property@1.0.1: {}
es-errors@1.3.0: {}
@@ -3642,19 +3680,6 @@ snapshots:
escape-string-regexp@4.0.0: {}
eslint-config-prettier@10.1.8(eslint@9.39.5(jiti@2.7.0)):
dependencies:
eslint: 9.39.5(jiti@2.7.0)
eslint-plugin-prettier@5.5.6(eslint-config-prettier@10.1.8(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0))(prettier@3.9.5):
dependencies:
eslint: 9.39.5(jiti@2.7.0)
prettier: 3.9.5
prettier-linter-helpers: 1.0.1
synckit: 0.11.13
optionalDependencies:
eslint-config-prettier: 10.1.8(eslint@9.39.5(jiti@2.7.0))
eslint-plugin-vue@10.9.2(eslint@9.39.5(jiti@2.7.0))(vue-eslint-parser@10.4.1(eslint@9.39.5(jiti@2.7.0))):
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.5(jiti@2.7.0))
@@ -3771,8 +3796,6 @@ snapshots:
fast-deep-equal@3.1.3: {}
fast-diff@1.3.0: {}
fast-json-stable-stringify@2.1.0: {}
fast-levenshtein@2.0.6: {}
@@ -3987,6 +4010,16 @@ snapshots:
transitivePeerDependencies:
- supports-color
iconv-lite@0.4.24:
dependencies:
safer-buffer: 2.1.2
optional: true
iconv-lite@0.6.3:
dependencies:
safer-buffer: 2.1.2
optional: true
ieee754@1.2.1: {}
ignore@5.3.2: {}
@@ -4015,6 +4048,8 @@ snapshots:
dependencies:
is-extglob: 2.1.1
is-what@4.1.16: {}
isarray@1.0.0: {}
isbinaryfile@4.0.10: {}
@@ -4074,6 +4109,21 @@ snapshots:
lazy-val@1.0.5: {}
less@4.7.0:
dependencies:
copy-anything: 3.0.5
parse-node-version: 1.0.1
optionalDependencies:
errno: 0.1.8
graceful-fs: 4.2.11
make-dir: 5.1.0
mime: 1.6.0
needle: 3.5.0
probe-image-size: 7.3.0
source-map: 0.6.1
transitivePeerDependencies:
- supports-color
levn@0.4.1:
dependencies:
prelude-ls: 1.2.1
@@ -4105,6 +4155,9 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
make-dir@5.1.0:
optional: true
matcher@3.0.0:
dependencies:
escape-string-regexp: 4.0.0
@@ -4118,6 +4171,9 @@ snapshots:
dependencies:
mime-db: 1.52.0
mime@1.6.0:
optional: true
mime@2.6.0: {}
mimic-response@1.0.1: {}
@@ -4183,6 +4239,9 @@ snapshots:
- supports-color
- utf-8-validate
ms@2.0.0:
optional: true
ms@2.1.3: {}
naive-ui@2.44.1(vue@3.5.39):
@@ -4211,6 +4270,21 @@ snapshots:
natural-compare@1.4.0: {}
needle@2.9.1:
dependencies:
debug: 3.2.7
iconv-lite: 0.4.24
sax: 1.6.0
transitivePeerDependencies:
- supports-color
optional: true
needle@3.5.0:
dependencies:
iconv-lite: 0.6.3
sax: 1.6.0
optional: true
node-abi@4.33.0:
dependencies:
semver: 7.7.4
@@ -4283,6 +4357,8 @@ snapshots:
dependencies:
callsites: 3.1.0
parse-node-version@1.0.1: {}
path-exists@4.0.0: {}
path-is-absolute@1.0.1: {}
@@ -4330,11 +4406,14 @@ snapshots:
prelude-ls@1.2.1: {}
prettier-linter-helpers@1.0.1:
probe-image-size@7.3.0:
dependencies:
fast-diff: 1.3.0
prettier@3.9.5: {}
lodash.merge: 4.6.2
needle: 2.9.1
stream-parser: 0.3.1
transitivePeerDependencies:
- supports-color
optional: true
proc-log@6.1.0: {}
@@ -4355,6 +4434,9 @@ snapshots:
retry: 0.12.0
signal-exit: 3.0.7
prr@1.0.1:
optional: true
pump@3.0.4:
dependencies:
end-of-stream: 1.4.5
@@ -4469,6 +4551,9 @@ snapshots:
safe-buffer@5.2.1: {}
safer-buffer@2.1.2:
optional: true
sanitize-filename@1.6.4:
dependencies:
truncate-utf8-bytes: 1.0.2
@@ -4528,6 +4613,13 @@ snapshots:
stat-mode@1.0.0: {}
stream-parser@0.3.1:
dependencies:
debug: 2.6.9
transitivePeerDependencies:
- supports-color
optional: true
string-width@4.2.3:
dependencies:
emoji-regex: 8.0.0
@@ -4558,10 +4650,6 @@ snapshots:
dependencies:
has-flag: 4.0.0
synckit@0.11.13:
dependencies:
'@pkgr/core': 0.3.6
tar@7.5.20:
dependencies:
'@isaacs/fs-minipass': 4.0.1
@@ -4649,7 +4737,7 @@ snapshots:
evtd: 0.2.4
vue: 3.5.39
vite@7.3.6(@types/node@26.1.1)(jiti@2.7.0):
vite@7.3.6(@types/node@26.1.1)(jiti@2.7.0)(less@4.7.0):
dependencies:
esbuild: 0.28.1
fdir: 6.5.0(picomatch@4.0.5)
@@ -4661,6 +4749,7 @@ snapshots:
'@types/node': 26.1.1
fsevents: 2.3.3
jiti: 2.7.0
less: 4.7.0
vooks@0.2.12(vue@3.5.39):
dependencies:
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 { join } from 'path'
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import icon from '../../resources/icon.png?asset'
import { registerIpcHandlers } from './ipc-handlers.js'
import { app, shell, BrowserWindow } from 'electron';
import { join } from 'path';
import { electronApp, optimizer, is } from '@electron-toolkit/utils';
import { registerIpcHandlers } from './ipc-handlers.js';
import icon from '../../resources/icon.png?asset';
/**
* 创建主窗口
* @returns {BrowserWindow} 主窗口实例
*/
function createWindow() {
// Create the browser window.
// 创建浏览器窗口
const mainWindow = new BrowserWindow({
width: 1200,
height: 800,
@@ -16,62 +22,66 @@ function createWindow() {
...(process.platform === 'linux' ? { icon } : {}),
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false
}
})
sandbox: false,
},
});
// 窗口准备就绪后显示,避免白屏
mainWindow.on('ready-to-show', () => {
mainWindow.show()
})
mainWindow.show();
});
// 拦截新窗口打开请求,使用系统默认浏览器打开外部链接
mainWindow.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url)
return { action: 'deny' }
})
shell.openExternal(details.url);
return { action: 'deny' };
});
// HMR for renderer base on electron-vite cli.
// Load the remote URL for development or the local html file for production.
// 基于 electron-vite cli 的热更新
// 开发环境加载远程 URL,生产环境加载本地 HTML 文件
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']);
} else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
mainWindow.loadFile(join(__dirname, '../renderer/index.html'));
}
}
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
// 当 Electron 完成初始化并准备好创建浏览器窗口时调用此方法
// 某些 API 只能在此事件触发后使用
app.whenReady().then(() => {
// Set app user model id for windows
electronApp.setAppUserModelId('com.electron')
// Default open or close DevTools by F12 in development
// and ignore CommandOrControl + R in production.
// see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
// 为 Windows 设置应用用户模型 ID
electronApp.setAppUserModelId('com.electron');
// 开发环境通过 F12 打开/关闭 DevTools
// 生产环境忽略 CommandOrControl + R 刷新
// 详见 https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window)
})
optimizer.watchWindowShortcuts(window);
});
// 注册 IPC 处理器
registerIpcHandlers()
registerIpcHandlers();
createWindow()
createWindow();
app.on('activate', function () {
// On macOS it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
// macOS 上,当点击应用图标且没有其他窗口打开时,通常会重新创建一个窗口
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
// Quit when all windows are closed, except on macOS. There, it's common
// for applications and their menu bar to stay active until the user quits
// explicitly with Cmd + Q.
});
// 当所有窗口关闭时退出应用,macOS 除外
// 在 macOS 上,应用及其菜单栏通常会保持活动状态,直到用户通过 Cmd + Q 显式退出
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
app.quit();
}
})
});
// In this file you can include the rest of your app's specific main process
// code. You can also put them in separate files and require them here.
// 在这个文件中,你可以包含应用特定的主进程代码
// 也可以将它们放到单独的文件中,然后在这里引入
+96 -61
View File
@@ -1,18 +1,31 @@
import { ipcMain, BrowserWindow } from 'electron'
import mqttManager from './mqtt-manager.js'
import { ipcMain, BrowserWindow } from 'electron';
import mqttManager from './mqtt-manager.js';
/**
* 注册所有 IPC 处理器
* 将渲染进程的请求转发给 MQTT 管理器并把 MQTT 事件推送给渲染进程
*/
export function registerIpcHandlers() {
// 获取主窗口(用于推送事件)
/**
* 获取当前所有窗口中的第一个主窗口
* @returns {BrowserWindow|null} 主窗口实例或 null
*/
function getMainWindow() {
const windows = BrowserWindow.getAllWindows()
return windows.length > 0 ? windows[0] : null
const windows = BrowserWindow.getAllWindows();
return windows.length > 0 ? windows[0] : null;
}
// 向渲染进程发送事件
/**
* 向渲染进程发送事件
* @param {string} channel - IPC 通道名称
* @param {*} data - 要发送的数据
*/
function sendToRenderer(channel, data) {
const win = getMainWindow()
const win = getMainWindow();
if (win && !win.isDestroyed()) {
win.webContents.send(channel, data)
win.webContents.send(channel, data);
}
}
@@ -20,100 +33,122 @@ export function registerIpcHandlers() {
mqttManager.addListener((event, data) => {
switch (event) {
case 'status-change':
sendToRenderer('mqtt:status-change', data)
break
sendToRenderer('mqtt:status-change', data);
break;
case 'message':
sendToRenderer('mqtt:message', data)
break
sendToRenderer('mqtt:message', data);
break;
case 'subscription-change':
sendToRenderer('mqtt:subscription-change', data)
break
sendToRenderer('mqtt:subscription-change', data);
break;
case 'subscription-error':
sendToRenderer('mqtt:subscription-error', data)
break
sendToRenderer('mqtt:subscription-error', data);
break;
case 'publish-error':
sendToRenderer('mqtt:publish-error', data)
break
sendToRenderer('mqtt:publish-error', data);
break;
}
})
});
// ==================== 服务器管理 ====================
ipcMain.handle('mqtt:get-servers', () => {
return mqttManager.getServers()
})
return mqttManager.getServers();
});
ipcMain.handle('mqtt:add-server', (_, config) => {
return mqttManager.addServer(config)
})
return mqttManager.addServer(config);
});
ipcMain.handle('mqtt:update-server', (_, id, config) => {
return mqttManager.updateServer(id, config)
})
return mqttManager.updateServer(id, config);
});
ipcMain.handle('mqtt:delete-server', (_, id) => {
mqttManager.deleteServer(id)
return true
})
mqttManager.deleteServer(id);
return true;
});
ipcMain.handle('mqtt:export-servers', () => {
return mqttManager.exportServers();
});
ipcMain.handle('mqtt:import-servers', (_, serverList) => {
return mqttManager.importServers(serverList);
});
// ==================== 快速发送 ====================
ipcMain.handle('mqtt:get-quick-send-list', () => {
return mqttManager.getQuickSendList();
});
ipcMain.handle('mqtt:save-quick-send-list', (_, list) => {
mqttManager.saveQuickSendList(list);
return true;
});
// ==================== 连接管理 ====================
ipcMain.handle('mqtt:connect', (_, id) => {
return mqttManager.connect(id)
})
return mqttManager.connect(id);
});
ipcMain.handle('mqtt:disconnect', (_, id) => {
mqttManager.disconnect(id)
return true
})
mqttManager.disconnect(id);
return true;
});
// ==================== 主题管理 ====================
ipcMain.handle('mqtt:add-topic', (_, serverId, topic, qos) => {
return mqttManager.addTopic(serverId, topic, qos)
})
ipcMain.handle('mqtt:add-topic', (_, serverId, topic, qos, comment) => {
return mqttManager.addTopic(serverId, topic, qos, comment);
});
ipcMain.handle('mqtt:remove-topic', (_, serverId, topic) => {
return mqttManager.removeTopic(serverId, topic)
})
return mqttManager.removeTopic(serverId, topic);
});
ipcMain.handle('mqtt:subscribe', (_, serverId, topic) => {
return mqttManager.subscribe(serverId, topic)
})
return mqttManager.subscribe(serverId, topic);
});
ipcMain.handle('mqtt:unsubscribe', (_, serverId, topic) => {
return mqttManager.unsubscribe(serverId, topic)
})
return mqttManager.unsubscribe(serverId, topic);
});
ipcMain.handle('mqtt:subscribe-all', (_, serverId) => {
return mqttManager.subscribeAll(serverId)
})
return mqttManager.subscribeAll(serverId);
});
ipcMain.handle('mqtt:update-topic', (_, serverId, topicId, topic, qos) => {
return mqttManager.updateTopic(serverId, topicId, topic, qos)
})
ipcMain.handle('mqtt:update-topic', (_, serverId, topicId, topic, qos, comment) => {
return mqttManager.updateTopic(serverId, topicId, topic, qos, comment);
});
// ==================== 消息发布 ====================
ipcMain.handle('mqtt:publish', (_, serverId, topic, payload, opts) => {
return mqttManager.publish(serverId, topic, payload, opts)
})
return mqttManager.publish(serverId, topic, payload, opts);
});
// ==================== 消息管理 ====================
ipcMain.handle('mqtt:get-messages', (_, serverId) => {
return mqttManager.getMessages(serverId)
})
return mqttManager.getMessages(serverId);
});
ipcMain.handle('mqtt:get-publish-history', (_, serverId) => {
return mqttManager.getPublishHistory(serverId)
})
return mqttManager.getPublishHistory(serverId);
});
ipcMain.handle('mqtt:clear-messages', (_, serverId) => {
mqttManager.clearMessages(serverId)
return true
})
mqttManager.clearMessages(serverId);
return true;
});
ipcMain.handle('mqtt:clear-publish-history', (_, serverId) => {
mqttManager.clearPublishHistory(serverId)
return true
})
mqttManager.clearPublishHistory(serverId);
return true;
});
// 兼容旧的 ping 测试
ipcMain.on('ping', () => console.log('pong'))
}
File diff suppressed because it is too large Load Diff
+41 -31
View File
@@ -1,26 +1,37 @@
import { contextBridge, ipcRenderer } from 'electron'
import { electronAPI } from '@electron-toolkit/preload'
import { contextBridge, ipcRenderer } from 'electron';
import { electronAPI } from '@electron-toolkit/preload';
// Custom APIs for renderer
/**
* 渲染进程可调用的自定义 API
* 封装了所有与主进程通信的 IPC 调用及事件监听
*/
const api = {
// 服务器管理
getServers: () => ipcRenderer.invoke('mqtt:get-servers'),
addServer: (config) => ipcRenderer.invoke('mqtt:add-server', config),
updateServer: (id, config) => ipcRenderer.invoke('mqtt:update-server', id, config),
deleteServer: (id) => ipcRenderer.invoke('mqtt:delete-server', id),
exportServers: () => ipcRenderer.invoke('mqtt:export-servers'),
importServers: (serverList) => ipcRenderer.invoke('mqtt:import-servers', serverList),
// 快速发送
getQuickSendList: () => ipcRenderer.invoke('mqtt:get-quick-send-list'),
saveQuickSendList: (list) => ipcRenderer.invoke('mqtt:save-quick-send-list', list),
// 连接管理
connect: (id) => ipcRenderer.invoke('mqtt:connect', id),
disconnect: (id) => ipcRenderer.invoke('mqtt:disconnect', id),
// 主题管理
addTopic: (serverId, topic, qos) => ipcRenderer.invoke('mqtt:add-topic', serverId, topic, qos),
addTopic: (serverId, topic, qos, comment) =>
ipcRenderer.invoke('mqtt:add-topic', serverId, topic, qos, comment),
removeTopic: (serverId, topic) => ipcRenderer.invoke('mqtt:remove-topic', serverId, topic),
subscribe: (serverId, topic) => ipcRenderer.invoke('mqtt:subscribe', serverId, topic),
unsubscribe: (serverId, topic) => ipcRenderer.invoke('mqtt:unsubscribe', serverId, topic),
subscribeAll: (serverId) => ipcRenderer.invoke('mqtt:subscribe-all', serverId),
updateTopic: (serverId, topicId, topic, qos) =>
ipcRenderer.invoke('mqtt:update-topic', serverId, topicId, topic, qos),
updateTopic: (serverId, topicId, topic, qos, comment) =>
ipcRenderer.invoke('mqtt:update-topic', serverId, topicId, topic, qos, comment),
// 消息发布
publish: (serverId, topic, payload, opts) =>
@@ -34,43 +45,42 @@ const api = {
// 事件监听
onStatusChange: (callback) => {
const handler = (_, data) => callback(data)
ipcRenderer.on('mqtt:status-change', handler)
return () => ipcRenderer.removeListener('mqtt:status-change', handler)
const handler = (_, data) => callback(data);
ipcRenderer.on('mqtt:status-change', handler);
return () => ipcRenderer.removeListener('mqtt:status-change', handler);
},
onMessage: (callback) => {
const handler = (_, data) => callback(data)
ipcRenderer.on('mqtt:message', handler)
return () => ipcRenderer.removeListener('mqtt:message', handler)
const handler = (_, data) => callback(data);
ipcRenderer.on('mqtt:message', handler);
return () => ipcRenderer.removeListener('mqtt:message', handler);
},
onSubscriptionChange: (callback) => {
const handler = (_, data) => callback(data)
ipcRenderer.on('mqtt:subscription-change', handler)
return () => ipcRenderer.removeListener('mqtt:subscription-change', handler)
const handler = (_, data) => callback(data);
ipcRenderer.on('mqtt:subscription-change', handler);
return () => ipcRenderer.removeListener('mqtt:subscription-change', handler);
},
onSubscriptionError: (callback) => {
const handler = (_, data) => callback(data)
ipcRenderer.on('mqtt:subscription-error', handler)
return () => ipcRenderer.removeListener('mqtt:subscription-error', handler)
const handler = (_, data) => callback(data);
ipcRenderer.on('mqtt:subscription-error', handler);
return () => ipcRenderer.removeListener('mqtt:subscription-error', handler);
},
onPublishError: (callback) => {
const handler = (_, data) => callback(data)
ipcRenderer.on('mqtt:publish-error', handler)
return () => ipcRenderer.removeListener('mqtt:publish-error', handler)
}
}
const handler = (_, data) => callback(data);
ipcRenderer.on('mqtt:publish-error', handler);
return () => ipcRenderer.removeListener('mqtt:publish-error', handler);
},
// Use `contextBridge` APIs to expose Electron APIs to
// renderer only if context isolation is enabled, otherwise
// just add to the DOM global.
};
// 根据是否启用上下文隔离,使用 contextBridge 暴露 API 或挂载到 window
if (process.contextIsolated) {
try {
contextBridge.exposeInMainWorld('electron', electronAPI)
contextBridge.exposeInMainWorld('api', api)
contextBridge.exposeInMainWorld('electron', electronAPI);
contextBridge.exposeInMainWorld('api', api);
} catch (error) {
console.error(error)
console.error(error);
}
} else {
window.electron = electronAPI
window.api = api
window.electron = electronAPI;
window.api = api;
}
+3 -3
View File
@@ -1,14 +1,14 @@
<!doctype html>
<html>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Frost MQTT Client</title>
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws: wss: mqtt: mqtts:"
/>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
+179 -62
View File
@@ -1,74 +1,143 @@
<script setup>
import { ref, onMounted } from 'vue'
import {
NLayout,
NMessageProvider,
NDialogProvider,
NConfigProvider,
NTabs,
NTabPane,
zhCN,
dateZhCN
} from 'naive-ui'
import Sidebar from './components/Sidebar.vue'
import ServerModal from './components/ServerModal.vue'
import Dashboard from './views/Dashboard.vue'
import Subscribe from './views/Subscribe.vue'
import Publish from './views/Publish.vue'
import Messages from './views/Messages.vue'
import { useMqttStore } from './stores/mqtt.js'
import { ref, onMounted } from 'vue';
import { NLayout, NMessageProvider, NDialogProvider, NConfigProvider, NTabs, NTabPane, zhCN, dateZhCN } from 'naive-ui';
import { useMqttStore } from './stores/mqtt.js';
const store = useMqttStore()
import Sidebar from './components/Sidebar.vue';
import ServerModal from './components/ServerModal.vue';
import Dashboard from './views/Dashboard.vue';
import Subscribe from './views/Subscribe.vue';
import Publish from './views/Publish.vue';
import Messages from './views/Messages.vue';
import QuickSend from './views/QuickSend.vue';
const activeTab = ref('dashboard')
const showServerModal = ref(false)
const editServerData = ref(null)
//
const store = useMqttStore();
//
const themeOverrides = {
common: {
primaryColor: '#0ea5a0',
primaryColorHover: '#0b8c87',
primaryColorPressed: '#0b8c87',
primaryColorSuppl: '#0ea5a0',
successColor: '#22c55e',
successColorHover: '#16a34a',
successColorPressed: '#15803d',
successColorSuppl: '#22c55e',
warningColor: '#f59e0b',
warningColorHover: '#d97706',
warningColorPressed: '#b45309',
warningColorSuppl: '#f59e0b',
errorColor: '#ef4444',
errorColorHover: '#dc2626',
errorColorPressed: '#b91c1c',
errorColorSuppl: '#ef4444',
infoColor: '#3b82f6',
infoColorHover: '#2563eb',
infoColorPressed: '#1d4ed8',
infoColorSuppl: '#3b82f6',
},
};
//
const activeTab = ref('dashboard');
//
const showServerModal = ref(false);
// null
const editServerData = ref(null);
// Store
onMounted(async () => {
await store.init()
})
await store.init();
});
/**
* 打开新增服务器弹窗
*/
function handleAddServer() {
editServerData.value = null
showServerModal.value = true
editServerData.value = null;
showServerModal.value = true;
}
/**
* 打开编辑服务器弹窗
* @param {import('@types/jsdoc').MqttServer} server - 要编辑的服务器对象
*/
function handleEditServer(server) {
editServerData.value = server
showServerModal.value = true
editServerData.value = server;
showServerModal.value = true;
}
/**
* 服务器保存后的回调
*/
function handleServerSaved() {
//
}
/**
* 切换标签页
* @param {string} tab - 标签页名称
*/
function handleSwitchTab(tab) {
activeTab.value = tab
activeTab.value = tab;
}
</script>
<template>
<n-config-provider :locale="zhCN" :date-locale="dateZhCN" :theme="null">
<n-config-provider
:locale="zhCN"
:date-locale="dateZhCN"
:theme="null"
:theme-overrides="themeOverrides"
>
<n-message-provider placement="bottom">
<n-dialog-provider>
<n-layout has-sider position="absolute">
<n-layout
has-sider
position="absolute"
>
<!-- 侧边栏 -->
<Sidebar @add-server="handleAddServer" @edit-server="handleEditServer" />
<Sidebar
@add-server="handleAddServer"
@edit-server="handleEditServer"
/>
<!-- 主内容区 -->
<n-layout content-class="main-layout-content">
<n-tabs v-model:value="activeTab" type="line" size="medium" style="padding: 0 16px">
<n-tab-pane name="dashboard" tab="仪表盘">
<n-tabs
v-model:value="activeTab"
type="line"
size="medium"
style="padding: 0 16px"
>
<n-tab-pane
name="dashboard"
tab="仪表盘"
>
<div class="tab-content dashboard-content">
<Dashboard @switch-tab="handleSwitchTab" @edit-server="handleEditServer" />
<div class="dashboard-scroll-wrapper">
<Dashboard
@switch-tab="handleSwitchTab"
@edit-server="handleEditServer"
/>
</div>
</div>
</n-tab-pane>
<n-tab-pane name="subscribe" tab="主题订阅">
<n-tab-pane
name="subscribe"
tab="主题订阅"
>
<div class="tab-content">
<Subscribe />
</div>
</n-tab-pane>
<n-tab-pane name="messages" tab="消息中心">
<n-tab-pane
name="messages"
tab="消息中心"
>
<div class="tab-content">
<div class="message-center-layout">
<section class="publish-panel">
@@ -80,6 +149,14 @@ function handleSwitchTab(tab) {
</div>
</div>
</n-tab-pane>
<n-tab-pane
name="quick-send"
tab="快速发送"
>
<div class="tab-content">
<QuickSend />
</div>
</n-tab-pane>
</n-tabs>
</n-layout>
@@ -88,16 +165,46 @@ function handleSwitchTab(tab) {
:edit-server="editServerData"
@saved="handleServerSaved"
/>
</n-layout>
</n-dialog-provider>
</n-message-provider>
</n-config-provider>
</template>
<style>
html,
body,
#app {
<style lang="less">
:root {
--frost-primary-color: #0ea5a0;
--frost-primary-color-hover: #0b8c87;
--frost-primary-color-pressed: #0b8c87;
--frost-primary-color-suppl: #0ea5a0;
--frost-success-color: #22c55e;
--frost-success-color-hover: #16a34a;
--frost-success-color-pressed: #15803d;
--frost-success-color-suppl: #22c55e;
--frost-warning-color: #f59e0b;
--frost-warning-color-hover: #d97706;
--frost-warning-color-pressed: #b45309;
--frost-warning-color-suppl: #f59e0b;
--frost-error-color: #ef4444;
--frost-error-color-hover: #dc2626;
--frost-error-color-pressed: #b91c1c;
--frost-error-color-suppl: #ef4444;
--frost-info-color: #3b82f6;
--frost-info-color-hover: #2563eb;
--frost-info-color-pressed: #1d4ed8;
--frost-info-color-suppl: #3b82f6;
--frost-color-text-tertiary: #999;
--frost-color-topic: #d25a00;
--frost-color-bg-light: #f5f7fa;
--frost-color-bg-item: #fafafa;
--frost-shadow-black-4: rgba(0, 0, 0, 0.04);
--frost-shadow-black-6: rgba(0, 0, 0, 0.06);
--frost-shadow-black-8: rgba(0, 0, 0, 0.08);
--frost-shadow-black-12: rgba(0, 0, 0, 0.12);
}
html, body, #app {
margin: 0;
padding: 0;
height: 100%;
@@ -110,12 +217,20 @@ body,
.tab-content {
box-sizing: border-box;
padding: 12px 0;
padding: 0 0 16px 0;
height: calc(100vh - 50px);
overflow: hidden;
&.dashboard-content {
display: flex;
flex-direction: column;
overflow: hidden;
}
}
.dashboard-content {
.dashboard-scroll-wrapper {
flex: 1;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
}
@@ -126,30 +241,27 @@ body,
gap: 12px;
height: 100%;
min-width: 0;
}
.publish-panel,
.messages-panel {
.publish-panel,
.messages-panel {
min-width: 0;
min-height: 0;
}
}
.publish-panel {
.publish-panel {
box-sizing: border-box;
height: 100%;
min-height: 0;
padding-right: 4px;
overflow: hidden;
}
}
.messages-panel {
.messages-panel {
height: 100%;
min-height: 0;
overflow: hidden;
}
}
@media (max-width: 900px) {
.message-center-layout {
@media (max-width: 900px) {
grid-template-columns: 1fr;
}
}
@@ -159,22 +271,27 @@ body,
scrollbar-width: thin;
scrollbar-color: rgba(144, 147, 153, 0.5) transparent;
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
&-track {
background-color: transparent;
}
::-webkit-scrollbar-thumb {
}
&-thumb {
background-color: rgba(144, 147, 153, 0.5);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
&:hover {
background-color: rgba(144, 147, 153, 0.8);
}
::-webkit-scrollbar-corner {
}
}
&-corner {
display: none;
background-color: transparent;
}
}
</style>
+158 -63
View File
@@ -1,31 +1,22 @@
<script setup>
import { ref, watch } from 'vue'
import {
NModal,
NForm,
NFormItem,
NGrid,
NFormItemGi,
NInput,
NInputNumber,
NSelect,
NSwitch,
NButton,
useMessage
} from 'naive-ui'
import { useMqttStore } from '../stores/mqtt.js'
import { ref, watch } from 'vue';
import { NModal, NForm, NGrid, NFormItemGi, NInput, NInputNumber, NSelect, NSwitch, NButton, useMessage } from 'naive-ui';
import { useMqttStore } from '../stores/mqtt.js';
//
const props = defineProps({
show: Boolean,
editServer: Object
})
editServer: Object,
});
const emit = defineEmits(['update:show', 'saved'])
// show
const emit = defineEmits(['update:show', 'saved']);
const store = useMqttStore()
const msg = useMessage()
const store = useMqttStore();
const msg = useMessage();
const formRef = ref(null)
//
const formRef = ref(null);
const form = ref({
name: '',
host: '',
@@ -35,30 +26,39 @@ const form = ref({
username: '',
password: '',
keepAlive: 60,
cleanSession: true
})
cleanSession: true,
connectTimeout: 10000,
reconnect: true,
reconnectInterval: 5000,
});
//
const rules = {
name: { required: true, message: '请输入服务器名称', trigger: 'blur' },
host: { required: true, message: '请输入主机地址', trigger: 'blur' },
port: { required: true, type: 'number', message: '请输入端口号', trigger: 'blur' }
}
port: { required: true, type: 'number', message: '请输入端口号', trigger: 'blur' },
};
//
const protocolOptions = [
{ label: 'mqtt:// (默认)', value: 'mqtt' },
{ label: 'mqtts:// (TLS)', value: 'mqtts' },
{ label: 'ws:// (WebSocket)', value: 'ws' },
{ label: 'wss:// (WebSocket TLS)', value: 'wss' }
]
{ label: 'wss:// (WebSocket TLS)', value: 'wss' },
];
const isEdit = ref(false)
//
const isEdit = ref(false);
/**
* 监听弹窗显示状态打开时初始化表单
*/
watch(
() => props.show,
(val) => {
if (val) {
if (props.editServer) {
isEdit.value = true
isEdit.value = true;
form.value = {
name: props.editServer.name,
host: props.editServer.host,
@@ -66,12 +66,15 @@ watch(
protocol: props.editServer.protocol || 'mqtt',
clientId: props.editServer.clientId || '',
username: props.editServer.username || '',
password: '',
password: props.editServer.password || '',
keepAlive: props.editServer.keepAlive || 60,
cleanSession: props.editServer.cleanSession !== false
}
cleanSession: props.editServer.cleanSession !== false,
connectTimeout: props.editServer.connectTimeout ?? 10000,
reconnect: props.editServer.reconnect !== false,
reconnectInterval: props.editServer.reconnectInterval ?? 5000,
};
} else {
isEdit.value = false
isEdit.value = false;
form.value = {
name: '',
host: '',
@@ -81,61 +84,107 @@ watch(
username: '',
password: '',
keepAlive: 60,
cleanSession: true
cleanSession: true,
connectTimeout: 10000,
reconnect: true,
reconnectInterval: 5000,
};
}
}
}
}
)
);
/**
* 保存服务器配置
*/
async function handleSave() {
try {
await formRef.value?.validate()
await formRef.value?.validate();
} catch {
return
return;
}
try {
if (isEdit.value && props.editServer) {
await store.updateServer(props.editServer.id, { ...form.value })
msg.success('服务器配置已更新')
await store.updateServer(props.editServer.id, { ...form.value });
msg.success('服务器配置已更新');
} else {
await store.addServer({ ...form.value })
msg.success('服务器已添加')
await store.addServer({ ...form.value });
msg.success('服务器已添加');
}
emit('saved')
emit('update:show', false)
emit('saved');
emit('update:show', false);
} catch (e) {
msg.error('操作失败: ' + (e.message || '未知错误'))
msg.error('操作失败: ' + (e.message || '未知错误'));
}
}
</script>
<template>
<n-modal
:show="show"
:mask-closable="false"
preset="card"
:show="show"
:title="isEdit ? '编辑 MQTT 服务器' : '添加 MQTT 服务器'"
preset="card"
style="width: 520px; max-width: 90vw"
@update:show="$emit('update:show', $event)"
>
<n-form ref="formRef" :model="form" :rules="rules" label-placement="top" size="medium">
<n-grid :cols="2" :x-gap="12">
<n-form-item-gi label="服务器名称" path="name">
<n-input v-model:value="form.name" placeholder="例如: 本地开发服务器" />
<n-form
ref="formRef"
:model="form"
:rules="rules"
label-placement="top"
size="medium"
>
<n-grid
:cols="2"
:x-gap="12"
>
<n-form-item-gi
label="服务器名称"
path="name"
>
<n-input
v-model:value="form.name"
placeholder="例如: 本地开发服务器"
/>
</n-form-item-gi>
<n-form-item-gi label="主机地址" path="host">
<n-input v-model:value="form.host" placeholder="例如: broker.emqx.io" />
<n-form-item-gi
label="主机地址"
path="host"
>
<n-input
v-model:value="form.host"
placeholder="例如: broker.emqx.io"
/>
</n-form-item-gi>
<n-form-item-gi label="端口" path="port">
<n-input-number v-model:value="form.port" :min="1" :max="65535" style="width: 100%" />
<n-form-item-gi
label="端口"
path="port"
>
<n-input-number
v-model:value="form.port"
:min="1"
:max="65535"
style="width: 100%"
/>
</n-form-item-gi>
<n-form-item-gi label="协议">
<n-select v-model:value="form.protocol" :options="protocolOptions" />
<n-select
v-model:value="form.protocol"
:options="protocolOptions"
/>
</n-form-item-gi>
<n-form-item-gi label="客户端 ID">
<n-input v-model:value="form.clientId" placeholder="自动生成" />
<n-input
v-model:value="form.clientId"
placeholder="自动生成"
/>
</n-form-item-gi>
<n-form-item-gi label="Keep Alive (秒)">
<n-input-number
@@ -146,20 +195,66 @@ async function handleSave() {
/>
</n-form-item-gi>
<n-form-item-gi label="用户名">
<n-input v-model:value="form.username" placeholder="可选" />
<n-input
v-model:value="form.username"
placeholder="可选"
/>
</n-form-item-gi>
<n-form-item-gi label="密码">
<n-input v-model:value="form.password" type="password" placeholder="可选" />
<n-input
v-model:value="form.password"
type="password"
show-password-on="click"
placeholder="可选"
/>
</n-form-item-gi>
</n-grid>
<n-form-item label="Clean Session">
<n-grid
:cols="2"
:x-gap="12"
>
<n-form-item-gi label="Clean Session">
<n-switch v-model:value="form.cleanSession" />
</n-form-item>
</n-form-item-gi>
<n-form-item-gi label="自动重连">
<n-switch v-model:value="form.reconnect" />
</n-form-item-gi>
<n-form-item-gi
label="连接超时 (毫秒)"
path="connectTimeout"
>
<n-input-number
v-model:value="form.connectTimeout"
:min="1000"
:max="120000"
:step="1000"
style="width: 100%"
/>
</n-form-item-gi>
<n-form-item-gi
label="重连间隔 (毫秒)"
path="reconnectInterval"
>
<n-input-number
v-model:value="form.reconnectInterval"
:min="1000"
:max="300000"
:step="1000"
:disabled="!form.reconnect"
style="width: 100%"
/>
</n-form-item-gi>
</n-grid>
</n-form>
<template #footer>
<div style="display: flex; justify-content: flex-end; gap: 8px">
<n-button @click="$emit('update:show', false)">取消</n-button>
<n-button type="primary" @click="handleSave">{{ isEdit ? '更新' : '保存' }}</n-button>
<n-button
@click="$emit('update:show', false)"
>取消</n-button>
<n-button
type="primary"
@click="handleSave"
>{{ isEdit ? '更新' : '保存' }}</n-button>
</div>
</template>
</n-modal>
+176 -72
View File
@@ -1,25 +1,44 @@
<script setup>
import { NLayoutSider, NButton, NTag, useMessage, useDialog } from 'naive-ui'
import { useMqttStore } from '../stores/mqtt.js'
import { ref } from 'vue';
import { NLayoutSider, NButton, NTag, useMessage, useDialog } from 'naive-ui';
import { useMqttStore } from '../stores/mqtt.js';
const emit = defineEmits(['add-server', 'edit-server'])
const store = useMqttStore()
const message = useMessage()
const dialog = useDialog()
//
const emit = defineEmits(['add-server', 'edit-server']);
// / API
const store = useMqttStore();
const message = useMessage();
const dialog = useDialog();
//
const fileInput = ref(null);
/**
* 切换服务器连接状态
* @param {import('@types/jsdoc').MqttServer} server - 服务器对象
*/
async function handleConnect(server) {
if (server.status === 'connected') {
await store.disconnectServer(server.id)
message.info('已断开连接: ' + server.name)
await store.disconnectServer(server.id);
message.info('已断开连接: ' + server.name);
} else {
await store.connectServer(server.id)
await store.connectServer(server.id);
}
}
/**
* 编辑服务器
* @param {import('@types/jsdoc').MqttServer} server - 服务器对象
*/
function handleEdit(server) {
emit('edit-server', server)
emit('edit-server', server);
}
/**
* 删除服务器弹出确认对话框
* @param {import('@types/jsdoc').MqttServer} server - 服务器对象
*/
function handleDelete(server) {
dialog.warning({
title: '确认删除',
@@ -27,10 +46,54 @@ function handleDelete(server) {
positiveText: '删除',
negativeText: '取消',
onPositiveClick: async () => {
await store.deleteServer(server.id)
message.info('服务器已删除')
await store.deleteServer(server.id);
message.info('服务器已删除');
},
});
}
/**
* 导出服务器配置到本地 JSON 文件
*/
async function handleExport() {
try {
let data = await store.exportServers();
let blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
let url = URL.createObjectURL(blob);
let a = document.createElement('a');
a.href = url;
a.download = `frost-mqtt-servers-${new Date().toISOString().slice(0, 10)}.json`;
a.click();
URL.revokeObjectURL(url);
message.success('服务器配置已导出');
} catch (e) {
message.error('导出失败: ' + (e.message || '未知错误'));
}
})
}
/**
* 触发文件导入点击
*/
function handleImportClick() {
fileInput.value?.click();
}
/**
* 处理导入的 JSON 配置文件
* @param {Event} event - 文件选择事件
*/
async function handleImportFile(event) {
let file = event.target.files?.[0];
if (!file) return;
try {
let text = await file.text();
let data = JSON.parse(text);
await store.importServers(data);
message.success('服务器配置已导入');
} catch (e) {
message.error('导入失败: ' + (e.message || '未知错误'));
}
event.target.value = '';
}
</script>
@@ -43,17 +106,23 @@ function handleDelete(server) {
show-trigger="bar"
:native-scrollbar="true"
content-class="sidebar-scroll-container"
:content-style="{ height: '100%', overflow: 'hidden' }"
:content-style="{
height: '100%',
overflow: 'hidden',
}"
>
<div class="sidebar-container">
<!-- 侧边栏头部 Logo 与标题 -->
<div class="sidebar-header">
<div class="sidebar-logo">F</div>
<div>
<div class="sidebar-title">Frost MQTT Client</div>
<div class="sidebar-subtitle">服务器 MQTT 客户端</div>
<div class="sidebar-subtitle">功能 MQTT 客户端</div>
</div>
</div>
<!-- 服务器列表 -->
<div class="server-list">
<div
v-for="srv in store.servers.value"
@@ -65,12 +134,21 @@ function handleDelete(server) {
<div class="server-item-top">
<span class="server-name">{{ srv.name }}</span>
<div class="server-actions">
<n-button text size="tiny" @click.stop="handleEdit(srv)">
<n-button
text
size="tiny"
@click.stop="handleEdit(srv)"
>
<template #icon>
<span style="font-size: 14px">&#9998;</span>
</template>
</n-button>
<n-button text size="tiny" type="error" @click.stop="handleDelete(srv)">
<n-button
text
size="tiny"
type="error"
@click.stop="handleDelete(srv)"
>
<template #icon>
<span style="font-size: 14px">&#10005;</span>
</template>
@@ -81,60 +159,64 @@ function handleDelete(server) {
<n-tag
:bordered="false"
size="small"
:type="
srv.status === 'connected'
? 'success'
: srv.status === 'connecting'
? 'warning'
: 'default'
"
:type="srv.status === 'connected' ? 'success' : srv.status === 'connecting' ? 'warning' : 'default'"
>
<template #icon>
<span class="status-dot" :class="srv.status" />
<span
class="status-dot"
:class="srv.status"
/>
</template>
{{ srv.protocol }}://{{ srv.host }}:{{ srv.port }}
</n-tag>
</div>
<div class="server-bottom">
<span class="server-status-text">
{{
srv.status === 'connected'
? '已连接'
: srv.status === 'connecting'
? '连接中...'
: srv.status === 'error'
? '连接失败'
: '未连接'
}}
</span>
<span
class="server-status-text"
>{{ srv.status === 'connected' ? '已连接' : srv.status === 'connecting' ? '连接中...' : srv.status === 'error' ? '连接失败' : '未连接' }}</span>
<n-button
size="tiny"
:type="
srv.status === 'connected'
? 'error'
: srv.status === 'connecting'
? 'warning'
: 'success'
"
:type="srv.status === 'connected' ? 'error' : srv.status === 'connecting' ? 'warning' : 'success'"
:disabled="srv.status === 'connecting'"
@click.stop="handleConnect(srv)"
>
{{
srv.status === 'connected' ? '断开' : srv.status === 'connecting' ? '...' : '连接'
}}
</n-button>
>{{ srv.status === 'connected' ? '断开' : srv.status === 'connecting' ? '...' : '连接' }}</n-button>
</div>
</div>
</div>
<!-- 侧边栏底部操作按钮 -->
<div class="sidebar-footer">
<n-button type="primary" block @click="$emit('add-server')"> + 添加服务器 </n-button>
<n-button
type="primary"
block
@click="$emit('add-server')"
> + 添加服务器</n-button>
<div class="sidebar-import-export">
<n-button
size="small"
block
@click="handleImportClick"
>导入配置</n-button>
<n-button
size="small"
block
@click="handleExport"
>导出配置</n-button>
</div>
<input
ref="fileInput"
type="file"
accept=".json,application/json"
style="display: none"
@change="handleImportFile"
>
</div>
</div>
</n-layout-sider>
</template>
<style scoped>
<style scoped lang="less">
:deep(.sidebar-scroll-container) {
height: 100%;
overflow: hidden !important;
@@ -160,7 +242,7 @@ function handleDelete(server) {
.sidebar-logo {
width: 32px;
height: 32px;
background: #18a058;
background: var(--frost-primary-color);
border-radius: 6px;
display: flex;
align-items: center;
@@ -196,15 +278,19 @@ function handleDelete(server) {
margin-bottom: 4px;
border: 1px solid transparent;
transition: all 0.15s;
}
.server-item:hover {
&:hover {
background: var(--n-color-hover);
}
.server-item.active {
.server-actions {
opacity: 1;
}
}
&.active {
background: var(--n-color-pressed);
border-color: #18a058;
border-color: var(--frost-primary-color);
}
}
.server-item-top {
@@ -228,12 +314,19 @@ function handleDelete(server) {
transition: opacity 0.15s;
}
.server-item:hover .server-actions {
opacity: 1;
}
.server-url {
margin-top: 4px;
overflow: hidden;
:deep(.n-tag) {
max-width: 100%;
}
:deep(.n-tag__content) {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.server-bottom {
@@ -254,20 +347,23 @@ function handleDelete(server) {
height: 6px;
border-radius: 50%;
margin-right: 2px;
}
.status-dot.connected {
background: #18a058;
}
.status-dot.connecting {
background: #f0a020;
&.connected {
background: var(--frost-success-color);
}
&.connecting {
background: var(--frost-warning-color);
animation: pulse 1s infinite;
}
.status-dot.disconnected {
background: #999;
}
.status-dot.error {
background: #d03050;
}
&.disconnected {
background: var(--frost-color-text-tertiary);
}
&.error {
background: var(--frost-error-color);
}
}
@keyframes pulse {
@@ -275,6 +371,7 @@ function handleDelete(server) {
100% {
opacity: 1;
}
50% {
opacity: 0.3;
}
@@ -285,4 +382,11 @@ function handleDelete(server) {
border-top: 1px solid var(--n-border-color);
flex-shrink: 0;
}
.sidebar-import-export {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: 8px;
}
</style>
@@ -1,13 +0,0 @@
<script setup>
import { reactive } from 'vue'
const versions = reactive({ ...window.electron.process.versions })
</script>
<template>
<ul class="versions">
<li class="electron-version">Electron v{{ versions.electron }}</li>
<li class="chrome-version">Chromium v{{ versions.chrome }}</li>
<li class="node-version">Node v{{ versions.node }}</li>
</ul>
</template>
+9 -4
View File
@@ -1,5 +1,10 @@
import { createApp } from 'vue'
import App from './App.vue'
import { createApp } from 'vue';
const app = createApp(App)
app.mount('#app')
import App from './App.vue';
/**
* 创建并挂载 Vue 应用
*/
const app = createApp(App);
app.mount('#app');
+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() {
// 清理旧监听器
cleanupFns.forEach((fn) => fn())
cleanupFns = []
/**
* @typedef {Object} StatusChangeData 服务器状态变更事件数据
* @property {string} id 服务器 id
* @property {MqttServerStatus} status 新状态
* @property {string} [error] 错误信息状态为 error 时存在
*/
cleanupFns.push(
window.api.onStatusChange((data) => {
const srv = servers.value.find((s) => s.id === data.id)
if (srv) {
srv.status = data.status
if (data.status === 'disconnected') {
srv.topics.forEach((t) => {
t.subscribed = false
})
}
}
})
)
/**
* @typedef {Object} MessageEventData 消息接收事件数据
* @property {string} serverId 所属服务器 id
* @property {MqttMessage} message 消息对象
*/
cleanupFns.push(
window.api.onMessage((data) => {
if (data.serverId === activeServerId.value) {
messages.value.unshift(data.message)
if (messages.value.length > 500) messages.value.pop()
}
})
)
/**
* @typedef {Object} SubscriptionChangeData 订阅状态变更事件数据
* @property {string} serverId 所属服务器 id
* @property {string} topic 主题名称
* @property {string} [topicId] 主题 id
* @property {string} [comment] 主题备注
* @property {boolean} subscribed 是否已订阅
*/
cleanupFns.push(
window.api.onSubscriptionChange((data) => {
if (data.serverId === activeServerId.value) {
const srv = servers.value.find((s) => s.id === data.serverId)
if (srv) {
const t = srv.topics.find((t) => t.topic === data.topic || t.id === data.topicId)
if (t) {
t.subscribed = data.subscribed
if (data.topic && data.topic !== t.topic) {
t.topic = data.topic
/**
* @typedef {Object} MqttStore
* @property {import('vue').Ref<MqttServer[]>} servers 所有服务器列表
* @property {import('vue').Ref<string | null>} activeServerId 当前活动服务器 ID
* @property {import('vue').ComputedRef<MqttServer | null>} activeServer 当前活动服务器对象
* @property {import('vue').Ref<MqttMessage[]>} messages 当前活动服务器消息列表
* @property {import('vue').Ref<PublishRecord[]>} publishHistory 当前活动服务器发布历史
* @property {import('vue').Ref<boolean>} loading 全局加载状态
* @property {import('vue').ComputedRef<MqttServer[]>} connectedServers 已连接服务器列表
* @property {import('vue').ComputedRef<number>} totalTopics 所有服务器主题总数
* @property {import('vue').ComputedRef<number>} subscribedTopics 已订阅主题总数
* @property {import('vue').ComputedRef<number>} totalMessages 当前活动服务器消息总数
* @property {() => Promise<void>} init 初始化 Store
* @property {(id: string | null) => void} setActiveServer 设置当前活动服务器
* @property {(config: ServerConfig) => Promise<MqttServer>} addServer 添加服务器
* @property {(id: string, config: ServerConfig) => Promise<MqttServer | null>} updateServer 更新服务器
* @property {(id: string) => Promise<void>} deleteServer 删除服务器
* @property {() => Promise<ServerConfig[]>} exportServers 导出服务器配置
* @property {(serverList: ServerConfig[]) => Promise<MqttServer[]>} importServers 导入服务器配置
* @property {(id: string) => Promise<boolean>} connectServer 连接服务器
* @property {(id: string) => Promise<void>} disconnectServer 断开服务器
* @property {(serverId: string, topic: string, qos: MqttQoS, comment: string) => Promise<MqttTopic | null>} addTopic 添加主题
* @property {(serverId: string, topic: string) => Promise<void>} removeTopic 移除主题
* @property {(serverId: string, topic: string) => Promise<boolean>} subscribeTopic 订阅主题
* @property {(serverId: string, topic: string) => Promise<boolean>} unsubscribeTopic 取消订阅主题
* @property {(serverId: string) => Promise<boolean>} subscribeAllTopics 批量订阅所有主题
* @property {(serverId: string, topicId: string, topic: string, qos: MqttQoS, comment: string) => Promise<boolean>} updateTopic 更新主题
* @property {(serverId: string, topic: string, payload: string, opts: PublishOptions) => Promise<boolean>} publishMessage 发布消息
* @property {() => Promise<void>} refreshMessages 刷新消息列表
* @property {() => Promise<void>} refreshPublishHistory 刷新发布历史
* @property {() => Promise<void>} clearMessages 清空消息列表
* @property {() => Promise<void>} clearPublishHistory 清空发布历史
*/
// ==================== 全局状态(单例) ====================
/**
* 所有已配置的服务器列表按名称排序
* @type {import('vue').Ref<MqttServer[]>}
*/
const servers = ref([]);
/**
* 当前在界面中选中的服务器 ID
* @type {import('vue').Ref<string | null>}
*/
const activeServerId = ref(null);
/**
* 当前活动服务器的消息列表包含订阅接收与本地发布的消息
* @type {import('vue').Ref<MqttMessage[]>}
*/
const messages = ref([]);
/**
* 当前活动服务器的发布历史记录
* @type {import('vue').Ref<PublishRecord[]>}
*/
const publishHistory = ref([]);
/**
* Store 初始化或异步操作时的全局加载状态
* @type {import('vue').Ref<boolean>}
*/
const loading = ref(false);
/**
* IPC 监听器清理函数集合
* @type {Array<() => void>}
*/
let cleanupFns = [];
/**
* 对指定服务器的主题列表按主题名称升序排序
* @param {MqttServer} server - 服务器对象
* @returns {void}
*/
function sortTopics(server) {
if (server && server.topics) {
server.topics.sort((a, b) => a.topic.localeCompare(b.topic));
}
}
}
}
})
)
}
/**
* 设置主进程事件监听器
* 包括状态变化消息接收订阅变化等事件
*/
function setupListeners() {
// 清理旧监听器
cleanupFns.forEach((fn) => fn());
cleanupFns = [];
cleanupFns.push(
window.api.onStatusChange((/** @type {StatusChangeData} */ data) => {
let srv = servers.value.find((s) => {
return s.id === data.id;
});
if (srv) {
srv.status = data.status;
if (data.status === 'disconnected') {
srv.topics.forEach((t) => {
t.subscribed = false;
});
}
}
})
);
cleanupFns.push(
window.api.onMessage((/** @type {MessageEventData} */ data) => {
if (data.serverId === activeServerId.value) {
messages.value.unshift(data.message);
if (messages.value.length > 500) {
messages.value.pop();
}
}
})
);
cleanupFns.push(
window.api.onSubscriptionChange((/** @type {SubscriptionChangeData} */ data) => {
if (data.serverId === activeServerId.value) {
let srv = servers.value.find((s) => {
return s.id === data.serverId;
});
if (srv) {
let t = srv.topics.find((t) => {
return t.topic === data.topic || t.id === data.topicId;
});
if (t) {
t.subscribed = data.subscribed;
if (typeof data.comment === 'string') {
t.comment = data.comment;
}
if (data.topic && data.topic !== t.topic) {
t.topic = data.topic;
sortTopics(srv);
}
}
}
}
})
);
}
/**
* MQTT 全局状态管理 Store
* @returns {MqttStore} 包含状态计算属性和方法的对象
*/
export function useMqttStore() {
const activeServer = computed(
/**
* 当前选中的服务器
* @type {import('vue').ComputedRef<MqttServer | null>}
*/
let activeServer = computed(
() => servers.value.find((s) => s.id === activeServerId.value) || null
)
);
const connectedServers = computed(() => servers.value.filter((s) => s.status === 'connected'))
/**
* 已连接的服务器列表
* @type {import('vue').ComputedRef<MqttServer[]>}
*/
let connectedServers = computed(() => servers.value.filter((s) => s.status === 'connected'));
const totalTopics = computed(() =>
/**
* 所有服务器主题总数
* @type {import('vue').ComputedRef<number>}
*/
let totalTopics = computed(() =>
servers.value.reduce((sum, s) => sum + (s.topics?.length || 0), 0)
)
);
const subscribedTopics = computed(() =>
servers.value.reduce((sum, s) => sum + (s.topics?.filter((t) => t.subscribed)?.length || 0), 0)
/**
* 已订阅主题总数
* @type {import('vue').ComputedRef<number>}
*/
let subscribedTopics = computed(() =>
servers.value.reduce(
(sum, s) => sum + (s.topics?.filter((t) => t.subscribed)?.length || 0),
0
)
);
const totalMessages = computed(() => messages.value.length)
/**
* 当前活动服务器消息总数
* @type {import('vue').ComputedRef<number>}
*/
let totalMessages = computed(() => messages.value.length);
/**
* 按服务器名称对服务器列表排序中文环境
* @returns {void}
*/
function sortServers() {
servers.value.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'));
}
// ==================== 初始化 ====================
/**
* 初始化 Store从主进程加载服务器列表并设置监听
* @returns {Promise<void>}
*/
async function init() {
loading.value = true
loading.value = true;
try {
const data = await window.api.getServers()
servers.value = data
if (data.length > 0 && !activeServerId.value) {
activeServerId.value = data[0].id
}
setupListeners()
let data = await window.api.getServers();
servers.value = data;
sortServers();
servers.value.forEach((srv) => sortTopics(srv));
setupListeners();
} catch (e) {
console.error('初始化失败:', e)
console.error('初始化失败:', e);
} finally {
loading.value = false
loading.value = false;
}
}
// ==================== 服务器管理 ====================
/**
* 设置当前活动服务器并刷新相关数据
* @param {string|null} id - 服务器 id
*/
function setActiveServer(id) {
activeServerId.value = id
activeServerId.value = id;
// 切换时刷新消息
refreshMessages()
refreshPublishHistory()
refreshMessages();
refreshPublishHistory();
}
/**
* 添加服务器
* @param {ServerConfig} config - 服务器配置
* @returns {Promise<MqttServer>} 添加后的服务器对象
*/
async function addServer(config) {
const server = await window.api.addServer(config)
servers.value.push(server)
let server = await window.api.addServer(config);
servers.value.push(server);
sortServers();
if (!activeServerId.value) {
activeServerId.value = server.id
}
return server
activeServerId.value = server.id;
}
return server;
}
/**
* 更新服务器配置
* @param {string} id - 服务器 id
* @param {ServerConfig} config - 新的服务器配置
* @returns {Promise<MqttServer | null>} 更新后的服务器对象或 null
*/
async function updateServer(id, config) {
const result = await window.api.updateServer(id, config)
let result = await window.api.updateServer(id, config);
if (result) {
const idx = servers.value.findIndex((s) => s.id === id)
let idx = servers.value.findIndex((s) => s.id === id);
if (idx !== -1) {
servers.value[idx] = { ...servers.value[idx], ...result }
servers.value[idx] = { ...servers.value[idx], ...result };
}
}
return result
sortServers();
}
return result;
}
/**
* 删除服务器
* @param {string} id - 服务器 id
* @returns {Promise<void>}
*/
async function deleteServer(id) {
await window.api.deleteServer(id)
servers.value = servers.value.filter((s) => s.id !== id)
await window.api.deleteServer(id);
servers.value = servers.value.filter((s) => s.id !== id);
sortServers();
if (activeServerId.value === id) {
activeServerId.value = servers.value.length > 0 ? servers.value[0].id : null
}
activeServerId.value = servers.value.length > 0 ? servers.value[0].id : null;
}
}
/**
* 导出所有服务器配置
* @returns {Promise<ServerConfig[]>} 服务器配置数组
*/
async function exportServers() {
return await window.api.exportServers();
}
/**
* 导入服务器配置列表
* @param {ServerConfig[]} serverList - 服务器配置数组
* @returns {Promise<MqttServer[]>} 导入后的服务器列表
*/
async function importServers(serverList) {
let data = await window.api.importServers(serverList);
servers.value = data;
sortServers();
servers.value.forEach((srv) => sortTopics(srv));
if (servers.value.length > 0 && !activeServerId.value) {
activeServerId.value = servers.value[0].id;
}
return data;
}
/**
* 连接指定服务器
* @param {string} id - 服务器 id
* @returns {Promise<boolean>} 是否成功发起连接
*/
async function connectServer(id) {
return await window.api.connect(id)
return await window.api.connect(id);
}
/**
* 断开指定服务器
* @param {string} id - 服务器 id
* @returns {Promise<void>}
*/
async function disconnectServer(id) {
await window.api.disconnect(id)
await window.api.disconnect(id);
}
// ==================== 主题管理 ====================
async function addTopic(serverId, topic, qos) {
const result = await window.api.addTopic(serverId, topic, qos)
/**
* 为指定服务器添加主题
* @param {string} serverId - 服务器 id
* @param {string} topic - 主题名称
* @param {MqttQoS} qos - QoS 等级
* @param {string} comment - 主题备注
* @returns {Promise<MqttTopic | null>} 添加后的主题对象或 null
*/
async function addTopic(serverId, topic, qos, comment) {
let result = await window.api.addTopic(serverId, topic, qos, comment);
if (result) {
const srv = servers.value.find((s) => s.id === serverId)
let srv = servers.value.find((s) => s.id === serverId);
if (srv) {
srv.topics.push({ topic, qos, subscribed: false })
srv.topics.push(result);
sortTopics(srv);
}
}
return result
}
return result;
}
/**
* 移除指定服务器的主题
* @param {string} serverId - 服务器 id
* @param {string} topic - 主题名称
* @returns {Promise<void>}
*/
async function removeTopic(serverId, topic) {
await window.api.removeTopic(serverId, topic)
const srv = servers.value.find((s) => s.id === serverId)
await window.api.removeTopic(serverId, topic);
let srv = servers.value.find((s) => s.id === serverId);
if (srv) {
srv.topics = srv.topics.filter((t) => t.topic !== topic)
}
srv.topics = srv.topics.filter((t) => t.topic !== topic);
sortTopics(srv);
}
}
/**
* 订阅指定主题
* @param {string} serverId - 服务器 id
* @param {string} topic - 主题名称
* @returns {Promise<boolean>} 是否发起订阅
*/
async function subscribeTopic(serverId, topic) {
return await window.api.subscribe(serverId, topic)
return await window.api.subscribe(serverId, topic);
}
/**
* 取消订阅指定主题
* @param {string} serverId - 服务器 id
* @param {string} topic - 主题名称
* @returns {Promise<boolean>} 是否发起取消订阅
*/
async function unsubscribeTopic(serverId, topic) {
return await window.api.unsubscribe(serverId, topic)
return await window.api.unsubscribe(serverId, topic);
}
/**
* 批量订阅指定服务器下所有未订阅主题
* @param {string} serverId - 服务器 id
* @returns {Promise<boolean>} 是否发起批量订阅
*/
async function subscribeAllTopics(serverId) {
return await window.api.subscribeAll(serverId)
return await window.api.subscribeAll(serverId);
}
async function updateTopic(serverId, topicId, topic, qos) {
return await window.api.updateTopic(serverId, topicId, topic, qos)
/**
* 更新指定服务器的主题信息
* @param {string} serverId - 服务器 id
* @param {string} topicId - 主题 id
* @param {string} topic - 新的主题名称
* @param {MqttQoS} qos - 新的 QoS 等级
* @param {string} comment - 新的主题备注
* @returns {Promise<boolean>} 是否更新成功
*/
async function updateTopic(serverId, topicId, topic, qos, comment) {
return await window.api.updateTopic(serverId, topicId, topic, qos, comment);
}
// ==================== 消息发布 ====================
/**
* 发布消息到指定主题
* @param {string} serverId - 服务器 id
* @param {string} topic - 目标主题
* @param {string} payload - 消息内容
* @param {PublishOptions} opts - 发布选项
* @returns {Promise<boolean>} 是否发起发布
*/
async function publishMessage(serverId, topic, payload, opts) {
const result = await window.api.publish(serverId, topic, payload, opts)
let result = await window.api.publish(serverId, topic, payload, opts);
if (result && serverId === activeServerId.value) {
refreshPublishHistory()
refreshPublishHistory();
}
return result
return result;
}
// ==================== 消息管理 ====================
/**
* 刷新当前活动服务器的消息列表
* @returns {Promise<void>}
*/
async function refreshMessages() {
if (!activeServerId.value) return
messages.value = await window.api.getMessages(activeServerId.value)
if (activeServerId.value) {
messages.value = await window.api.getMessages(activeServerId.value);
}
}
/**
* 刷新当前活动服务器的发布历史
* @returns {Promise<void>}
*/
async function refreshPublishHistory() {
if (!activeServerId.value) return
publishHistory.value = await window.api.getPublishHistory(activeServerId.value)
if (activeServerId.value) {
publishHistory.value = await window.api.getPublishHistory(activeServerId.value);
}
}
/**
* 清空当前活动服务器的消息列表
* @returns {Promise<void>}
*/
async function clearMessages() {
if (!activeServerId.value) return
await window.api.clearMessages(activeServerId.value)
messages.value = []
if (activeServerId.value) {
await window.api.clearMessages(activeServerId.value);
messages.value = [];
}
}
/**
* 清空当前活动服务器的发布历史
* @returns {Promise<void>}
*/
async function clearPublishHistory() {
if (!activeServerId.value) return
await window.api.clearPublishHistory(activeServerId.value)
publishHistory.value = []
if (activeServerId.value) {
await window.api.clearPublishHistory(activeServerId.value);
publishHistory.value = [];
}
}
return {
@@ -221,6 +569,8 @@ export function useMqttStore() {
addServer,
updateServer,
deleteServer,
exportServers,
importServers,
connectServer,
disconnectServer,
addTopic,
@@ -233,6 +583,7 @@ export function useMqttStore() {
refreshMessages,
refreshPublishHistory,
clearMessages,
clearPublishHistory
}
clearPublishHistory,
};
}
+135 -105
View File
@@ -1,60 +1,78 @@
<script setup>
import { useMqttStore } from '../stores/mqtt.js'
import {
NGrid,
NGridItem,
NCard,
NStatistic,
NTag,
NDescriptions,
NDescriptionsItem,
NSpace,
NButton,
NList,
NListItem
} from 'naive-ui'
import { useMessage } from 'naive-ui'
import { useMqttStore } from '../stores/mqtt.js';
import { NGrid, NGridItem, NCard, NStatistic, NTag, NDescriptions, NDescriptionsItem, NSpace, NButton, NList, NListItem, useMessage } from 'naive-ui';
const store = useMqttStore()
const msg = useMessage()
// API
const store = useMqttStore();
const msg = useMessage();
//
const emit = defineEmits(['switch-tab', 'edit-server']);
/**
* 处理快捷操作切换标签页并选中服务器
* @param {import('@types/jsdoc').MqttServer} srv - 服务器对象
* @param {string} action - 目标标签页名称
*/
function handleAction(srv, action) {
if (srv.id === store.activeServerId.value) {
emit('switch-tab', action)
emit('switch-tab', action);
} else {
store.setActiveServer(srv.id)
emit('switch-tab', action)
store.setActiveServer(srv.id);
emit('switch-tab', action);
}
}
/**
* 切换当前服务器连接状态
* @param {import('@types/jsdoc').MqttServer} srv - 服务器对象
*/
async function handleToggleConnection(srv) {
if (srv.status === 'connected') {
await store.disconnectServer(srv.id)
msg.info('已断开连接: ' + srv.name)
await store.disconnectServer(srv.id);
msg.info('已断开连接: ' + srv.name);
} else if (srv.status === 'disconnected' || srv.status === 'error') {
await store.connectServer(srv.id)
await store.connectServer(srv.id);
}
}
const emit = defineEmits(['switch-tab', 'edit-server'])
</script>
<template>
<div class="dashboard">
<!-- 统计卡片 -->
<n-grid :cols="4" :x-gap="12" responsive="screen">
<n-grid
:cols="4"
:x-gap="12"
responsive="screen"
>
<n-grid-item>
<n-card size="small" :bordered="true">
<n-statistic label="服务器总数" :value="store.servers.value.length" />
<n-card
size="small"
:bordered="true"
>
<n-statistic
label="服务器总数"
:value="store.servers.value.length"
/>
</n-card>
</n-grid-item>
<n-grid-item>
<n-card size="small" :bordered="true">
<n-statistic label="已连接" :value="store.connectedServers.value.length" />
<n-card
size="small"
:bordered="true"
>
<n-statistic
label="已连接"
:value="store.connectedServers.value.length"
/>
</n-card>
</n-grid-item>
<n-grid-item>
<n-card size="small" :bordered="true">
<n-card
size="small"
:bordered="true"
>
<n-statistic
label="已订阅主题"
:value="`${store.subscribedTopics.value}/${store.totalTopics.value}`"
@@ -62,8 +80,14 @@ const emit = defineEmits(['switch-tab', 'edit-server'])
</n-card>
</n-grid-item>
<n-grid-item>
<n-card size="small" :bordered="true">
<n-statistic label="消息总数" :value="store.messages.value.length" />
<n-card
size="small"
:bordered="true"
>
<n-statistic
label="消息总数"
:value="store.messages.value.length"
/>
</n-card>
</n-grid-item>
</n-grid>
@@ -77,29 +101,18 @@ const emit = defineEmits(['switch-tab', 'edit-server'])
>
<template #header-extra>
<n-tag
:type="
store.activeServer.value.status === 'connected'
? 'success'
: store.activeServer.value.status === 'connecting'
? 'warning'
: 'error'
"
:type="store.activeServer.value.status === 'connected' ? 'success' : store.activeServer.value.status === 'connecting' ? 'warning' : 'error'"
size="small"
>
{{
store.activeServer.value.status === 'connected'
? '已连接'
: store.activeServer.value.status === 'connecting'
? '连接中...'
: '未连接'
}}
</n-tag>
>{{ store.activeServer.value.status === 'connected' ? '已连接' : store.activeServer.value.status === 'connecting' ? '连接中...' : '未连接' }}</n-tag>
</template>
<n-descriptions :column="2" size="small" bordered>
<n-descriptions
class="server-descriptions"
:column="2"
size="small"
bordered
>
<n-descriptions-item label="地址">
{{ store.activeServer.value.protocol }}://{{ store.activeServer.value.host }}:{{
store.activeServer.value.port
}}
{{ store.activeServer.value.protocol }}://{{ store.activeServer.value.host }}:{{ store.activeServer.value.port }}
</n-descriptions-item>
<n-descriptions-item label="客户端 ID">
{{ store.activeServer.value.clientId }}
@@ -116,52 +129,50 @@ const emit = defineEmits(['switch-tab', 'edit-server'])
size="small"
:type="store.activeServer.value.status === 'connected' ? 'error' : 'success'"
@click="handleToggleConnection(store.activeServer.value)"
>
{{ store.activeServer.value.status === 'connected' ? '断开连接' : '连接服务器' }}
</n-button>
<n-button size="small" @click="$emit('edit-server', store.activeServer.value)"
>编辑配置</n-button
>
<n-button size="small" @click="handleAction(store.activeServer.value, 'subscribe')"
>管理订阅</n-button
>
<n-button size="small" @click="handleAction(store.activeServer.value, 'publish')"
>发布消息</n-button
>
<n-button size="small" @click="handleAction(store.activeServer.value, 'messages')"
>查看消息</n-button
>
>{{ store.activeServer.value.status === 'connected' ? '断开连接' : '连接服务器' }}</n-button>
<n-button
size="small"
@click="$emit('edit-server', store.activeServer.value)"
>编辑配置</n-button>
<n-button
size="small"
@click="handleAction(store.activeServer.value, 'subscribe')"
>管理订阅</n-button>
<n-button
size="small"
@click="handleAction(store.activeServer.value, 'publish')"
>发布消息</n-button>
<n-button
size="small"
@click="handleAction(store.activeServer.value, 'messages')"
>查看消息</n-button>
</n-space>
</n-card>
<!-- 所有服务器状态 -->
<n-card title="所有服务器状态" size="small" style="margin-top: 12px">
<n-card
title="所有服务器状态"
size="small"
style="margin-top: 12px"
>
<n-list>
<n-list-item v-for="srv in store.servers.value" :key="srv.id">
<n-list-item
v-for="srv in store.servers.value"
:key="srv.id"
>
<template #prefix>
<span class="status-dot" :class="srv.status" />
<span
class="status-dot"
:class="srv.status"
/>
</template>
<n-space align="center">
<span style="font-weight: 500">{{ srv.name }}</span>
<n-tag
:bordered="false"
size="small"
:type="
srv.status === 'connected'
? 'success'
: srv.status === 'connecting'
? 'warning'
: 'default'
"
>
{{
srv.status === 'connected'
? '已连接'
: srv.status === 'connecting'
? '连接中...'
: '未连接'
}}
</n-tag>
:type="srv.status === 'connected' ? 'success' : srv.status === 'connecting' ? 'warning' : 'default'"
>{{ srv.status === 'connected' ? '已连接' : srv.status === 'connecting' ? '连接中...' : '未连接' }}</n-tag>
</n-space>
<template #suffix>
<n-button
@@ -169,42 +180,60 @@ const emit = defineEmits(['switch-tab', 'edit-server'])
:type="srv.status === 'connected' ? 'error' : 'success'"
:disabled="srv.status === 'connecting'"
@click="handleToggleConnection(srv)"
>
{{
srv.status === 'connected' ? '断开' : srv.status === 'connecting' ? '...' : '连接'
}}
</n-button>
>{{ srv.status === 'connected' ? '断开' : srv.status === 'connecting' ? '...' : '连接' }}</n-button>
</template>
</n-list-item>
</n-list>
</n-card>
</div>
</template>
<style scoped>
<style scoped lang="less">
.dashboard {
max-width: 900px;
}
.server-descriptions {
:deep(.n-descriptions-table) {
table-layout: fixed;
width: 100%;
}
:deep(.n-descriptions-table-header),
:deep(.n-descriptions-table-content) {
width: 25%;
}
:deep(.n-descriptions-table-content) {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.status-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
}
.status-dot.connected {
background: #18a058;
}
.status-dot.connecting {
background: #f0a020;
&.connected {
background: var(--frost-success-color);
}
&.connecting {
background: var(--frost-warning-color);
animation: pulse 1s infinite;
}
.status-dot.disconnected {
background: #999;
}
.status-dot.error {
background: #d03050;
}
&.disconnected {
background: var(--frost-color-text-tertiary);
}
&.error {
background: var(--frost-error-color);
}
}
@keyframes pulse {
@@ -212,6 +241,7 @@ const emit = defineEmits(['switch-tab', 'edit-server'])
100% {
opacity: 1;
}
50% {
opacity: 0.3;
}
+189 -57
View File
@@ -1,108 +1,229 @@
<script setup>
import { ref, computed } from 'vue'
import { useMqttStore } from '../stores/mqtt.js'
import { NCard, NSpace, NInput, NButton, NEmpty } from 'naive-ui'
import { ref, computed } from 'vue';
import { useMqttStore } from '../stores/mqtt.js';
import { NCard, NSelect, NButton, NEmpty, useMessage } from 'naive-ui';
const store = useMqttStore()
// API
const store = useMqttStore();
const message = useMessage();
const filter = ref('')
//
const filter = ref([]);
const filteredMessages = computed(() => {
if (!filter.value.trim()) return store.messages.value
const f = filter.value.trim().toLowerCase()
return store.messages.value.filter((m) => m.topic.toLowerCase().includes(f))
})
async function handleClear() {
await store.clearMessages()
/**
* 复制主题到剪贴板
* @param {string} topic - 主题名称
*/
async function copyTopic(topic) {
try {
await navigator.clipboard.writeText(topic);
message.success('主题已复制');
} catch {
message.error('复制失败');
}
}
/**
* 可过滤的主题选项来自已配置主题和已接收消息
* @type {import('vue').ComputedRef<Array<{label: string, value: string}>>}
*/
const topicOptions = computed(() => {
let server = store.activeServer.value;
let serverTopics = server?.topics?.map((t) => t.topic) || [];
let messageTopics = store.messages.value.map((m) => m.topic);
let all = [...new Set([...serverTopics, ...messageTopics])];
all.sort((a, b) => a.localeCompare(b));
return all.map((topic) => ({ label: topic, value: topic }));
});
/**
* 判断消息主题是否匹配指定的过滤主题支持 + 单层通配符与 # 多层通配符
* @param {string} filterTopic - 过滤主题可包含 + #
* @param {string} messageTopic - 消息主题
* @returns {boolean} 是否匹配
*/
function topicMatchesFilter(filterTopic, messageTopic) {
if (filterTopic === '#') {
return true;
}
if (filterTopic.endsWith('/#')) {
let prefix = filterTopic.slice(0, -2);
return messageTopic === prefix || messageTopic.startsWith(prefix + '/');
}
let filterParts = filterTopic.split('/');
let messageParts = messageTopic.split('/');
if (filterParts.length !== messageParts.length) {
return false;
}
return filterParts.every((part, index) => part === '+' || part === messageParts[index]);
}
/**
* 根据选中主题筛选后的消息列表支持 + / #
* @type {import('vue').ComputedRef<Array<Object>>}
*/
const filteredMessages = computed(() => {
if (!filter.value || filter.value.length === 0) {
return store.messages.value;
}
return store.messages.value.filter((m) =>
filter.value.some((f) => topicMatchesFilter(f, m.topic))
);
});
/**
* 清空当前活动服务器的消息列表
*/
async function handleClear() {
await store.clearMessages();
}
/**
* 格式化消息 Payload若可解析为 JSON 则格式化输出
* @param {string} payload - 消息内容
* @returns {string} 格式化后的内容
*/
function formatPayload(payload) {
try {
return JSON.stringify(JSON.parse(payload), null, 2)
return JSON.stringify(JSON.parse(payload), null, 2);
} catch {
return payload
return payload;
}
}
</script>
<template>
<div v-if="store.activeServer.value" class="messages-view">
<!-- 内容区域 -->
<div
v-if="store.activeServer.value"
class="messages-view"
>
<n-card
size="small"
:content-style="{
display: 'flex',
flexDirection: 'column',
minHeight: 0,
overflow: 'hidden'
overflow: 'hidden',
}"
>
<template #header>
<n-space align="center" justify="space-between" style="width: 100%">
<div>
<span style="font-weight: 700">消息监控</span>
<span style="font-size: 12px; opacity: 0.55; margin-left: 8px"
>实时查看接收和发送的 MQTT 消息</span
<div
style="display: flex; flex-direction: column; gap: 8px; width: 100%"
>
<div>
<span>消息监控</span>
<span
style="font-size: 12px; opacity: 0.55; margin-left: 8px"
>实时查看接收和发送的 MQTT 消息</span>
</div>
<n-space>
<n-input
<div
style="display: flex; align-items: center; gap: 8px; width: 100%"
>
<n-select
v-model:value="filter"
size="small"
placeholder="过滤主题..."
style="width: 180px"
style="flex: 1 1 auto; max-width: 480px; min-width: 0"
multiple
filterable
clearable
max-tag-count="responsive"
:options="topicOptions"
/>
<n-button size="small" @click="handleClear">清空</n-button>
</n-space>
</n-space>
<n-button
size="small"
@click="handleClear"
>清空消息</n-button>
</div>
</div>
</template>
<n-empty v-if="filteredMessages.length === 0" description="等待消息...">
<n-empty
v-if="filteredMessages.length === 0"
description="等待消息..."
>
<template #icon>
<span style="font-size: 32px; opacity: 0.4">&#9776;</span>
</template>
</n-empty>
<div v-else ref="logRef" class="msg-log">
<div v-for="m in filteredMessages" :key="m.id" class="msg-entry">
<div
v-else
ref="logRef"
class="msg-log"
>
<div
v-for="m in filteredMessages"
:key="m.id"
class="msg-entry"
>
<div class="msg-header">
<span class="msg-time">{{ m.time }}</span>
<span class="msg-dir" :class="m.direction">{{
m.direction === 'pub' ? 'PUB' : 'SUB'
}}</span>
<span class="msg-topic" :title="m.topic">{{ m.topic }}</span>
<span class="msg-meta">QoS{{ m.qos }}{{ m.retain ? ' R' : '' }}</span>
<span
class="msg-dir"
:class="m.direction"
>{{ m.direction === 'pub' ? 'PUB' : 'SUB' }}</span>
<span
class="msg-topic"
:title="m.topic"
@click="copyTopic(m.topic)"
>{{ m.topic }}</span>
<span
class="msg-meta"
>QoS{{ m.qos }}{{ m.retain ? ' R' : '' }}</span>
</div>
<pre class="msg-payload">{{ formatPayload(m.payload) }}</pre>
<pre
class="msg-payload"
>{{ formatPayload(m.payload) }}</pre>
</div>
</div>
</n-card>
</div>
<n-empty v-else description="请先在左侧选择一个 MQTT 服务器" />
<!-- 空状态 -->
<n-empty
v-else
description="请先在左侧选择一个 MQTT 服务器"
/>
</template>
<style scoped>
<style scoped lang="less">
.messages-view {
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
}
.messages-view :deep(.n-card) {
:deep(.n-card) {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
}
.messages-view :deep(.n-card__content) {
:deep(.n-card__content) {
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
}
}
.msg-log {
background: #1e1e2e;
background: var(--frost-color-bg-light);
border-radius: 6px;
padding: 12px;
height: 100%;
@@ -113,12 +234,12 @@ function formatPayload(payload) {
font-family: monospace;
font-size: 12px;
line-height: 1.7;
color: #cdd6f4;
color: #333;
}
.msg-entry {
padding: 8px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
border-bottom: 1px solid var(--frost-shadow-black-6);
}
.msg-header {
@@ -129,40 +250,51 @@ function formatPayload(payload) {
}
.msg-time {
color: #6c7086;
color: var(--frost-color-text-tertiary);
flex-shrink: 0;
}
.msg-dir {
flex-shrink: 0;
font-weight: 700;
width: 28px;
&.pub {
color: var(--frost-info-color);
}
&.sub {
color: var(--frost-success-color);
}
}
.msg-dir.pub {
color: #89b4fa;
}
.msg-dir.sub {
color: #a6e3a1;
}
.msg-topic {
color: #f9e2af;
color: var(--frost-color-topic);
min-width: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
.msg-payload {
margin: 6px 0 0;
padding: 8px 10px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.05);
color: #cdd6f4;
background: var(--frost-shadow-black-4);
color: #333;
font: inherit;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.msg-meta {
color: #6c7086;
color: var(--frost-color-text-tertiary);
font-size: 11px;
flex-shrink: 0;
}
+251 -121
View File
@@ -1,185 +1,305 @@
<script setup>
import { ref, watch } from 'vue'
import { useMqttStore } from '../stores/mqtt.js'
import {
NCard,
NSpace,
NFormItem,
NInput,
NSelect,
NSwitch,
NButton,
NEmpty,
NTabs,
NTabPane,
useMessage
} from 'naive-ui'
import { ref, watch } from 'vue';
import { useMqttStore } from '../stores/mqtt.js';
import { NCard, NSpace, NFormItem, NInput, NSelect, NSwitch, NButton, NEmpty, NTabs, NTabPane, useMessage } from 'naive-ui';
const store = useMqttStore()
const msg = useMessage()
// API
const store = useMqttStore();
const msg = useMessage();
const topic = ref('')
const payload = ref('')
const qos = ref(1)
const retain = ref(false)
const activePublishTab = ref('publish')
const formStorageKey = 'mqtt-client.publish-form'
const publishTabStorageKey = 'mqtt-client.publish-active-tab'
//
const topic = ref('');
const payload = ref('');
const qos = ref(1);
const retain = ref(false);
let formReady = false
//
const activePublishTab = ref('publish');
// localStorage key
const formStorageKey = 'mqtt-client.publish-form';
const publishTabStorageKey = 'mqtt-client.publish-active-tab';
let formReady = false;
// localStorage
try {
const savedForm = JSON.parse(localStorage.getItem(formStorageKey) || '{}')
if (typeof savedForm.topic === 'string') topic.value = savedForm.topic
if (typeof savedForm.payload === 'string') payload.value = savedForm.payload
if ([0, 1, 2].includes(savedForm.qos)) qos.value = savedForm.qos
if (typeof savedForm.retain === 'boolean') retain.value = savedForm.retain
let savedForm = JSON.parse(localStorage.getItem(formStorageKey) || '{}');
if (typeof savedForm.topic === 'string') {
topic.value = savedForm.topic;
}
if (typeof savedForm.payload === 'string') {
payload.value = savedForm.payload;
}
if ([0, 1, 2].includes(savedForm.qos)) {
qos.value = savedForm.qos;
}
if (typeof savedForm.retain === 'boolean') {
retain.value = savedForm.retain;
}
} catch {
localStorage.removeItem(formStorageKey)
localStorage.removeItem(formStorageKey);
}
// localStorage
try {
const savedTab = localStorage.getItem(publishTabStorageKey)
if (savedTab === 'publish' || savedTab === 'history') activePublishTab.value = savedTab
let savedTab = localStorage.getItem(publishTabStorageKey);
if (savedTab === 'publish' || savedTab === 'history') {
activePublishTab.value = savedTab;
}
} catch {
localStorage.removeItem(publishTabStorageKey)
localStorage.removeItem(publishTabStorageKey);
}
//
watch([topic, payload, qos, retain], () => {
if (!formReady) return
if (!formReady) {
return;
}
localStorage.setItem(
formStorageKey,
JSON.stringify({
topic: topic.value,
payload: payload.value,
qos: qos.value,
retain: retain.value
retain: retain.value,
})
)
})
);
});
//
watch(activePublishTab, (tab) => {
localStorage.setItem(publishTabStorageKey, tab)
})
localStorage.setItem(publishTabStorageKey, tab);
});
formReady = true
formReady = true;
// QoS
const qosOptions = [
{ label: 'QoS 0 - 最多一次', value: 0 },
{ label: 'QoS 1 - 至少一次', value: 1 },
{ label: 'QoS 2 - 恰好一次', value: 2 }
]
{ label: 'QoS 2 - 恰好一次', value: 2 },
];
/**
* 发布消息到当前服务器
*/
async function handlePublish() {
const srv = store.activeServer.value
if (!srv) return
let srv = store.activeServer.value;
if (!srv) {
return;
}
if (srv.status !== 'connected') {
msg.warning('请先连接服务器')
return
msg.warning('请先连接服务器');
return;
}
if (!topic.value.trim()) {
msg.warning('请输入目标主题')
return
msg.warning('请输入目标主题');
return;
}
const result = await store.publishMessage(srv.id, topic.value.trim(), payload.value, {
let result = await store.publishMessage(srv.id, topic.value.trim(), payload.value, {
qos: qos.value,
retain: retain.value
})
retain: retain.value,
});
if (result) {
msg.success('消息已发布到: ' + topic.value)
msg.success('消息已发布到: ' + topic.value);
}
}
/**
* 格式化 Payload JSON
*/
function handleFormatJson() {
try {
const obj = JSON.parse(payload.value)
payload.value = JSON.stringify(obj, null, 2)
msg.info('JSON 已格式化')
let obj = JSON.parse(payload.value);
payload.value = JSON.stringify(obj, null, 2);
msg.info('JSON 已格式化');
} catch {
msg.warning('无效的 JSON 格式')
msg.warning('无效的 JSON 格式');
}
}
/**
* 清空发布历史
*/
async function handleClearHistory() {
await store.clearPublishHistory()
await store.clearPublishHistory();
}
/**
* 复制主题到剪贴板
* @param {string} topic - 主题名称
*/
async function copyTopic(topic) {
try {
await navigator.clipboard.writeText(topic);
msg.success('主题已复制');
} catch {
msg.error('复制失败');
}
}
</script>
<template>
<div v-if="store.activeServer.value" class="publish-view">
<n-tabs v-model:value="activePublishTab" type="line" size="small">
<n-tab-pane name="publish" tab="发布消息">
<!-- 内容区域 -->
<div
v-if="store.activeServer.value"
class="publish-view"
>
<n-tabs
v-model:value="activePublishTab"
type="line"
size="small"
>
<n-tab-pane
name="publish"
tab="发布消息"
>
<n-card
size="small"
:content-style="{
display: 'flex',
flexDirection: 'column',
minHeight: 0,
overflow: 'hidden'
overflow: 'hidden',
}"
>
<div class="publish-form">
<n-form-item class="topic-field" label="目标主题 (Topic)">
<n-input v-model:value="topic" placeholder="例如: sensor/device01/temperature" />
<n-form-item
class="topic-field"
label="目标主题 (Topic)"
>
<n-input
v-model:value="topic"
placeholder="例如: sensor/device01/temperature"
/>
</n-form-item>
<n-space align="center">
<n-form-item label="QoS">
<n-select v-model:value="qos" :options="qosOptions" style="width: 160px" />
<n-select
v-model:value="qos"
:options="qosOptions"
style="width: 160px"
/>
</n-form-item>
<n-form-item label="保留消息">
<n-switch v-model:value="retain" />
</n-form-item>
</n-space>
<n-form-item class="payload-field" label="消息内容 (Payload)">
<n-form-item
class="payload-field"
label="消息内容 (Payload)"
>
<n-input
v-model:value="payload"
type="textarea"
:resizable="false"
:rows="1"
placeholder='{"temperature": 25.6, "humidity": 68.3}'
/>
</n-form-item>
<n-space>
<n-button type="primary" @click="handlePublish">发布消息</n-button>
<n-button @click="handleFormatJson">格式化 JSON</n-button>
<n-button
type="primary"
@click="handlePublish"
>发布消息</n-button>
<n-button
@click="handleFormatJson"
>格式化 JSON</n-button>
</n-space>
</div>
</n-card>
</n-tab-pane>
<n-tab-pane name="history" tab="发布历史">
<n-tab-pane
name="history"
tab="发布历史"
>
<n-card
size="small"
:content-style="{
display: 'flex',
flexDirection: 'column',
minHeight: 0,
overflow: 'hidden'
overflow: 'hidden',
}"
>
<n-space style="margin-bottom: 12px">
<n-button size="small" @click="handleClearHistory">清空</n-button>
<n-button
size="small"
@click="handleClearHistory"
>清空历史</n-button>
</n-space>
<n-empty v-if="store.publishHistory.value.length === 0" description="暂无发布记录" />
<div v-else class="msg-log">
<div v-for="m in store.publishHistory.value" :key="m.id || m.time" class="msg-entry">
<n-empty
v-if="store.publishHistory.value.length === 0"
description="暂无发布记录"
/>
<div
v-else
class="msg-log"
>
<div
v-for="m in store.publishHistory.value"
:key="m.id || m.time"
class="msg-entry"
>
<div class="msg-header">
<span class="msg-time">{{ m.time }}</span>
<span class="msg-dir pub">PUB</span>
<span class="msg-topic" :title="m.topic">{{ m.topic }}</span>
<span class="msg-meta">QoS{{ m.qos }}{{ m.retain ? ' R' : '' }}</span>
<span
class="msg-time"
>{{ m.time }}</span>
<span
class="msg-dir pub"
>PUB</span>
<span
class="msg-topic"
:title="m.topic"
@click="copyTopic(m.topic)"
>{{ m.topic }}</span>
<span
class="msg-meta"
>QoS{{ m.qos }}{{ m.retain ? ' R' : '' }}</span>
</div>
<pre class="msg-payload">{{ m.payload }}</pre>
<pre
class="msg-payload"
>{{ m.payload }}</pre>
</div>
</div>
</n-card>
</n-tab-pane>
</n-tabs>
</div>
<n-empty v-else description="请先在左侧选择一个 MQTT 服务器" />
<!-- 空状态 -->
<n-empty
v-else
description="请先在左侧选择一个 MQTT 服务器"
/>
</template>
<style scoped>
<style scoped lang="less">
.publish-view {
display: flex;
flex-direction: column;
@@ -187,61 +307,61 @@ async function handleClearHistory() {
height: 100%;
min-width: 0;
min-height: 0;
}
.publish-view :deep(.n-tabs) {
:deep(.n-tabs) {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
}
.publish-view :deep(.n-tabs-nav) {
:deep(.n-tabs-nav) {
flex-shrink: 0;
}
}
.publish-view :deep(.n-tabs-pane-wrapper) {
:deep(.n-tabs-pane-wrapper) {
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
}
}
.publish-view :deep(.n-tabs-tab-panels),
.publish-view :deep(.n-tabs-pane-wrapper) {
:deep(.n-tabs-tab-panels),
:deep(.n-tabs-pane-wrapper) {
height: 100%;
min-height: 0;
}
}
.publish-view :deep(.n-tab-pane) {
:deep(.n-tab-pane) {
box-sizing: border-box;
height: 100%;
min-height: 0;
}
}
.publish-view :deep(.n-tab-pane > .n-card) {
:deep(.n-tab-pane > .n-card) {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
}
.publish-view :deep(.n-card__content) {
:deep(.n-card__content) {
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
}
.publish-view :deep(.n-card__content > div) {
> div {
height: 100%;
min-height: 0;
}
}
}
.topic-field {
width: 100%;
}
.topic-field :deep(.n-form-item-blank) {
:deep(.n-form-item-blank) {
width: 100%;
}
}
.publish-form {
@@ -249,34 +369,34 @@ async function handleClearHistory() {
flex-direction: column;
height: 100%;
min-height: 0;
}
.publish-form .payload-field {
.payload-field {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.publish-form .payload-field :deep(.n-form-item-blank) {
:deep(.n-form-item-blank) {
flex: 1;
min-height: 0;
}
}
.publish-form .payload-field :deep(.n-input) {
:deep(.n-input) {
height: 100%;
}
}
.publish-form .payload-field :deep(.n-input__textarea-el),
.publish-form .payload-field :deep(textarea) {
:deep(.n-input__textarea-el),
:deep(textarea) {
resize: none !important;
height: 100% !important;
min-height: 0 !important;
font-family: monospace;
}
}
}
.msg-log {
background: #1e1e2e;
background: var(--frost-color-bg-light);
border-radius: 6px;
padding: 12px;
height: 100%;
@@ -286,12 +406,12 @@ async function handleClearHistory() {
font-family: monospace;
font-size: 12px;
line-height: 1.7;
color: #cdd6f4;
color: #333;
}
.msg-entry {
padding: 8px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
border-bottom: 1px solid var(--frost-shadow-black-6);
}
.msg-header {
@@ -302,37 +422,47 @@ async function handleClearHistory() {
}
.msg-time {
color: #6c7086;
color: var(--frost-color-text-tertiary);
flex-shrink: 0;
}
.msg-dir {
flex-shrink: 0;
font-weight: 700;
width: 28px;
&.pub {
color: var(--frost-info-color);
}
}
.msg-dir.pub {
color: #89b4fa;
}
.msg-topic {
color: #f9e2af;
color: var(--frost-color-topic);
min-width: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
.msg-payload {
margin: 6px 0 0;
padding: 8px 10px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.05);
color: #cdd6f4;
background: var(--frost-shadow-black-4);
color: #333;
font: inherit;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.msg-meta {
color: #6c7086;
color: var(--frost-color-text-tertiary);
font-size: 11px;
flex-shrink: 0;
}
+896
View File
@@ -0,0 +1,896 @@
<script setup>
import { ref, computed, watch, onMounted, onUnmounted } from 'vue';
import { useMqttStore } from '../stores/mqtt.js';
import { NCard, NSpace, NFormItem, NInput, NSelect, NSwitch, NButton, NEmpty, NModal, NForm, NGrid, NGridItem, NCheckbox, useMessage, useDialog } from 'naive-ui';
// API
const store = useMqttStore();
const msg = useMessage();
const dialog = useDialog();
// localStorage key
const oldStorageKey = 'mqtt-client.quick-send-list';
/**
* @typedef {Object} QuickSendItem
* @property {string} id
* @property {string} topic
* @property {string} comment
* @property {string} payload
* @property {0|1|2} qos
* @property {boolean} retain
*/
//
/** @type {import('vue').Ref<QuickSendItem[]>} */
const quickList = ref([]);
//
const quickListLoaded = ref(false);
//
const showModal = ref(false);
const isEdit = ref(false);
//
const formId = ref('');
const formTopic = ref('');
const formComment = ref('');
const formPayload = ref('');
const formQos = ref(1);
const formRetain = ref(false);
// localStorage
function migrateFromLocalStorage() {
try {
let saved = JSON.parse(localStorage.getItem(oldStorageKey) || '[]');
if (Array.isArray(saved) && saved.length > 0) {
quickList.value = saved.filter((item) => {
return item && typeof item.id === 'string' && typeof item.topic === 'string';
});
window.api.saveQuickSendList(JSON.parse(JSON.stringify(quickList.value)));
}
} catch {
//
} finally {
localStorage.removeItem(oldStorageKey);
}
}
//
watch(
quickList,
() => {
if (!quickListLoaded.value) {
return;
}
window.api.saveQuickSendList(JSON.parse(JSON.stringify(quickList.value))).catch((e) => {
console.error('保存快速发送列表失败:', e);
});
},
{ deep: true }
);
// QoS
const qosOptions = [
{ label: 'QoS 0 - 最多一次', value: 0 },
{ label: 'QoS 1 - 至少一次', value: 1 },
{ label: 'QoS 2 - 恰好一次', value: 2 },
];
//
const hasActiveServer = computed(() => Boolean(store.activeServer.value));
//
/** @type {import('vue').Ref<number>} */
const dragIndex = ref(-1);
/** @type {import('vue').Ref<QuickSendItem | null>} */
const dragItem = ref(null);
//
/** @type {import('vue').Ref<HTMLElement | null>} */
const containerRef = ref(null);
const columnCount = ref(1);
/** @type {ResizeObserver | null} */
let resizeObserver = null;
//
/** @type {import('vue').Ref<string[]>} */
const selectedIds = ref([]);
/** @type {import('vue').Ref<HTMLInputElement | null>} */
const fileInput = ref(null);
//
const isAllSelected = computed(() => {
return quickList.value.length > 0 && selectedIds.value.length === quickList.value.length;
});
//
const hasSelected = computed(() => selectedIds.value.length > 0);
/**
* 生成唯一 id
* @returns {string}
*/
function generateId() {
return Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
}
/**
* 打开新增弹窗
*/
function handleAdd() {
isEdit.value = false;
formId.value = '';
formTopic.value = '';
formComment.value = '';
formPayload.value = '';
formQos.value = 1;
formRetain.value = false;
showModal.value = true;
}
/**
* 打开编辑弹窗
* @param {QuickSendItem} item
*/
function handleEdit(item) {
isEdit.value = true;
formId.value = item.id;
formTopic.value = item.topic;
formComment.value = item.comment || '';
formPayload.value = item.payload;
formQos.value = item.qos;
formRetain.value = item.retain;
showModal.value = true;
}
/**
* 保存快速发送项
*/
function handleSave() {
let topic = formTopic.value.trim();
if (!topic) {
msg.warning('请输入目标主题');
return;
}
let comment = formComment.value.trim();
let payload = formPayload.value;
let qos = formQos.value;
let retain = formRetain.value;
if (isEdit.value) {
let idx = quickList.value.findIndex((item) => item.id === formId.value);
if (idx !== -1) {
quickList.value[idx] = {
...quickList.value[idx],
topic,
comment,
payload,
qos,
retain,
};
}
} else {
quickList.value.push({
id: generateId(),
topic,
comment,
payload,
qos,
retain,
});
}
showModal.value = false;
}
/**
* 删除快速发送项
* @param {QuickSendItem} item
*/
function handleDelete(item) {
dialog.warning({
title: '确认删除',
content: `确定要删除主题 "${item.topic}" 的快速消息吗?`,
positiveText: '删除',
negativeText: '取消',
onPositiveClick: () => {
quickList.value = quickList.value.filter((i) => i.id !== item.id);
},
});
}
/**
* 开始拖拽
* @param {DragEvent} e
* @param {QuickSendItem} item
* @param {number} index
*/
function handleDragStart(e, item, index) {
dragIndex.value = index;
dragItem.value = item;
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', item.id);
}
}
/**
* 拖拽经过目标项
* @param {DragEvent} e
* @param {number} index
*/
function handleDragOver(e, index) {
e.preventDefault();
if (e.dataTransfer) {
e.dataTransfer.dropEffect = 'move';
}
if (dragIndex.value === -1 || dragIndex.value === index) {
return;
}
}
/**
* 放置拖拽项
* @param {DragEvent} e
* @param {number} index
*/
function handleDrop(e, index) {
e.preventDefault();
if (dragIndex.value === -1 || dragIndex.value === index) {
return;
}
let item = quickList.value[dragIndex.value];
quickList.value.splice(dragIndex.value, 1);
quickList.value.splice(index, 0, item);
}
/**
* 拖拽结束
*/
function handleDragEnd() {
dragIndex.value = -1;
dragItem.value = null;
}
/**
* 发送消息到当前服务器
* @param {QuickSendItem} item
*/
async function handleSend(item) {
let srv = store.activeServer.value;
if (!srv) {
msg.warning('请先在左侧选择一个 MQTT 服务器');
return;
}
if (srv.status !== 'connected') {
msg.warning('请先连接服务器');
return;
}
let result = await store.publishMessage(srv.id, item.topic, item.payload, {
qos: item.qos,
retain: item.retain,
});
if (result) {
msg.success('消息已发布到: ' + item.topic);
}
}
/**
* 复制主题到剪贴板
* @param {string} topic
*/
async function copyTopic(topic) {
try {
await navigator.clipboard.writeText(topic);
msg.success('主题已复制');
} catch {
msg.error('复制失败');
}
}
/**
* 格式化消息内容为 JSON
*/
function handleFormatJson() {
try {
let obj = JSON.parse(formPayload.value);
formPayload.value = JSON.stringify(obj, null, 2);
msg.info('JSON 已格式化');
} catch {
msg.warning('无效的 JSON 格式');
}
}
/**
* 选中或取消选中单个快速消息
* @param {string} id
* @param {boolean} checked
*/
function handleSelectItem(id, checked) {
if (checked) {
selectedIds.value.push(id);
} else {
selectedIds.value = selectedIds.value.filter((i) => i !== id);
}
}
/**
* 切换全选状态
*/
function handleToggleSelectAll() {
if (isAllSelected.value) {
selectedIds.value = [];
} else {
selectedIds.value = quickList.value.map((item) => item.id);
}
}
/**
* 批量删除选中的快速消息
*/
function handleBatchDelete() {
if (!hasSelected.value) {
return;
}
dialog.warning({
title: '确认批量删除',
content: `确定要删除选中的 ${selectedIds.value.length} 条快速消息吗?`,
positiveText: '删除',
negativeText: '取消',
onPositiveClick: () => {
quickList.value = quickList.value.filter((item) => !selectedIds.value.includes(item.id));
selectedIds.value = [];
},
});
}
/**
* 导出快速消息列表为 JSON 文件
*/
function handleExport() {
let data = quickList.value;
let blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
let url = URL.createObjectURL(blob);
let a = document.createElement('a');
a.href = url;
a.download = `frost-mqtt-quick-send-${new Date().toISOString().slice(0, 10)}.json`;
a.click();
URL.revokeObjectURL(url);
msg.success('快速消息已导出');
}
/**
* 触发文件导入点击
*/
function handleImportClick() {
fileInput.value?.click();
}
/**
* 处理导入的 JSON 文件
* @param {Event} event
*/
function handleImportFile(event) {
let file = event.target.files?.[0];
if (!file) {
return;
}
let reader = new FileReader();
reader.onload = (e) => {
try {
let data = JSON.parse(String(e.target?.result || ''));
if (!Array.isArray(data)) {
msg.error('导入失败: 文件内容不是数组');
return;
}
let validItems = data.filter((item) => {
return item && typeof item.id === 'string' && typeof item.topic === 'string';
});
if (validItems.length === 0) {
msg.warning('未找到有效的快速消息');
return;
}
dialog.create({
title: '选择导入方式',
content: `检测到 ${validItems.length} 条快速消息,请选择导入方式:`,
positiveText: '追加',
negativeText: '覆盖',
class: 'quick-send-dialog',
onPositiveClick: () => {
let existingIds = new Set(quickList.value.map((item) => item.id));
let itemsToAdd = validItems.map((item) => {
if (existingIds.has(item.id)) {
return { ...item, id: generateId() };
}
return item;
});
quickList.value.push(...itemsToAdd);
selectedIds.value = [];
msg.success(`已追加 ${itemsToAdd.length} 条快速消息`);
},
onNegativeClick: () => {
quickList.value = validItems;
selectedIds.value = [];
msg.success(`已覆盖为导入的 ${validItems.length} 条快速消息`);
},
});
} catch (err) {
msg.error('导入失败: ' + (err.message || '未知错误'));
}
};
reader.onerror = () => {
msg.error('读取文件失败');
};
reader.readAsText(file);
event.target.value = '';
}
function updateColumnCount(width) {
if (width >= 1520) {
columnCount.value = 3;
} else if (width >= 720) {
columnCount.value = 2;
} else {
columnCount.value = 1;
}
}
onMounted(async () => {
try {
let list = await window.api.getQuickSendList();
if (Array.isArray(list)) {
quickList.value = list.filter((item) => {
return item && typeof item.id === 'string' && typeof item.topic === 'string';
});
}
if (quickList.value.length === 0) {
migrateFromLocalStorage();
}
} catch (e) {
console.error('加载快速发送列表失败:', e);
} finally {
quickListLoaded.value = true;
}
resizeObserver = new ResizeObserver((entries) => {
for (let entry of entries) {
updateColumnCount(entry.contentRect.width);
}
});
if (containerRef.value) {
resizeObserver.observe(containerRef.value);
updateColumnCount(containerRef.value.clientWidth);
}
});
onUnmounted(() => {
if (resizeObserver) {
resizeObserver.disconnect();
resizeObserver = null;
}
});
</script>
<template>
<div
ref="containerRef"
class="quick-send-view"
>
<n-card
size="small"
:content-style="{
display: 'flex',
flexDirection: 'column',
minHeight: 0,
overflow: 'hidden',
}"
>
<n-space
style="margin-bottom: 12px"
align="center"
>
<n-button
type="primary"
@click="handleAdd"
>新增快速消息</n-button>
<n-button
size="small"
:disabled="quickList.length === 0"
@click="handleToggleSelectAll"
>{{ isAllSelected ? '取消全选' : '全选' }}</n-button>
<n-button
size="small"
type="error"
ghost
:disabled="!hasSelected"
@click="handleBatchDelete"
>批量删除</n-button>
<n-button
size="small"
@click="handleImportClick"
>导入</n-button>
<n-button
size="small"
@click="handleExport"
>导出</n-button>
</n-space>
<n-empty
v-if="quickList.length === 0"
description="暂无快速发送消息,点击上方按钮添加"
/>
<div
v-else
class="quick-list"
:class="`columns-${columnCount}`"
>
<div
v-for="(item, index) in quickList"
:key="item.id"
class="quick-item"
:class="{ dragging: dragIndex === index }"
draggable="true"
@dragstart="handleDragStart($event, item, index)"
@dragover.prevent="handleDragOver($event, index)"
@drop.prevent="handleDrop($event, index)"
@dragend="handleDragEnd"
>
<div class="quick-header">
<n-checkbox
class="quick-checkbox"
:checked="selectedIds.includes(item.id)"
@update:checked="(checked) => handleSelectItem(item.id, checked)"
@click.stop
/>
<div class="quick-topic-wrap">
<span class="quick-dir pub">PUB</span>
<span
class="quick-topic"
:title="item.topic"
@click="copyTopic(item.topic)"
>{{ item.topic }}</span>
<span class="quick-meta">QoS{{ item.qos }}{{ item.retain ? ' R' : '' }}</span>
</div>
<n-space>
<n-button
size="tiny"
:disabled="!hasActiveServer"
type="primary"
@click="handleSend(item)"
>发送</n-button>
<n-button
size="tiny"
@click="handleEdit(item)"
>编辑</n-button>
<n-button
size="tiny"
type="error"
ghost
@click="handleDelete(item)"
>删除</n-button>
</n-space>
</div>
<div
v-if="item.comment"
class="quick-comment"
:title="item.comment"
>{{ item.comment }}</div>
<pre
v-if="item.payload"
class="quick-payload"
>{{ item.payload }}</pre>
</div>
</div>
<input
ref="fileInput"
type="file"
accept=".json,application/json"
style="display: none"
@change="handleImportFile"
>
</n-card>
<n-modal
v-model:show="showModal"
class="quick-send-modal"
:title="isEdit ? '编辑快速消息' : '新增快速消息'"
preset="card"
style="width: 520px; max-width: 90vw"
:bordered="false"
:segmented="{ content: true }"
>
<n-form
label-placement="top"
:show-feedback="false"
>
<n-form-item label="目标主题">
<n-input
v-model:value="formTopic"
placeholder="例如: sensor/device01/temperature"
/>
</n-form-item>
<n-form-item label="备注">
<n-input
v-model:value="formComment"
placeholder="可选,用于标识该快速消息的用途"
/>
</n-form-item>
<n-grid
cols="2"
x-gap="12"
>
<n-grid-item>
<n-form-item label="QoS">
<n-select
v-model:value="formQos"
:options="qosOptions"
/>
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="保留消息">
<n-switch v-model:value="formRetain" />
</n-form-item>
</n-grid-item>
</n-grid>
<n-form-item label="消息内容">
<div class="payload-field">
<n-input
v-model:value="formPayload"
type="textarea"
:resizable="false"
:rows="6"
placeholder="输入要发送的消息内容"
/>
<n-button
size="small"
@click="handleFormatJson"
>格式化 JSON</n-button>
</div>
</n-form-item>
</n-form>
<template #footer>
<n-space justify="end">
<n-button @click="showModal = false">取消</n-button>
<n-button
type="primary"
@click="handleSave"
>保存</n-button>
</n-space>
</template>
</n-modal>
</div>
</template>
<style scoped lang="less">
.quick-send-view {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
:deep(.n-card) {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
:deep(.n-card__content) {
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
}
}
.quick-list {
flex: 1;
min-height: 0;
overflow-y: auto;
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 12px;
padding-right: 4px;
align-content: start;
&.columns-1 {
grid-template-columns: minmax(0, 1fr);
}
&.columns-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
&.columns-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
.payload-field {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
:deep(.n-input) {
width: 100%;
}
.n-button {
align-self: flex-end;
}
}
.quick-item {
border: 1px solid var(--frost-shadow-black-8);
border-radius: 6px;
padding: 12px;
background: var(--frost-color-bg-item);
cursor: grab;
transition: box-shadow 0.2s, opacity 0.2s;
&.dragging {
opacity: 0.6;
box-shadow: 0 4px 12px var(--frost-shadow-black-12);
cursor: grabbing;
}
}
.quick-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
min-width: 0;
}
.quick-checkbox {
flex-shrink: 0;
margin-top: 2px;
:deep(.n-checkbox-box) {
width: 16px;
height: 16px;
}
}
.quick-topic-wrap {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
flex: 1;
flex-wrap: wrap;
}
.quick-dir {
flex-shrink: 0;
font-weight: 700;
width: 28px;
font-size: 12px;
&.pub {
color: var(--frost-info-color);
}
}
.quick-topic {
color: var(--frost-color-topic);
min-width: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
font-family: monospace;
font-size: 13px;
&:hover {
text-decoration: underline;
}
}
.quick-meta {
color: var(--frost-color-text-tertiary);
font-size: 11px;
flex-shrink: 0;
font-family: monospace;
}
.quick-comment {
margin-top: 8px;
color: var(--frost-color-text-secondary);
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.quick-payload {
margin: 10px 0 0;
padding: 8px 10px;
border-radius: 4px;
background: var(--frost-shadow-black-4);
color: #333;
font: inherit;
white-space: pre-wrap;
overflow-wrap: anywhere;
font-family: monospace;
font-size: 12px;
line-height: 1.6;
max-height: 120px;
overflow-y: auto;
}
.quick-send-modal {
.n-form-item {
margin-bottom: 20px;
&__label {
margin-bottom: 8px;
}
&:last-child {
margin-bottom: 0;
}
}
.n-grid {
margin-bottom: 20px;
}
}
.quick-send-dialog {
:deep(.n-dialog__icon) {
color: var(--frost-primary-color);
}
}
</style>
+434 -131
View File
@@ -1,168 +1,289 @@
<script setup>
import { ref, watch } from 'vue'
import { useMqttStore } from '../stores/mqtt.js'
import {
NCard,
NSpace,
NFormItem,
NInput,
NSelect,
NButton,
NEmpty,
NList,
NListItem,
NTag,
NEllipsis,
useMessage
} from 'naive-ui'
import { ref, watch } from 'vue';
import { useMqttStore } from '../stores/mqtt.js';
import { NCard, NSpace, NFormItem, NInput, NSelect, NButton, NEmpty, NList, NListItem, NTag, useMessage } from 'naive-ui';
const store = useMqttStore()
const msg = useMessage()
// API
const store = useMqttStore();
const msg = useMessage();
const topicInput = ref('')
const qos = ref(2)
const editingTopicId = ref(null)
const editingTopic = ref('')
const editingQos = ref(2)
// QoS
const topicInput = ref('');
const qos = ref(2);
const comment = ref('');
//
const editingTopicId = ref(null);
const editingTopic = ref('');
const editingQos = ref(2);
const editingComment = ref('');
// QoS
const qosOptions = [
{ label: 'QoS 0 - 最多一次', value: 0 },
{ label: 'QoS 1 - 至少一次', value: 1 },
{ label: 'QoS 2 - 恰好一次', value: 2 }
]
{ label: 'QoS 2 - 恰好一次', value: 2 },
];
const formStorageKey = 'mqtt-client.subscribe-form'
let formReady = false
// key
const formStorageKey = 'mqtt-client.subscribe-form';
let formReady = false;
// localStorage
try {
const savedForm = JSON.parse(localStorage.getItem(formStorageKey) || '{}')
if (typeof savedForm.topicInput === 'string') topicInput.value = savedForm.topicInput
if ([0, 1, 2].includes(savedForm.qos)) qos.value = savedForm.qos
let savedForm = JSON.parse(localStorage.getItem(formStorageKey) || '{}');
if (typeof savedForm.topicInput === 'string') {
topicInput.value = savedForm.topicInput;
}
if ([0, 1, 2].includes(savedForm.qos)) {
qos.value = savedForm.qos;
}
} catch {
localStorage.removeItem(formStorageKey)
localStorage.removeItem(formStorageKey);
}
// localStorage
watch([topicInput, qos], () => {
if (!formReady) return
if (!formReady) {
return;
}
localStorage.setItem(
formStorageKey,
JSON.stringify({ topicInput: topicInput.value, qos: qos.value })
)
})
);
formReady = true
});
formReady = true;
/**
* 添加新主题
*/
async function handleAddTopic() {
const topic = topicInput.value.trim()
let topic = topicInput.value.trim();
if (!topic) {
msg.warning('请输入主题名称')
return
msg.warning('请输入主题名称');
return;
}
const srv = store.activeServer.value
if (!srv) return
let srv = store.activeServer.value;
if (!srv) {
return;
}
if (srv.topics && srv.topics.find((t) => t.topic === topic)) {
msg.warning('该主题已存在')
return
msg.warning('该主题已存在');
return;
}
const result = await store.addTopic(srv.id, topic, qos.value)
let result = await store.addTopic(srv.id, topic, qos.value, comment.value.trim());
if (result) {
msg.success('主题已添加' + (srv.status === 'connected' ? '并已订阅' : ''))
topicInput.value = ''
msg.success('主题已添加' + (srv.status === 'connected' ? '并已订阅' : ''));
topicInput.value = '';
comment.value = '';
}
}
/**
* 移除主题
* @param {string} topic - 主题名称
*/
async function handleRemoveTopic(topic) {
const srv = store.activeServer.value
if (!srv) return
await store.removeTopic(srv.id, topic)
msg.info('主题已移除')
let srv = store.activeServer.value;
if (!srv) {
return;
}
await store.removeTopic(srv.id, topic);
msg.info('主题已移除');
}
/**
* 切换单个主题订阅状态
* @param {string} topic - 主题名称
*/
async function handleToggleSub(topic) {
const srv = store.activeServer.value
if (!srv) return
if (srv.status !== 'connected') {
msg.warning('请先连接服务器')
return
let srv = store.activeServer.value;
if (!srv) {
return;
}
const t = srv.topics.find((t) => t.topic === topic)
if (!t) return
if (srv.status !== 'connected') {
msg.warning('请先连接服务器');
return;
}
let t = srv.topics.find((t) => t.topic === topic);
if (!t) {
return;
}
if (t.subscribed) {
await store.unsubscribeTopic(srv.id, topic)
msg.info('已取消订阅: ' + topic)
await store.unsubscribeTopic(srv.id, topic);
msg.info('已取消订阅: ' + topic);
} else {
await store.subscribeTopic(srv.id, topic)
msg.info('已订阅: ' + topic)
await store.subscribeTopic(srv.id, topic);
msg.info('已订阅: ' + topic);
}
}
/**
* 批量订阅当前服务器下所有未订阅主题
*/
async function handleSubscribeAll() {
const srv = store.activeServer.value
if (!srv) return
let srv = store.activeServer.value;
if (!srv) {
return;
}
if (srv.status !== 'connected') {
msg.warning('请先连接服务器')
return
msg.warning('请先连接服务器');
return;
}
await store.subscribeAllTopics(srv.id)
msg.success('已订阅全部主题')
await store.subscribeAllTopics(srv.id);
msg.success('已订阅全部主题');
}
/**
* 进入主题编辑状态
* @param {import('@types/jsdoc').MqttTopic} t - 主题对象
*/
function startEdit(t) {
editingTopicId.value = t.id || t.topic
editingTopic.value = t.topic
editingQos.value = t.qos ?? 2
editingTopicId.value = t.id || t.topic;
editingTopic.value = t.topic;
editingQos.value = t.qos ?? 2;
editingComment.value = t.comment ?? '';
}
/**
* 取消主题编辑
*/
function cancelEdit() {
editingTopicId.value = null
editingTopic.value = ''
editingQos.value = 2
editingTopicId.value = null;
editingTopic.value = '';
editingQos.value = 2;
editingComment.value = '';
}
/**
* 保存主题编辑
* @param {import('@types/jsdoc').MqttTopic} t - 主题对象
*/
async function handleEditSave(t) {
const srv = store.activeServer.value
if (!srv) return
const topicName = editingTopic.value.trim()
if (!topicName) {
msg.warning('请输入主题名称')
return
let srv = store.activeServer.value;
if (!srv) {
return;
}
let topicName = editingTopic.value.trim();
if (!topicName) {
msg.warning('请输入主题名称');
return;
}
if (
srv.topics.find(
(item) => item.topic === topicName && (item.id || item.topic) !== editingTopicId.value
)
) {
msg.warning('该主题已存在')
return
msg.warning('该主题已存在');
return;
}
const topicId = t.id || t.topic
const result = await store.updateTopic(srv.id, topicId, topicName, editingQos.value)
let topicId = t.id || t.topic;
let result = await store.updateTopic(
srv.id,
topicId,
topicName,
editingQos.value,
editingComment.value.trim()
);
if (result) {
msg.success('主题已更新')
cancelEdit()
msg.success('主题已更新');
cancelEdit();
} else {
msg.error('主题更新失败')
msg.error('主题更新失败');
}
}
</script>
<template>
<div v-if="store.activeServer.value" class="subscribe-view">
<!-- 内容区域 -->
<div
v-if="store.activeServer.value"
class="subscribe-view"
>
<!-- 添加主题 -->
<n-card title="添加订阅主题" size="small">
<n-card
title="添加订阅主题"
size="small"
>
<div class="topic-form">
<n-form-item class="topic-field" label="主题 (Topic)">
<n-form-item
class="topic-field"
label="主题 (Topic)"
>
<n-input
v-model:value="topicInput"
placeholder="例如: sensor/+/temperature"
@keydown.enter="handleAddTopic"
/>
</n-form-item>
<n-form-item class="qos-field" label="QoS">
<n-select v-model:value="qos" :options="qosOptions" />
<n-form-item
class="comment-field"
label="备注"
>
<n-input
v-model:value="comment"
placeholder="可选备注"
@keydown.enter="handleAddTopic"
/>
</n-form-item>
<n-button class="topic-submit" type="primary" @click="handleAddTopic">
+ 添加订阅
</n-button>
<n-form-item
class="qos-field"
label="QoS"
>
<n-select
v-model:value="qos"
:options="qosOptions"
/>
</n-form-item>
<n-button
class="topic-submit"
type="primary"
@click="handleAddTopic"
>+ 添加订阅</n-button>
</div>
</n-card>
@@ -175,78 +296,137 @@ async function handleEditSave(t) {
display: 'flex',
flexDirection: 'column',
minHeight: 0,
overflow: 'hidden'
overflow: 'hidden',
}"
>
<template #header-extra>
<n-button size="small" @click="handleSubscribeAll">全部订阅</n-button>
<n-button
size="small"
@click="handleSubscribeAll"
>全部订阅</n-button>
</template>
<n-empty
v-if="!store.activeServer.value.topics || store.activeServer.value.topics.length === 0"
description="暂无订阅主题,请在上方添加"
/>
<div v-else class="topic-list-scroll">
<div
v-else
class="topic-list-scroll"
>
<n-list>
<n-list-item
v-for="t in store.activeServer.value.topics"
:key="t.id || t.topic"
class="topic-item"
>
<template v-if="editingTopicId !== (t.id || t.topic)" #prefix>
<n-tag :type="t.subscribed ? 'success' : 'default'" size="small">
QoS {{ t.qos }}
</n-tag>
<template #prefix>
<n-tag
class="topic-qos-tag"
:type="t.subscribed ? 'success' : 'default'"
size="small"
>QoS {{ t.qos }}</n-tag>
</template>
<div v-if="editingTopicId === (t.id || t.topic)" class="topic-edit-row">
<div class="topic-content">
<div
class="topic-name"
:class="{ 'is-hidden': editingTopicId === (t.id || t.topic) }"
>
<span
class="topic-label"
:title="t.topic"
>{{ t.topic }}</span>
<div class="topic-comment">
<span class="topic-comment-prefix">备注</span>
<span
class="topic-comment-text"
:title="t.comment"
>{{ t.comment }}</span>
</div>
</div>
<div
class="topic-edit-row"
:class="{ 'is-visible': editingTopicId === (t.id || t.topic) }"
>
<n-input
v-model:value="editingTopic"
class="topic-edit-topic"
size="small"
placeholder="主题名称"
@keydown.enter="handleEditSave(t)"
/>
<n-input
v-model:value="editingComment"
class="topic-edit-comment"
size="small"
placeholder="备注"
/>
<n-select
v-model:value="editingQos"
class="topic-edit-qos"
size="small"
:options="qosOptions"
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
v-if="editingTopicId !== (t.id || t.topic)"
class="topic-actions"
class="topic-edit-actions"
:size="4"
:wrap="false"
>
<n-button size="tiny" @click="startEdit(t)">编辑</n-button>
<n-button
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)"
>
{{ t.subscribed ? '取消订阅' : '订阅' }}
</n-button>
<n-button size="tiny" type="error" text @click="handleRemoveTopic(t.topic)">
&#10005;
{{ t.subscribed ? '订阅' : '订阅' }}
</n-button>
<n-button
class="topic-action-remove"
size="tiny"
type="error"
text
@click.stop="handleRemoveTopic(t.topic)"
>&#10005;</n-button>
</n-space>
</template>
</n-list-item>
</n-list>
</div>
</n-card>
</div>
<n-empty v-else description="请先在左侧选择一个 MQTT 服务器" />
<!-- 空状态 -->
<n-empty
v-else
description="请先在左侧选择一个 MQTT 服务器"
/>
</template>
<style scoped>
<style scoped lang="less">
.subscribe-view {
display: flex;
flex-direction: column;
@@ -290,45 +470,168 @@ async function handleEditSave(t) {
width: 160px;
}
.comment-field {
flex: 1 1 180px;
width: auto;
}
.topic-submit {
flex: 0 0 auto;
}
.topic-field :deep(.n-form-item-blank),
.qos-field :deep(.n-form-item-blank) {
.topic-field,
.qos-field,
.comment-field {
:deep(.n-form-item-blank) {
width: 100%;
}
}
.topic-item :deep(.n-list-item__main) {
.topic-item {
box-sizing: border-box;
height: 56px;
min-height: 56px;
padding-top: 0 !important;
padding-bottom: 0 !important;
:deep(.n-list-item__main) {
display: flex;
align-items: center;
height: 100%;
min-width: 0;
gap: 12px;
}
}
.topic-content {
position: relative;
display: flex;
align-items: center;
flex: 1;
width: 100%;
height: 100%;
min-width: 0;
overflow: hidden;
}
.topic-name {
min-width: 0;
display: flex;
flex: 1;
font-family: monospace;
flex-direction: column;
justify-content: center;
align-items: stretch;
width: 100%;
height: 40px;
min-width: 0;
min-height: 0;
gap: 2px;
font-size: 13px;
overflow: hidden;
visibility: visible;
&.is-hidden {
visibility: hidden;
}
}
.topic-label {
min-width: 0;
overflow: hidden;
font-family: monospace;
line-height: 18px;
text-overflow: ellipsis;
white-space: nowrap;
}
.topic-comment {
display: flex;
align-items: center;
min-width: 0;
max-width: 100%;
padding: 1px 4px;
gap: 4px;
border: 1px solid rgba(14, 165, 160, 0.16);
border-radius: 4px;
background: rgba(20, 184, 166, 0.06);
color: var(--n-text-color-3);
font-size: 11px;
line-height: 16px;
&-prefix {
flex-shrink: 0;
color: #0f766e;
}
&-text {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.topic-qos-tag {
min-width: 56px;
justify-content: center;
}
.topic-actions {
flex-shrink: 0;
visibility: visible;
&.is-hidden {
visibility: hidden;
pointer-events: none;
}
.n-button {
min-width: 40px;
}
.topic-action-sub {
min-width: auto;
}
.topic-action-remove {
min-width: 24px;
}
}
.topic-edit-row {
position: absolute;
inset: 0;
display: flex;
align-items: center;
flex-wrap: wrap;
flex-wrap: nowrap;
gap: 8px;
flex: 1;
width: 100%;
height: 100%;
min-width: 0;
overflow: hidden;
visibility: hidden;
pointer-events: none;
&.is-visible {
visibility: visible;
pointer-events: auto;
}
}
.topic-edit-topic {
flex: 1 1 auto;
min-width: 0;
}
.topic-edit-row .n-input {
flex: 1;
min-width: 180px;
.topic-edit-comment {
flex: 1 1 auto;
min-width: 0;
}
.topic-edit-qos {
flex: 0 0 auto;
width: 160px;
min-width: 80px;
}
@media (max-width: 720px) {
+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>
<div class="sidebar-title">Frost MQTT Client</div>
<div class="sidebar-subtitle">服务器管理客户端</div>
<div class="sidebar-subtitle">功能 MQTT 客户端</div>
</div>
</div>
+16
View File
@@ -0,0 +1,16 @@
# CHANGELOG
## [1.0.1] - 2026-07-21
### Added
- 快速发送列表持久化到本地文件 `frost-mqtt-data.json`
- 快速消息支持备注。
### Fixed
- 解决“快速发送”模块中,主题名称较长时,外层卡片缩小到一定程度后不能再随外层容器缩小而缩小的问题。
## [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`,并修复实际报告的问题。