16 Commits
Author SHA1 Message Date
frost-zx 037694f6f8 feat: 增加主密码功能 2026-07-24 21:55:13 +08:00
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
19 changed files with 2266 additions and 330 deletions
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "frost-mqtt-client", "name": "frost-mqtt-client",
"version": "1.0.0", "version": "1.0.1",
"description": "一个使用 AI 开发的多功能 MQTT 桌面客户端,基于 Electron + Vue 3 + Naive UI 构建。", "description": "一个使用 AI 开发的多功能 MQTT 桌面客户端,基于 Electron + Vue 3 + Naive UI 构建。",
"main": "./out/main/index.js", "main": "./out/main/index.js",
"author": "Frost-ZX", "author": "Frost-ZX",
@@ -28,6 +28,7 @@
"electron-builder": "^26.0.12", "electron-builder": "^26.0.12",
"electron-vite": "^5.0.0", "electron-vite": "^5.0.0",
"eslint": "^9.39.1", "eslint": "^9.39.1",
"less": "^4.7.0",
"eslint-plugin-vue": "^10.6.2", "eslint-plugin-vue": "^10.6.2",
"naive-ui": "^2.44.1", "naive-ui": "^2.44.1",
"vite": "^7.2.6", "vite": "^7.2.6",
+185 -8
View File
@@ -23,7 +23,7 @@ importers:
version: 2.1.0(eslint@9.39.5(jiti@2.7.0)) version: 2.1.0(eslint@9.39.5(jiti@2.7.0))
'@vitejs/plugin-vue': '@vitejs/plugin-vue':
specifier: ^6.0.2 specifier: ^6.0.2
version: 6.0.8(vite@7.3.6(@types/node@26.1.1)(jiti@2.7.0))(vue@3.5.39) version: 6.0.8(vite@7.3.6(@types/node@26.1.1)(jiti@2.7.0)(less@4.7.0))(vue@3.5.39)
electron: electron:
specifier: ^39.2.6 specifier: ^39.2.6
version: 39.8.10 version: 39.8.10
@@ -32,19 +32,22 @@ importers:
version: 26.15.3(electron-builder-squirrel-windows@26.15.3) version: 26.15.3(electron-builder-squirrel-windows@26.15.3)
electron-vite: electron-vite:
specifier: ^5.0.0 specifier: ^5.0.0
version: 5.0.0(vite@7.3.6(@types/node@26.1.1)(jiti@2.7.0)) version: 5.0.0(vite@7.3.6(@types/node@26.1.1)(jiti@2.7.0)(less@4.7.0))
eslint: eslint:
specifier: ^9.39.1 specifier: ^9.39.1
version: 9.39.5(jiti@2.7.0) version: 9.39.5(jiti@2.7.0)
eslint-plugin-vue: eslint-plugin-vue:
specifier: ^10.6.2 specifier: ^10.6.2
version: 10.9.2(eslint@9.39.5(jiti@2.7.0))(vue-eslint-parser@10.4.1(eslint@9.39.5(jiti@2.7.0))) version: 10.9.2(eslint@9.39.5(jiti@2.7.0))(vue-eslint-parser@10.4.1(eslint@9.39.5(jiti@2.7.0)))
less:
specifier: ^4.7.0
version: 4.7.0
naive-ui: naive-ui:
specifier: ^2.44.1 specifier: ^2.44.1
version: 2.44.1(vue@3.5.39) version: 2.44.1(vue@3.5.39)
vite: vite:
specifier: ^7.2.6 specifier: ^7.2.6
version: 7.3.6(@types/node@26.1.1)(jiti@2.7.0) version: 7.3.6(@types/node@26.1.1)(jiti@2.7.0)(less@4.7.0)
vue: vue:
specifier: ^3.5.25 specifier: ^3.5.25
version: 3.5.39 version: 3.5.39
@@ -1088,6 +1091,10 @@ packages:
convert-source-map@2.0.0: convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
copy-anything@3.0.5:
resolution: {integrity: sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==}
engines: {node: '>=12.13'}
core-util-is@1.0.3: core-util-is@1.0.3:
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
@@ -1120,6 +1127,22 @@ packages:
date-fns@4.4.0: date-fns@4.4.0:
resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==} resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==}
debug@2.6.9:
resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==}
peerDependencies:
supports-color: '*'
peerDependenciesMeta:
supports-color:
optional: true
debug@3.2.7:
resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==}
peerDependencies:
supports-color: '*'
peerDependenciesMeta:
supports-color:
optional: true
debug@4.4.3: debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'} engines: {node: '>=6.0'}
@@ -1232,6 +1255,10 @@ packages:
err-code@2.0.3: err-code@2.0.3:
resolution: {integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==} resolution: {integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==}
errno@0.1.8:
resolution: {integrity: sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==}
hasBin: true
es-define-property@1.0.1: es-define-property@1.0.1:
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -1544,6 +1571,14 @@ packages:
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
engines: {node: '>= 14'} engines: {node: '>= 14'}
iconv-lite@0.4.24:
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
engines: {node: '>=0.10.0'}
iconv-lite@0.6.3:
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
engines: {node: '>=0.10.0'}
ieee754@1.2.1: ieee754@1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
@@ -1582,6 +1617,10 @@ packages:
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
is-what@4.1.16:
resolution: {integrity: sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==}
engines: {node: '>=12.13'}
isarray@1.0.0: isarray@1.0.0:
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
@@ -1660,6 +1699,11 @@ packages:
lazy-val@1.0.5: lazy-val@1.0.5:
resolution: {integrity: sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==} resolution: {integrity: sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==}
less@4.7.0:
resolution: {integrity: sha512-i7dAlT3+boO0mMh1G4cex0vz1lLAScmBbikm1VEDNv+cy0ore1CUo2UtX8m3N9QLE5WYDr4ISbiCRzHNGyFkrA==}
engines: {node: '>=18'}
hasBin: true
levn@0.4.1: levn@0.4.1:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
@@ -1694,6 +1738,10 @@ packages:
magic-string@0.30.21: magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
make-dir@5.1.0:
resolution: {integrity: sha512-IfpFq6UM39dUNiphpA6uDezNx/AvWyhwfICWPR3t1VspkgkMZrL+Rk1RbN1bx+aeNYwOrqGJgEgV3yotk+ZUVw==}
engines: {node: '>=18'}
matcher@3.0.0: matcher@3.0.0:
resolution: {integrity: sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==} resolution: {integrity: sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -1710,6 +1758,11 @@ packages:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
mime@1.6.0:
resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==}
engines: {node: '>=4'}
hasBin: true
mime@2.6.0: mime@2.6.0:
resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==}
engines: {node: '>=4.0.0'} engines: {node: '>=4.0.0'}
@@ -1761,6 +1814,9 @@ packages:
engines: {node: '>=16.0.0'} engines: {node: '>=16.0.0'}
hasBin: true hasBin: true
ms@2.0.0:
resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==}
ms@2.1.3: ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -1778,6 +1834,16 @@ packages:
natural-compare@1.4.0: natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
needle@2.9.1:
resolution: {integrity: sha512-6R9fqJ5Zcmf+uYaFgdIHmLwNldn5HbK8L5ybn7Uz+ylX/rnOsSp1AHcvQSrCaFN+qNM1wpymHqD7mVasEOlHGQ==}
engines: {node: '>= 4.4.x'}
hasBin: true
needle@3.5.0:
resolution: {integrity: sha512-jaQyPKKk2YokHrEg+vFDYxXIHTCBgiZwSHOoVx/8V3GIBS8/VN6NdVRmg8q1ERtPkMvmOvebsgga4sAj5hls/w==}
engines: {node: '>= 4.4.x'}
hasBin: true
node-abi@4.33.0: node-abi@4.33.0:
resolution: {integrity: sha512-vLBWCKb+7LWsX+TbfzWOkw0W81m377tyx3hOweBTjO43CXZnRGS1/JPWs20fr0PgZyDXk6ROYrylsEycK8raDA==} resolution: {integrity: sha512-vLBWCKb+7LWsX+TbfzWOkw0W81m377tyx3hOweBTjO43CXZnRGS1/JPWs20fr0PgZyDXk6ROYrylsEycK8raDA==}
engines: {node: '>=22.12.0'} engines: {node: '>=22.12.0'}
@@ -1839,6 +1905,10 @@ packages:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'} engines: {node: '>=6'}
parse-node-version@1.0.1:
resolution: {integrity: sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA==}
engines: {node: '>= 0.10'}
path-exists@4.0.0: path-exists@4.0.0:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -1890,6 +1960,9 @@ packages:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
probe-image-size@7.3.0:
resolution: {integrity: sha512-7CaDeBwiAbh6ohXsvLbAZhO7wzsZAmaevfxe39qvCwRh8LyaZfDlBGGLU1CCTgrTLtCOdwBBhjOrIHaIIimHfQ==}
proc-log@6.1.0: proc-log@6.1.0:
resolution: {integrity: sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==} resolution: {integrity: sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==}
engines: {node: ^20.17.0 || >=22.9.0} engines: {node: ^20.17.0 || >=22.9.0}
@@ -1912,6 +1985,9 @@ packages:
proper-lockfile@4.1.2: proper-lockfile@4.1.2:
resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==}
prr@1.0.1:
resolution: {integrity: sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==}
pump@3.0.4: pump@3.0.4:
resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==}
@@ -1994,6 +2070,9 @@ packages:
safe-buffer@5.2.1: safe-buffer@5.2.1:
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
sanitize-filename@1.6.4: sanitize-filename@1.6.4:
resolution: {integrity: sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg==} resolution: {integrity: sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg==}
@@ -2074,6 +2153,9 @@ packages:
resolution: {integrity: sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==} resolution: {integrity: sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
stream-parser@0.3.1:
resolution: {integrity: sha512-bJ/HgKq41nlKvlhccD5kaCr/P+Hu0wPNKPJOH7en+YrJu/9EgqUF+88w5Jb6KNcjOFMhfX4B2asfeAtIGuHObQ==}
string-width@4.2.3: string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -2993,10 +3075,10 @@ snapshots:
'@types/node': 22.20.1 '@types/node': 22.20.1
optional: true optional: true
'@vitejs/plugin-vue@6.0.8(vite@7.3.6(@types/node@26.1.1)(jiti@2.7.0))(vue@3.5.39)': '@vitejs/plugin-vue@6.0.8(vite@7.3.6(@types/node@26.1.1)(jiti@2.7.0)(less@4.7.0))(vue@3.5.39)':
dependencies: dependencies:
'@rolldown/pluginutils': 1.0.1 '@rolldown/pluginutils': 1.0.1
vite: 7.3.6(@types/node@26.1.1)(jiti@2.7.0) vite: 7.3.6(@types/node@26.1.1)(jiti@2.7.0)(less@4.7.0)
vue: 3.5.39 vue: 3.5.39
'@vue/compiler-core@3.5.39': '@vue/compiler-core@3.5.39':
@@ -3320,6 +3402,10 @@ snapshots:
convert-source-map@2.0.0: {} convert-source-map@2.0.0: {}
copy-anything@3.0.5:
dependencies:
is-what: 4.1.16
core-util-is@1.0.3: {} core-util-is@1.0.3: {}
cross-dirname@0.1.0: cross-dirname@0.1.0:
@@ -3348,6 +3434,16 @@ snapshots:
date-fns@4.4.0: {} date-fns@4.4.0: {}
debug@2.6.9:
dependencies:
ms: 2.0.0
optional: true
debug@3.2.7:
dependencies:
ms: 2.1.3
optional: true
debug@4.4.3: debug@4.4.3:
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
@@ -3455,7 +3551,7 @@ snapshots:
electron-to-chromium@1.5.392: {} electron-to-chromium@1.5.392: {}
electron-vite@5.0.0(vite@7.3.6(@types/node@26.1.1)(jiti@2.7.0)): electron-vite@5.0.0(vite@7.3.6(@types/node@26.1.1)(jiti@2.7.0)(less@4.7.0)):
dependencies: dependencies:
'@babel/core': 7.29.7 '@babel/core': 7.29.7
'@babel/plugin-transform-arrow-functions': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-arrow-functions': 7.29.7(@babel/core@7.29.7)
@@ -3463,7 +3559,7 @@ snapshots:
esbuild: 0.25.12 esbuild: 0.25.12
magic-string: 0.30.21 magic-string: 0.30.21
picocolors: 1.1.1 picocolors: 1.1.1
vite: 7.3.6(@types/node@26.1.1)(jiti@2.7.0) vite: 7.3.6(@types/node@26.1.1)(jiti@2.7.0)(less@4.7.0)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -3499,6 +3595,11 @@ snapshots:
err-code@2.0.3: {} err-code@2.0.3: {}
errno@0.1.8:
dependencies:
prr: 1.0.1
optional: true
es-define-property@1.0.1: {} es-define-property@1.0.1: {}
es-errors@1.3.0: {} es-errors@1.3.0: {}
@@ -3909,6 +4010,16 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
iconv-lite@0.4.24:
dependencies:
safer-buffer: 2.1.2
optional: true
iconv-lite@0.6.3:
dependencies:
safer-buffer: 2.1.2
optional: true
ieee754@1.2.1: {} ieee754@1.2.1: {}
ignore@5.3.2: {} ignore@5.3.2: {}
@@ -3937,6 +4048,8 @@ snapshots:
dependencies: dependencies:
is-extglob: 2.1.1 is-extglob: 2.1.1
is-what@4.1.16: {}
isarray@1.0.0: {} isarray@1.0.0: {}
isbinaryfile@4.0.10: {} isbinaryfile@4.0.10: {}
@@ -3996,6 +4109,21 @@ snapshots:
lazy-val@1.0.5: {} lazy-val@1.0.5: {}
less@4.7.0:
dependencies:
copy-anything: 3.0.5
parse-node-version: 1.0.1
optionalDependencies:
errno: 0.1.8
graceful-fs: 4.2.11
make-dir: 5.1.0
mime: 1.6.0
needle: 3.5.0
probe-image-size: 7.3.0
source-map: 0.6.1
transitivePeerDependencies:
- supports-color
levn@0.4.1: levn@0.4.1:
dependencies: dependencies:
prelude-ls: 1.2.1 prelude-ls: 1.2.1
@@ -4027,6 +4155,9 @@ snapshots:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
make-dir@5.1.0:
optional: true
matcher@3.0.0: matcher@3.0.0:
dependencies: dependencies:
escape-string-regexp: 4.0.0 escape-string-regexp: 4.0.0
@@ -4040,6 +4171,9 @@ snapshots:
dependencies: dependencies:
mime-db: 1.52.0 mime-db: 1.52.0
mime@1.6.0:
optional: true
mime@2.6.0: {} mime@2.6.0: {}
mimic-response@1.0.1: {} mimic-response@1.0.1: {}
@@ -4105,6 +4239,9 @@ snapshots:
- supports-color - supports-color
- utf-8-validate - utf-8-validate
ms@2.0.0:
optional: true
ms@2.1.3: {} ms@2.1.3: {}
naive-ui@2.44.1(vue@3.5.39): naive-ui@2.44.1(vue@3.5.39):
@@ -4133,6 +4270,21 @@ snapshots:
natural-compare@1.4.0: {} natural-compare@1.4.0: {}
needle@2.9.1:
dependencies:
debug: 3.2.7
iconv-lite: 0.4.24
sax: 1.6.0
transitivePeerDependencies:
- supports-color
optional: true
needle@3.5.0:
dependencies:
iconv-lite: 0.6.3
sax: 1.6.0
optional: true
node-abi@4.33.0: node-abi@4.33.0:
dependencies: dependencies:
semver: 7.7.4 semver: 7.7.4
@@ -4205,6 +4357,8 @@ snapshots:
dependencies: dependencies:
callsites: 3.1.0 callsites: 3.1.0
parse-node-version@1.0.1: {}
path-exists@4.0.0: {} path-exists@4.0.0: {}
path-is-absolute@1.0.1: {} path-is-absolute@1.0.1: {}
@@ -4252,6 +4406,15 @@ snapshots:
prelude-ls@1.2.1: {} prelude-ls@1.2.1: {}
probe-image-size@7.3.0:
dependencies:
lodash.merge: 4.6.2
needle: 2.9.1
stream-parser: 0.3.1
transitivePeerDependencies:
- supports-color
optional: true
proc-log@6.1.0: {} proc-log@6.1.0: {}
process-nextick-args@2.0.1: {} process-nextick-args@2.0.1: {}
@@ -4271,6 +4434,9 @@ snapshots:
retry: 0.12.0 retry: 0.12.0
signal-exit: 3.0.7 signal-exit: 3.0.7
prr@1.0.1:
optional: true
pump@3.0.4: pump@3.0.4:
dependencies: dependencies:
end-of-stream: 1.4.5 end-of-stream: 1.4.5
@@ -4385,6 +4551,9 @@ snapshots:
safe-buffer@5.2.1: {} safe-buffer@5.2.1: {}
safer-buffer@2.1.2:
optional: true
sanitize-filename@1.6.4: sanitize-filename@1.6.4:
dependencies: dependencies:
truncate-utf8-bytes: 1.0.2 truncate-utf8-bytes: 1.0.2
@@ -4444,6 +4613,13 @@ snapshots:
stat-mode@1.0.0: {} stat-mode@1.0.0: {}
stream-parser@0.3.1:
dependencies:
debug: 2.6.9
transitivePeerDependencies:
- supports-color
optional: true
string-width@4.2.3: string-width@4.2.3:
dependencies: dependencies:
emoji-regex: 8.0.0 emoji-regex: 8.0.0
@@ -4561,7 +4737,7 @@ snapshots:
evtd: 0.2.4 evtd: 0.2.4
vue: 3.5.39 vue: 3.5.39
vite@7.3.6(@types/node@26.1.1)(jiti@2.7.0): vite@7.3.6(@types/node@26.1.1)(jiti@2.7.0)(less@4.7.0):
dependencies: dependencies:
esbuild: 0.28.1 esbuild: 0.28.1
fdir: 6.5.0(picomatch@4.0.5) fdir: 6.5.0(picomatch@4.0.5)
@@ -4573,6 +4749,7 @@ snapshots:
'@types/node': 26.1.1 '@types/node': 26.1.1
fsevents: 2.3.3 fsevents: 2.3.3
jiti: 2.7.0 jiti: 2.7.0
less: 4.7.0
vooks@0.2.12(vue@3.5.39): vooks@0.2.12(vue@3.5.39):
dependencies: dependencies:
+37 -4
View File
@@ -77,6 +77,17 @@ export function registerIpcHandlers() {
return mqttManager.importServers(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) => { ipcMain.handle('mqtt:connect', (_, id) => {
@@ -90,8 +101,8 @@ export function registerIpcHandlers() {
// ==================== 主题管理 ==================== // ==================== 主题管理 ====================
ipcMain.handle('mqtt:add-topic', (_, serverId, topic, qos) => { ipcMain.handle('mqtt:add-topic', (_, serverId, topic, qos, comment) => {
return mqttManager.addTopic(serverId, topic, qos); return mqttManager.addTopic(serverId, topic, qos, comment);
}); });
ipcMain.handle('mqtt:remove-topic', (_, serverId, topic) => { ipcMain.handle('mqtt:remove-topic', (_, serverId, topic) => {
@@ -110,8 +121,8 @@ export function registerIpcHandlers() {
return mqttManager.subscribeAll(serverId); return mqttManager.subscribeAll(serverId);
}); });
ipcMain.handle('mqtt:update-topic', (_, serverId, topicId, topic, qos) => { ipcMain.handle('mqtt:update-topic', (_, serverId, topicId, topic, qos, comment) => {
return mqttManager.updateTopic(serverId, topicId, topic, qos); return mqttManager.updateTopic(serverId, topicId, topic, qos, comment);
}); });
// ==================== 消息发布 ==================== // ==================== 消息发布 ====================
@@ -140,4 +151,26 @@ export function registerIpcHandlers() {
return true; return true;
}); });
// ==================== 安全管理 ====================
ipcMain.handle('mqtt:get-security-status', () => {
return mqttManager.getSecurityStatus();
});
ipcMain.handle('mqtt:setup-master-password', (_, password) => {
return mqttManager.setupMasterPassword(password);
});
ipcMain.handle('mqtt:unlock', (_, password) => {
return mqttManager.unlock(password);
});
ipcMain.handle('mqtt:lock', () => {
return mqttManager.lock();
});
ipcMain.handle('mqtt:remove-master-password', (_, password) => {
return mqttManager.removeMasterPassword(password);
});
} }
+216 -5
View File
@@ -1,6 +1,8 @@
import { randomBytes } from 'crypto';
import { app } from 'electron'; import { app } from 'electron';
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'; import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
import { join } from 'path'; import { join } from 'path';
import { createPasswordVerifier, decryptSecret, deriveKey, encryptSecret, verifyPassword } from './security.js';
import mqtt from 'mqtt'; import mqtt from 'mqtt';
@@ -35,6 +37,10 @@ class MqttManager {
this.listeners = new Set(); // IPC callback functions this.listeners = new Set(); // IPC callback functions
this.msgIdCounter = 0; // 用于生成唯一消息 id this.msgIdCounter = 0; // 用于生成唯一消息 id
this.dataPath = join(app.getPath('userData'), 'frost-mqtt-data.json'); this.dataPath = join(app.getPath('userData'), 'frost-mqtt-data.json');
this.quickSendList = [];
this.security = null;
this.masterKey = null;
this.locked = false;
this.loadData(); this.loadData();
} }
@@ -76,15 +82,30 @@ class MqttManager {
let raw = readFileSync(this.dataPath, 'utf-8'); let raw = readFileSync(this.dataPath, 'utf-8');
let data = JSON.parse(raw); let data = JSON.parse(raw);
this.security = data.security || null;
this.locked = Boolean(this.security?.enabled);
if (data.servers) { if (data.servers) {
data.servers.forEach((s) => { data.servers.forEach((s) => {
let encryptedPassword = s.passwordEncrypted || '';
s.status = 'disconnected'; s.status = 'disconnected';
s.password = this.locked ? '' : (s.password || '');
if (encryptedPassword) {
s.passwordEncrypted = encryptedPassword;
}
s.topics = (s.topics || []).map((t) => ({ s.topics = (s.topics || []).map((t) => ({
...t, ...t,
id: t.id || 'topic_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6), id: t.id || 'topic_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6),
comment: t.comment ?? '',
subscribed: false, subscribed: false,
})); }));
this.servers.set(s.id, s); this.servers.set(s.id, s);
}); });
} }
@@ -100,6 +121,12 @@ class MqttManager {
}); });
} }
if (Array.isArray(data.quickSendList)) {
this.quickSendList = data.quickSendList.filter((item) => {
return item && typeof item.id === 'string' && typeof item.topic === 'string';
});
}
} else { } else {
this.initDefaultServers(); this.initDefaultServers();
} }
@@ -116,13 +143,19 @@ class MqttManager {
try { try {
let data = { let data = {
security: this.security,
servers: Array.from(this.servers.values()).map((s) => ({ servers: Array.from(this.servers.values()).map((s) => ({
...s, ...s,
password: this.security?.enabled ? undefined : (s.password || ''),
passwordEncrypted: this.security?.enabled && s.password
? encryptSecret(s.password, this.masterKey)
: (this.security?.enabled ? s.passwordEncrypted || '' : undefined),
status: 'disconnected', status: 'disconnected',
topics: s.topics.map((t) => ({ ...t, subscribed: false })), topics: s.topics.map((t) => ({ ...t, subscribed: false })),
})), })),
messages: Object.fromEntries(this.messages), messages: Object.fromEntries(this.messages),
publishHistory: Object.fromEntries(this.publishHistory), publishHistory: Object.fromEntries(this.publishHistory),
quickSendList: this.quickSendList,
}; };
writeFileSync(this.dataPath, JSON.stringify(data, null, 2), 'utf-8'); writeFileSync(this.dataPath, JSON.stringify(data, null, 2), 'utf-8');
@@ -154,8 +187,8 @@ class MqttManager {
reconnectInterval: 5000, reconnectInterval: 5000,
status: 'disconnected', status: 'disconnected',
topics: [ topics: [
{ id: 'topic_default_001', topic: 'sensor/+/temperature', qos: 1, subscribed: false }, { id: 'topic_default_001', topic: 'sensor/+/temperature', qos: 1, comment: '', subscribed: false },
{ id: 'topic_default_002', topic: 'device/status', qos: 2, subscribed: false }, { id: 'topic_default_002', topic: 'device/status', qos: 2, comment: '', subscribed: false },
], ],
}, },
]; ];
@@ -205,11 +238,109 @@ class MqttManager {
// ==================== 服务器管理 ==================== // ==================== 服务器管理 ====================
getSecurityStatus() {
return { enabled: Boolean(this.security?.enabled), locked: this.locked };
}
setupMasterPassword(password) {
this.assertPassword(password);
if (this.security?.enabled) {
throw new Error('主密码已设置');
}
let salt = randomBytes(16).toString('base64');
this.masterKey = deriveKey(password, Buffer.from(salt, 'base64'));
this.security = { enabled: true, salt, verifier: createPasswordVerifier(this.masterKey) };
this.locked = false;
this.saveData();
return this.getSecurityStatus();
}
unlock(password) {
if (!this.security?.enabled) {
return this.getSecurityStatus();
};
let key = deriveKey(password, Buffer.from(this.security.salt, 'base64'));
if (!verifyPassword(key, this.security.verifier)) {
throw new Error('主密码错误');
}
try {
this.servers.forEach((server) => {
server.password = server.passwordEncrypted ? decryptSecret(server.passwordEncrypted, key) : '';
});
} catch {
throw new Error('服务器密码解密失败,数据可能已损坏');
}
this.masterKey = key;
this.locked = false;
return this.getSecurityStatus();
}
lock() {
if (!this.security?.enabled || this.locked) {
return this.getSecurityStatus();
}
this.clients.forEach((_, id) => this.disconnect(id));
this.servers.forEach((server) => {
server.passwordEncrypted = server.password
? encryptSecret(server.password, this.masterKey)
: server.passwordEncrypted || '';
server.password = '';
});
this.saveData();
this.masterKey = null;
this.locked = true;
return this.getSecurityStatus();
}
removeMasterPassword(password) {
if (!this.security?.enabled) {
return this.getSecurityStatus();
}
this.unlock(password);
this.security = null;
this.masterKey = null;
this.locked = false;
this.saveData();
return this.getSecurityStatus();
}
assertPassword(password) {
if (typeof password !== 'string' || password.length < 4) {
throw new Error('主密码至少需要 4 个字符');
}
}
assertUnlocked() {
if (this.locked) throw new Error('应用已锁定,请先解锁');
}
/** /**
* 获取所有服务器列表 * 获取所有服务器列表
* @returns {MqttServer[]} 服务器配置数组 * @returns {MqttServer[]} 服务器配置数组
*/ */
getServers() { getServers() {
this.assertUnlocked();
return Array.from(this.servers.values()).map((s) => ({ return Array.from(this.servers.values()).map((s) => ({
...s, ...s,
password: s.password || '', password: s.password || '',
@@ -222,6 +353,7 @@ class MqttManager {
* @returns {MqttServer | null} 服务器配置或 null * @returns {MqttServer | null} 服务器配置或 null
*/ */
getServer(id) { getServer(id) {
this.assertUnlocked();
return this.servers.get(id) || null; return this.servers.get(id) || null;
} }
@@ -272,6 +404,7 @@ class MqttManager {
id: t.id || 'topic_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6), id: t.id || 'topic_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6),
topic: t.topic, topic: t.topic,
qos: t.qos ?? 1, qos: t.qos ?? 1,
comment: t.comment ?? '',
subscribed: false, subscribed: false,
}; };
} }
@@ -283,6 +416,8 @@ class MqttManager {
*/ */
addServer(config) { addServer(config) {
this.assertUnlocked();
let id = 'srv_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6); let id = 'srv_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6);
let server = { let server = {
id, id,
@@ -308,6 +443,8 @@ class MqttManager {
*/ */
updateServer(id, config) { updateServer(id, config) {
this.assertUnlocked();
let server = this.servers.get(id); let server = this.servers.get(id);
if (!server) { if (!server) {
@@ -327,6 +464,7 @@ class MqttManager {
* @param {string} id - 服务器 id * @param {string} id - 服务器 id
*/ */
deleteServer(id) { deleteServer(id) {
this.assertUnlocked();
// 先断开连接 // 先断开连接
this.disconnect(id); this.disconnect(id);
this.servers.delete(id); this.servers.delete(id);
@@ -340,7 +478,11 @@ class MqttManager {
* @returns {ServerConfig[]} 服务器配置数组 * @returns {ServerConfig[]} 服务器配置数组
*/ */
exportServers() { exportServers() {
this.assertUnlocked();
let schemaKeys = Object.keys(this.getServerConfigSchema()); let schemaKeys = Object.keys(this.getServerConfigSchema());
return Array.from(this.servers.values()).map((s) => { return Array.from(this.servers.values()).map((s) => {
let exported = {}; let exported = {};
for (let key of schemaKeys) { for (let key of schemaKeys) {
@@ -349,6 +491,7 @@ class MqttManager {
exported.topics = (s.topics || []).map((t) => this.buildTopicExport(t)); exported.topics = (s.topics || []).map((t) => this.buildTopicExport(t));
return exported; return exported;
}); });
} }
/** /**
@@ -361,6 +504,7 @@ class MqttManager {
id: t.id, id: t.id,
topic: t.topic, topic: t.topic,
qos: t.qos, qos: t.qos,
comment: t.comment ?? '',
}; };
} }
@@ -370,6 +514,9 @@ class MqttManager {
* @returns {MqttServer[]} 导入后的服务器列表 * @returns {MqttServer[]} 导入后的服务器列表
*/ */
importServers(serverList) { importServers(serverList) {
this.assertUnlocked();
if (!Array.isArray(serverList)) { if (!Array.isArray(serverList)) {
throw new Error('导入格式错误:必须是服务器配置数组'); throw new Error('导入格式错误:必须是服务器配置数组');
} }
@@ -385,6 +532,7 @@ class MqttManager {
this.publishHistory.clear(); this.publishHistory.clear();
serverList.forEach((config) => { serverList.forEach((config) => {
if (!config.name || !config.host) { if (!config.name || !config.host) {
return; return;
} }
@@ -400,10 +548,44 @@ class MqttManager {
this.servers.set(id, server); this.servers.set(id, server);
this.messages.set(id, []); this.messages.set(id, []);
this.publishHistory.set(id, []); this.publishHistory.set(id, []);
}); });
this.saveData(); this.saveData();
return this.getServers(); return this.getServers();
}
// ==================== 快速发送 ====================
/**
* 获取快速发送列表
* @returns {QuickSendItem[]} 快速发送项数组
*/
getQuickSendList() {
this.assertUnlocked();
return this.quickSendList;
}
/**
* 保存快速发送列表
* @param {QuickSendItem[]} list - 快速发送项数组
*/
saveQuickSendList(list) {
this.assertUnlocked();
if (!Array.isArray(list)) {
return;
}
this.quickSendList = list.filter((item) => {
return item && typeof item.id === 'string' && typeof item.topic === 'string';
});
this.saveData();
} }
// ==================== 连接管理 ==================== // ==================== 连接管理 ====================
@@ -415,6 +597,8 @@ class MqttManager {
*/ */
connect(id) { connect(id) {
this.assertUnlocked();
let server = this.servers.get(id); let server = this.servers.get(id);
if (!server) { if (!server) {
@@ -516,6 +700,8 @@ class MqttManager {
*/ */
disconnect(id) { disconnect(id) {
this.assertUnlocked();
let client = this.clients.get(id); let client = this.clients.get(id);
if (client) { if (client) {
@@ -575,20 +761,26 @@ class MqttManager {
* @param {string} serverId - 服务器 id * @param {string} serverId - 服务器 id
* @param {string} topic - 主题名称 * @param {string} topic - 主题名称
* @param {MqttQoS} qos - QoS 等级 * @param {MqttQoS} qos - QoS 等级
* @param {string} comment - 主题备注
* @returns {MqttTopic | null} 添加后的主题对象或 null * @returns {MqttTopic | null} 添加后的主题对象或 null
*/ */
addTopic(serverId, topic, qos = 1) { addTopic(serverId, topic, qos = 1, comment = '') {
this.assertUnlocked();
let server = this.servers.get(serverId); let server = this.servers.get(serverId);
if (!server) { if (!server) {
return null; return null;
} }
if (server.topics.find((t) => t.topic === topic)) { if (server.topics.find((t) => t.topic === topic)) {
return null; return null;
} }
let topicId = 'topic_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6); let topicId = 'topic_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6);
let newTopic = { id: topicId, topic, qos, subscribed: false }; let newTopic = { id: topicId, topic, qos, comment, subscribed: false };
server.topics.push(newTopic); server.topics.push(newTopic);
this.saveData(); this.saveData();
@@ -610,6 +802,8 @@ class MqttManager {
*/ */
removeTopic(serverId, topic) { removeTopic(serverId, topic) {
this.assertUnlocked();
let server = this.servers.get(serverId); let server = this.servers.get(serverId);
if (!server) { if (!server) {
@@ -635,6 +829,8 @@ class MqttManager {
*/ */
subscribe(serverId, topic) { subscribe(serverId, topic) {
this.assertUnlocked();
let server = this.servers.get(serverId); let server = this.servers.get(serverId);
if (!server) { if (!server) {
@@ -659,6 +855,8 @@ class MqttManager {
*/ */
unsubscribe(serverId, topic) { unsubscribe(serverId, topic) {
this.assertUnlocked();
let client = this.clients.get(serverId); let client = this.clients.get(serverId);
let server = this.servers.get(serverId); let server = this.servers.get(serverId);
@@ -686,9 +884,12 @@ class MqttManager {
* @param {string} topicId - 主题 id * @param {string} topicId - 主题 id
* @param {string} newTopic - 新的主题名称 * @param {string} newTopic - 新的主题名称
* @param {number} newQos - 新的 QoS 等级 * @param {number} newQos - 新的 QoS 等级
* @param {string} comment - 新的主题备注
* @returns {boolean} 是否更新成功 * @returns {boolean} 是否更新成功
*/ */
updateTopic(serverId, topicId, newTopic, newQos) { updateTopic(serverId, topicId, newTopic, newQos, comment = '') {
this.assertUnlocked();
let server = this.servers.get(serverId); let server = this.servers.get(serverId);
@@ -721,6 +922,7 @@ class MqttManager {
t.topic = newTopic; t.topic = newTopic;
t.qos = newQos; t.qos = newQos;
t.comment = comment;
// 如果已连接且此前已订阅,重新订阅新主题 // 如果已连接且此前已订阅,重新订阅新主题
if (server.status === 'connected' && wasSubscribed) { if (server.status === 'connected' && wasSubscribed) {
@@ -733,6 +935,7 @@ class MqttManager {
topic: newTopic, topic: newTopic,
subscribed: t.subscribed, subscribed: t.subscribed,
topicId, topicId,
comment: t.comment,
}); });
return true; return true;
@@ -746,6 +949,8 @@ class MqttManager {
*/ */
subscribeAll(serverId) { subscribeAll(serverId) {
this.assertUnlocked();
let client = this.clients.get(serverId); let client = this.clients.get(serverId);
let server = this.servers.get(serverId); let server = this.servers.get(serverId);
@@ -796,6 +1001,8 @@ class MqttManager {
*/ */
publish(serverId, topic, payload, opts = {}) { publish(serverId, topic, payload, opts = {}) {
this.assertUnlocked();
let client = this.clients.get(serverId); let client = this.clients.get(serverId);
let server = this.servers.get(serverId); let server = this.servers.get(serverId);
@@ -875,6 +1082,7 @@ class MqttManager {
* @returns {MqttMessage[]} 消息数组 * @returns {MqttMessage[]} 消息数组
*/ */
getMessages(serverId) { getMessages(serverId) {
this.assertUnlocked();
return this.messages.get(serverId) || []; return this.messages.get(serverId) || [];
} }
@@ -884,6 +1092,7 @@ class MqttManager {
* @returns {PublishRecord[]} 发布历史数组 * @returns {PublishRecord[]} 发布历史数组
*/ */
getPublishHistory(serverId) { getPublishHistory(serverId) {
this.assertUnlocked();
return this.publishHistory.get(serverId) || []; return this.publishHistory.get(serverId) || [];
} }
@@ -892,6 +1101,7 @@ class MqttManager {
* @param {string} serverId - 服务器 id * @param {string} serverId - 服务器 id
*/ */
clearMessages(serverId) { clearMessages(serverId) {
this.assertUnlocked();
this.messages.set(serverId, []); this.messages.set(serverId, []);
this.saveData(); this.saveData();
} }
@@ -901,6 +1111,7 @@ class MqttManager {
* @param {string} serverId - 服务器 id * @param {string} serverId - 服务器 id
*/ */
clearPublishHistory(serverId) { clearPublishHistory(serverId) {
this.assertUnlocked();
this.publishHistory.set(serverId, []); this.publishHistory.set(serverId, []);
this.saveData(); this.saveData();
} }
+36
View File
@@ -0,0 +1,36 @@
import { createCipheriv, createDecipheriv, createHash, randomBytes, scryptSync, timingSafeEqual } from 'crypto';
const KEY_LENGTH = 32;
const VERIFIER = 'frost-mqtt-master-password-v1';
export function deriveKey(password, salt) {
return scryptSync(password, salt, KEY_LENGTH, { N: 16384, r: 8, p: 1 });
}
export function createPasswordVerifier(key) {
return createHash('sha256').update(key).update(VERIFIER).digest('hex');
}
export function verifyPassword(key, verifier) {
let expected = Buffer.from(createPasswordVerifier(key), 'hex');
let actual = Buffer.from(verifier || '', 'hex');
return actual.length === expected.length && timingSafeEqual(actual, expected);
}
export function encryptSecret(secret, key) {
let iv = randomBytes(12);
let cipher = createCipheriv('aes-256-gcm', key, iv);
let encrypted = Buffer.concat([cipher.update(secret, 'utf8'), cipher.final()]);
return `v1:${iv.toString('base64')}:${cipher.getAuthTag().toString('base64')}:${encrypted.toString('base64')}`;
}
export function decryptSecret(value, key) {
let parts = String(value || '').split(':');
if (parts.length !== 4 || parts[0] !== 'v1') throw new Error('加密数据格式无效');
let decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(parts[1], 'base64'));
decipher.setAuthTag(Buffer.from(parts[2], 'base64'));
return Buffer.concat([
decipher.update(Buffer.from(parts[3], 'base64')),
decipher.final(),
]).toString('utf8');
}
+13 -3
View File
@@ -8,6 +8,11 @@ import { electronAPI } from '@electron-toolkit/preload';
const api = { const api = {
// 服务器管理 // 服务器管理
getSecurityStatus: () => ipcRenderer.invoke('mqtt:get-security-status'),
setupMasterPassword: (password) => ipcRenderer.invoke('mqtt:setup-master-password', password),
unlock: (password) => ipcRenderer.invoke('mqtt:unlock', password),
lock: () => ipcRenderer.invoke('mqtt:lock'),
removeMasterPassword: (password) => ipcRenderer.invoke('mqtt:remove-master-password', password),
getServers: () => ipcRenderer.invoke('mqtt:get-servers'), getServers: () => ipcRenderer.invoke('mqtt:get-servers'),
addServer: (config) => ipcRenderer.invoke('mqtt:add-server', config), addServer: (config) => ipcRenderer.invoke('mqtt:add-server', config),
updateServer: (id, config) => ipcRenderer.invoke('mqtt:update-server', id, config), updateServer: (id, config) => ipcRenderer.invoke('mqtt:update-server', id, config),
@@ -15,18 +20,23 @@ const api = {
exportServers: () => ipcRenderer.invoke('mqtt:export-servers'), exportServers: () => ipcRenderer.invoke('mqtt:export-servers'),
importServers: (serverList) => ipcRenderer.invoke('mqtt:import-servers', serverList), 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), connect: (id) => ipcRenderer.invoke('mqtt:connect', id),
disconnect: (id) => ipcRenderer.invoke('mqtt:disconnect', id), disconnect: (id) => ipcRenderer.invoke('mqtt:disconnect', id),
// 主题管理 // 主题管理
addTopic: (serverId, topic, qos) => ipcRenderer.invoke('mqtt:add-topic', serverId, topic, qos), addTopic: (serverId, topic, qos, comment) =>
ipcRenderer.invoke('mqtt:add-topic', serverId, topic, qos, comment),
removeTopic: (serverId, topic) => ipcRenderer.invoke('mqtt:remove-topic', serverId, topic), removeTopic: (serverId, topic) => ipcRenderer.invoke('mqtt:remove-topic', serverId, topic),
subscribe: (serverId, topic) => ipcRenderer.invoke('mqtt:subscribe', serverId, topic), subscribe: (serverId, topic) => ipcRenderer.invoke('mqtt:subscribe', serverId, topic),
unsubscribe: (serverId, topic) => ipcRenderer.invoke('mqtt:unsubscribe', serverId, topic), unsubscribe: (serverId, topic) => ipcRenderer.invoke('mqtt:unsubscribe', serverId, topic),
subscribeAll: (serverId) => ipcRenderer.invoke('mqtt:subscribe-all', serverId), subscribeAll: (serverId) => ipcRenderer.invoke('mqtt:subscribe-all', serverId),
updateTopic: (serverId, topicId, topic, qos) => updateTopic: (serverId, topicId, topic, qos, comment) =>
ipcRenderer.invoke('mqtt:update-topic', serverId, topicId, topic, qos), ipcRenderer.invoke('mqtt:update-topic', serverId, topicId, topic, qos, comment),
// 消息发布 // 消息发布
publish: (serverId, topic, payload, opts) => publish: (serverId, topic, payload, opts) =>
+158 -44
View File
@@ -1,14 +1,16 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue'; import { ref, onMounted } from 'vue';
import { NLayout, NMessageProvider, NDialogProvider, NConfigProvider, NTabs, NTabPane, zhCN, dateZhCN } from 'naive-ui'; import { NLayout, NMessageProvider, NDialogProvider, NConfigProvider, NTabs, NTabPane, NCard, NInput, NButton, NSpace, zhCN, dateZhCN } from 'naive-ui';
import { useMqttStore } from './stores/mqtt.js'; import { useMqttStore } from './stores/mqtt.js';
import Sidebar from './components/Sidebar.vue'; import Sidebar from './components/Sidebar.vue';
import ServerModal from './components/ServerModal.vue'; import ServerModal from './components/ServerModal.vue';
import SecurityModal from './components/SecurityModal.vue';
import Dashboard from './views/Dashboard.vue'; import Dashboard from './views/Dashboard.vue';
import Subscribe from './views/Subscribe.vue'; import Subscribe from './views/Subscribe.vue';
import Publish from './views/Publish.vue'; import Publish from './views/Publish.vue';
import Messages from './views/Messages.vue'; import Messages from './views/Messages.vue';
import QuickSend from './views/QuickSend.vue';
// 全局状态 // 全局状态
const store = useMqttStore(); const store = useMqttStore();
@@ -39,13 +41,19 @@ const themeOverrides = {
}, },
}; };
// 当前激活的标签页 /** 当前激活的标签页 */
const activeTab = ref('dashboard'); const activeTab = ref('dashboard');
// 是否显示服务器编辑弹窗
/** 是否显示服务器编辑弹窗 */
const showServerModal = ref(false); const showServerModal = ref(false);
// 当前编辑的服务器数据,null 表示新增
/** 当前编辑的服务器数据,null 表示新增 */
const editServerData = ref(null); const editServerData = ref(null);
const showSecurityModal = ref(false);
const unlockPassword = ref('');
const unlockError = ref('');
// 组件挂载时初始化 Store // 组件挂载时初始化 Store
onMounted(async () => { onMounted(async () => {
await store.init(); await store.init();
@@ -75,6 +83,16 @@ function handleServerSaved() {
// 刷新数据 // 刷新数据
} }
async function handleUnlock() {
try {
await store.unlock(unlockPassword.value);
unlockPassword.value = '';
unlockError.value = '';
} catch (e) {
unlockError.value = e.message || '解锁失败';
}
}
/** /**
* 切换标签页 * 切换标签页
* @param {string} tab - 标签页名称 * @param {string} tab - 标签页名称
@@ -102,10 +120,15 @@ function handleSwitchTab(tab) {
<Sidebar <Sidebar
@add-server="handleAddServer" @add-server="handleAddServer"
@edit-server="handleEditServer" @edit-server="handleEditServer"
@lock="store.lock"
@security="showSecurityModal = true"
/> />
<!-- 主内容区 --> <!-- 主内容区 -->
<n-layout content-class="main-layout-content"> <n-layout
v-if="!store.security.value.locked"
content-class="main-layout-content"
>
<n-tabs <n-tabs
v-model:value="activeTab" v-model:value="activeTab"
type="line" type="line"
@@ -148,6 +171,14 @@ function handleSwitchTab(tab) {
</div> </div>
</div> </div>
</n-tab-pane> </n-tab-pane>
<n-tab-pane
name="quick-send"
tab="快速发送"
>
<div class="tab-content">
<QuickSend />
</div>
</n-tab-pane>
</n-tabs> </n-tabs>
</n-layout> </n-layout>
@@ -156,14 +187,78 @@ function handleSwitchTab(tab) {
:edit-server="editServerData" :edit-server="editServerData"
@saved="handleServerSaved" @saved="handleServerSaved"
/> />
<SecurityModal v-model:show="showSecurityModal" />
</n-layout> </n-layout>
<div
v-if="store.security.value.locked"
class="lock-screen"
>
<n-card
title="应用已锁定"
class="lock-card"
>
<p>请输入主密码以继续使用 Frost MQTT Client</p>
<n-input
v-model:value="unlockPassword"
type="password"
show-password-on="click"
autofocus
@keyup.enter="handleUnlock"
/>
<div
v-if="unlockError"
class="lock-error"
>{{ unlockError }}</div>
<n-space
justify="end"
style="margin-top: 16px"
>
<n-button
type="primary"
:disabled="!unlockPassword"
@click="handleUnlock"
>解锁</n-button>
</n-space>
</n-card>
</div>
</n-dialog-provider> </n-dialog-provider>
</n-message-provider> </n-message-provider>
</n-config-provider> </n-config-provider>
</template> </template>
<style> <style lang="less">
:root {
--frost-primary-color: #0ea5a0;
--frost-primary-color-hover: #0b8c87;
--frost-primary-color-pressed: #0b8c87;
--frost-primary-color-suppl: #0ea5a0;
--frost-success-color: #22c55e;
--frost-success-color-hover: #16a34a;
--frost-success-color-pressed: #15803d;
--frost-success-color-suppl: #22c55e;
--frost-warning-color: #f59e0b;
--frost-warning-color-hover: #d97706;
--frost-warning-color-pressed: #b45309;
--frost-warning-color-suppl: #f59e0b;
--frost-error-color: #ef4444;
--frost-error-color-hover: #dc2626;
--frost-error-color-pressed: #b91c1c;
--frost-error-color-suppl: #ef4444;
--frost-info-color: #3b82f6;
--frost-info-color-hover: #2563eb;
--frost-info-color-pressed: #1d4ed8;
--frost-info-color-suppl: #3b82f6;
--frost-color-text-tertiary: #999;
--frost-color-topic: #d25a00;
--frost-color-bg-light: #f5f7fa;
--frost-color-bg-item: #fafafa;
--frost-shadow-black-4: rgba(0, 0, 0, 0.04);
--frost-shadow-black-6: rgba(0, 0, 0, 0.06);
--frost-shadow-black-8: rgba(0, 0, 0, 0.08);
--frost-shadow-black-12: rgba(0, 0, 0, 0.12);
}
html, body, #app { html, body, #app {
margin: 0; margin: 0;
padding: 0; padding: 0;
@@ -175,17 +270,33 @@ html, body, #app {
overflow: hidden; overflow: hidden;
} }
.lock-screen {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: color-mix(in srgb, var(--n-color) 88%, transparent);
backdrop-filter: blur(2px);
z-index: 10;
}
.lock-card {
width: 360px;
max-width: 90vw;
}
.tab-content { .tab-content {
box-sizing: border-box; box-sizing: border-box;
padding: 0 0 16px 0; padding: 0 0 16px 0;
height: calc(100vh - 50px); height: calc(100vh - 50px);
overflow: hidden; overflow: hidden;
}
.dashboard-content { &.dashboard-content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
}
} }
.dashboard-scroll-wrapper { .dashboard-scroll-wrapper {
@@ -201,29 +312,27 @@ html, body, #app {
gap: 12px; gap: 12px;
height: 100%; height: 100%;
min-width: 0; min-width: 0;
}
.publish-panel, .publish-panel,
.messages-panel { .messages-panel {
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
} }
.publish-panel { .publish-panel {
box-sizing: border-box; box-sizing: border-box;
height: 100%; height: 100%;
min-height: 0; min-height: 0;
overflow: hidden; overflow: hidden;
} }
.messages-panel { .messages-panel {
height: 100%; height: 100%;
min-height: 0; min-height: 0;
overflow: hidden; overflow: hidden;
} }
@media (max-width: 900px) { @media (max-width: 900px) {
.message-center-layout {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
@@ -233,22 +342,27 @@ html, body, #app {
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: rgba(144, 147, 153, 0.5) transparent; scrollbar-color: rgba(144, 147, 153, 0.5) transparent;
} }
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 8px; width: 8px;
height: 8px; height: 8px;
}
::-webkit-scrollbar-track { &-track {
background-color: transparent; background-color: transparent;
} }
::-webkit-scrollbar-thumb {
background-color: rgba(144, 147, 153, 0.5); &-thumb {
border-radius: 4px; background-color: rgba(144, 147, 153, 0.5);
} border-radius: 4px;
::-webkit-scrollbar-thumb:hover {
background-color: rgba(144, 147, 153, 0.8); &:hover {
} background-color: rgba(144, 147, 153, 0.8);
::-webkit-scrollbar-corner { }
display: none; }
background-color: transparent;
&-corner {
display: none;
background-color: transparent;
}
} }
</style> </style>
@@ -0,0 +1,96 @@
<script setup>
import { ref } from 'vue';
import { NModal, NCard, NForm, NFormItem, NInput, NButton, NSpace, useMessage } from 'naive-ui';
import { useMqttStore } from '../stores/mqtt.js';
defineProps({
show: Boolean,
});
const emit = defineEmits(['update:show']);
const store = useMqttStore();
const msg = useMessage();
const password = ref('');
const confirmPassword = ref('');
async function save() {
if (password.value.length < 4 || password.value !== confirmPassword.value) {
msg.error(password.value.length < 4 ? '主密码至少需要 4 个字符' : '两次输入的主密码不一致');
return;
}
try {
await store.setupMasterPassword(password.value);
password.value = '';
confirmPassword.value = '';
emit('update:show', false);
msg.success('主密码已设置,服务器密码已加密保存');
} catch (e) {
msg.error(e.message || '设置失败');
}
}
async function remove() {
try {
await store.removeMasterPassword(password.value);
password.value = '';
confirmPassword.value = '';
emit('update:show', false);
msg.success('主密码已移除');
} catch (e) {
msg.error(e.message || '移除失败');
}
}
</script>
<template>
<n-modal
:show="show"
:mask-closable="false"
@update:show="$emit('update:show', $event)"
>
<n-card
style="width: 420px; max-width: 90vw"
:title="store.security.value.enabled ? '移除主密码' : '设置主密码'"
>
<n-form label-placement="top">
<n-form-item label="主密码">
<n-input
v-model:value="password"
type="password"
show-password-on="click"
autofocus
/>
</n-form-item>
<n-form-item
v-if="!store.security.value.enabled"
label="确认主密码"
>
<n-input
v-model:value="confirmPassword"
type="password"
show-password-on="click"
@keyup.enter="save"
/>
</n-form-item>
</n-form>
<template #footer>
<n-space justify="end">
<n-button @click="$emit('update:show', false)">取消</n-button>
<n-button
v-if="store.security.value.enabled"
type="error"
@click="remove"
>移除并解密保存</n-button>
<n-button
v-else
type="primary"
@click="save"
>设置</n-button>
</n-space>
</template>
</n-card>
</n-modal>
</template>
+55 -35
View File
@@ -4,7 +4,7 @@ import { NLayoutSider, NButton, NTag, useMessage, useDialog } from 'naive-ui';
import { useMqttStore } from '../stores/mqtt.js'; import { useMqttStore } from '../stores/mqtt.js';
// 组件事件定义 // 组件事件定义
const emit = defineEmits(['add-server', 'edit-server']); const emit = defineEmits(['add-server', 'edit-server', 'lock', 'security']);
// 全局状态与消息/对话框 API // 全局状态与消息/对话框 API
const store = useMqttStore(); const store = useMqttStore();
@@ -203,6 +203,22 @@ async function handleImportFile(event) {
@click="handleExport" @click="handleExport"
>导出配置</n-button> >导出配置</n-button>
</div> </div>
<div
class="sidebar-import-export"
:class="{ 'sidebar-single-action': !store.security.value.enabled }"
>
<n-button
v-if="store.security.value.enabled"
size="small"
block
@click="emit('lock')"
>锁定应用</n-button>
<n-button
size="small"
block
@click="emit('security')"
>{{ store.security.value.enabled ? '主密码设置' : '设置主密码' }}</n-button>
</div>
<input <input
ref="fileInput" ref="fileInput"
type="file" type="file"
@@ -216,7 +232,7 @@ async function handleImportFile(event) {
</n-layout-sider> </n-layout-sider>
</template> </template>
<style scoped> <style scoped lang="less">
:deep(.sidebar-scroll-container) { :deep(.sidebar-scroll-container) {
height: 100%; height: 100%;
overflow: hidden !important; overflow: hidden !important;
@@ -242,7 +258,7 @@ async function handleImportFile(event) {
.sidebar-logo { .sidebar-logo {
width: 32px; width: 32px;
height: 32px; height: 32px;
background: #0ea5a0; background: var(--frost-primary-color);
border-radius: 6px; border-radius: 6px;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -278,15 +294,19 @@ async function handleImportFile(event) {
margin-bottom: 4px; margin-bottom: 4px;
border: 1px solid transparent; border: 1px solid transparent;
transition: all 0.15s; transition: all 0.15s;
}
.server-item:hover { &:hover {
background: var(--n-color-hover); background: var(--n-color-hover);
}
.server-item.active { .server-actions {
background: var(--n-color-pressed); opacity: 1;
border-color: #0ea5a0; }
}
&.active {
background: var(--n-color-pressed);
border-color: var(--frost-primary-color);
}
} }
.server-item-top { .server-item-top {
@@ -310,23 +330,19 @@ async function handleImportFile(event) {
transition: opacity 0.15s; transition: opacity 0.15s;
} }
.server-item:hover .server-actions {
opacity: 1;
}
.server-url { .server-url {
margin-top: 4px; margin-top: 4px;
overflow: hidden; overflow: hidden;
}
.server-url :deep(.n-tag) { :deep(.n-tag) {
max-width: 100%; max-width: 100%;
} }
.server-url :deep(.n-tag__content) { :deep(.n-tag__content) {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
}
} }
.server-bottom { .server-bottom {
@@ -347,23 +363,23 @@ async function handleImportFile(event) {
height: 6px; height: 6px;
border-radius: 50%; border-radius: 50%;
margin-right: 2px; margin-right: 2px;
}
.status-dot.connected { &.connected {
background: #22c55e; background: var(--frost-success-color);
} }
.status-dot.connecting { &.connecting {
background: #f59e0b; background: var(--frost-warning-color);
animation: pulse 1s infinite; animation: pulse 1s infinite;
} }
.status-dot.disconnected { &.disconnected {
background: #999; background: var(--frost-color-text-tertiary);
} }
.status-dot.error { &.error {
background: #ef4444; background: var(--frost-error-color);
}
} }
@keyframes pulse { @keyframes pulse {
@@ -389,4 +405,8 @@ async function handleImportFile(event) {
gap: 8px; gap: 8px;
margin-top: 8px; margin-top: 8px;
} }
.sidebar-single-action {
grid-template-columns: 1fr;
}
</style> </style>
+67 -6
View File
@@ -33,6 +33,7 @@ import { ref, computed } from 'vue';
* @property {string} serverId 所属服务器 id * @property {string} serverId 所属服务器 id
* @property {string} topic 主题名称 * @property {string} topic 主题名称
* @property {string} [topicId] 主题 id * @property {string} [topicId] 主题 id
* @property {string} [comment] 主题备注
* @property {boolean} subscribed 是否已订阅 * @property {boolean} subscribed 是否已订阅
*/ */
@@ -57,12 +58,12 @@ import { ref, computed } from 'vue';
* @property {(serverList: ServerConfig[]) => Promise<MqttServer[]>} importServers 导入服务器配置 * @property {(serverList: ServerConfig[]) => Promise<MqttServer[]>} importServers 导入服务器配置
* @property {(id: string) => Promise<boolean>} connectServer 连接服务器 * @property {(id: string) => Promise<boolean>} connectServer 连接服务器
* @property {(id: string) => Promise<void>} disconnectServer 断开服务器 * @property {(id: string) => Promise<void>} disconnectServer 断开服务器
* @property {(serverId: string, topic: string, qos: MqttQoS) => Promise<MqttTopic | null>} addTopic 添加主题 * @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<void>} removeTopic 移除主题
* @property {(serverId: string, topic: string) => Promise<boolean>} subscribeTopic 订阅主题 * @property {(serverId: string, topic: string) => Promise<boolean>} subscribeTopic 订阅主题
* @property {(serverId: string, topic: string) => Promise<boolean>} unsubscribeTopic 取消订阅主题 * @property {(serverId: string, topic: string) => Promise<boolean>} unsubscribeTopic 取消订阅主题
* @property {(serverId: string) => Promise<boolean>} subscribeAllTopics 批量订阅所有主题 * @property {(serverId: string) => Promise<boolean>} subscribeAllTopics 批量订阅所有主题
* @property {(serverId: string, topicId: string, topic: string, qos: MqttQoS) => Promise<boolean>} updateTopic 更新主题 * @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 {(serverId: string, topic: string, payload: string, opts: PublishOptions) => Promise<boolean>} publishMessage 发布消息
* @property {() => Promise<void>} refreshMessages 刷新消息列表 * @property {() => Promise<void>} refreshMessages 刷新消息列表
* @property {() => Promise<void>} refreshPublishHistory 刷新发布历史 * @property {() => Promise<void>} refreshPublishHistory 刷新发布历史
@@ -102,12 +103,25 @@ const publishHistory = ref([]);
*/ */
const loading = ref(false); const loading = ref(false);
const security = ref({ enabled: false, locked: false });
/** /**
* IPC 监听器清理函数集合 * IPC 监听器清理函数集合
* @type {Array<() => void>} * @type {Array<() => void>}
*/ */
let cleanupFns = []; let cleanupFns = [];
/**
* 对指定服务器的主题列表按主题名称升序排序
* @param {MqttServer} server - 服务器对象
* @returns {void}
*/
function sortTopics(server) {
if (server && server.topics) {
server.topics.sort((a, b) => a.topic.localeCompare(b.topic));
}
}
/** /**
* 设置主进程事件监听器 * 设置主进程事件监听器
* 包括状态变化、消息接收、订阅变化等事件 * 包括状态变化、消息接收、订阅变化等事件
@@ -170,8 +184,12 @@ function setupListeners() {
if (t) { if (t) {
t.subscribed = data.subscribed; t.subscribed = data.subscribed;
if (typeof data.comment === 'string') {
t.comment = data.comment;
}
if (data.topic && data.topic !== t.topic) { if (data.topic && data.topic !== t.topic) {
t.topic = data.topic; t.topic = data.topic;
sortTopics(srv);
} }
} }
@@ -248,10 +266,17 @@ export function useMqttStore() {
try { try {
security.value = await window.api.getSecurityStatus();
if (security.value.locked) {
return;
};
let data = await window.api.getServers(); let data = await window.api.getServers();
servers.value = data; servers.value = data;
sortServers(); sortServers();
servers.value.forEach((srv) => sortTopics(srv));
setupListeners(); setupListeners();
} catch (e) { } catch (e) {
@@ -262,6 +287,32 @@ export function useMqttStore() {
} }
async function unlock(password) {
await window.api.unlock(password);
security.value = await window.api.getSecurityStatus();
let data = await window.api.getServers();
servers.value = data;
sortServers();
servers.value.forEach((srv) => sortTopics(srv));
setupListeners();
}
async function setupMasterPassword(password) {
security.value = await window.api.setupMasterPassword(password);
}
async function lock() {
security.value = await window.api.lock();
servers.value = [];
activeServerId.value = null;
messages.value = [];
publishHistory.value = [];
}
async function removeMasterPassword(password) {
security.value = await window.api.removeMasterPassword(password);
}
// ==================== 服务器管理 ==================== // ==================== 服务器管理 ====================
/** /**
@@ -355,6 +406,7 @@ export function useMqttStore() {
let data = await window.api.importServers(serverList); let data = await window.api.importServers(serverList);
servers.value = data; servers.value = data;
sortServers(); sortServers();
servers.value.forEach((srv) => sortTopics(srv));
if (servers.value.length > 0 && !activeServerId.value) { if (servers.value.length > 0 && !activeServerId.value) {
activeServerId.value = servers.value[0].id; activeServerId.value = servers.value[0].id;
} }
@@ -386,16 +438,18 @@ export function useMqttStore() {
* @param {string} serverId - 服务器 id * @param {string} serverId - 服务器 id
* @param {string} topic - 主题名称 * @param {string} topic - 主题名称
* @param {MqttQoS} qos - QoS 等级 * @param {MqttQoS} qos - QoS 等级
* @param {string} comment - 主题备注
* @returns {Promise<MqttTopic | null>} 添加后的主题对象或 null * @returns {Promise<MqttTopic | null>} 添加后的主题对象或 null
*/ */
async function addTopic(serverId, topic, qos) { async function addTopic(serverId, topic, qos, comment) {
let result = await window.api.addTopic(serverId, topic, qos); let result = await window.api.addTopic(serverId, topic, qos, comment);
if (result) { if (result) {
let srv = servers.value.find((s) => s.id === serverId); let srv = servers.value.find((s) => s.id === serverId);
if (srv) { if (srv) {
srv.topics.push(result); srv.topics.push(result);
sortTopics(srv);
} }
} }
@@ -417,6 +471,7 @@ export function useMqttStore() {
if (srv) { if (srv) {
srv.topics = srv.topics.filter((t) => t.topic !== topic); srv.topics = srv.topics.filter((t) => t.topic !== topic);
sortTopics(srv);
} }
} }
@@ -456,10 +511,11 @@ export function useMqttStore() {
* @param {string} topicId - 主题 id * @param {string} topicId - 主题 id
* @param {string} topic - 新的主题名称 * @param {string} topic - 新的主题名称
* @param {MqttQoS} qos - 新的 QoS 等级 * @param {MqttQoS} qos - 新的 QoS 等级
* @param {string} comment - 新的主题备注
* @returns {Promise<boolean>} 是否更新成功 * @returns {Promise<boolean>} 是否更新成功
*/ */
async function updateTopic(serverId, topicId, topic, qos) { async function updateTopic(serverId, topicId, topic, qos, comment) {
return await window.api.updateTopic(serverId, topicId, topic, qos); return await window.api.updateTopic(serverId, topicId, topic, qos, comment);
} }
// ==================== 消息发布 ==================== // ==================== 消息发布 ====================
@@ -536,6 +592,7 @@ export function useMqttStore() {
messages, messages,
publishHistory, publishHistory,
loading, loading,
security,
// 计算属性 // 计算属性
connectedServers, connectedServers,
totalTopics, totalTopics,
@@ -543,6 +600,10 @@ export function useMqttStore() {
totalMessages, totalMessages,
// 方法 // 方法
init, init,
unlock,
setupMasterPassword,
lock,
removeMasterPassword,
setActiveServer, setActiveServer,
addServer, addServer,
updateServer, updateServer,
+28 -26
View File
@@ -189,25 +189,27 @@ async function handleToggleConnection(srv) {
</div> </div>
</template> </template>
<style scoped> <style scoped lang="less">
.dashboard { .dashboard {
max-width: 900px; max-width: 900px;
} }
.server-descriptions :deep(.n-descriptions-table) { .server-descriptions {
table-layout: fixed; :deep(.n-descriptions-table) {
width: 100%; table-layout: fixed;
} width: 100%;
}
.server-descriptions :deep(.n-descriptions-table-header), :deep(.n-descriptions-table-header),
.server-descriptions :deep(.n-descriptions-table-content) { :deep(.n-descriptions-table-content) {
width: 25%; width: 25%;
} }
.server-descriptions :deep(.n-descriptions-table-content) { :deep(.n-descriptions-table-content) {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
}
} }
.status-dot { .status-dot {
@@ -215,23 +217,23 @@ async function handleToggleConnection(srv) {
width: 8px; width: 8px;
height: 8px; height: 8px;
border-radius: 50%; border-radius: 50%;
}
.status-dot.connected { &.connected {
background: #22c55e; background: var(--frost-success-color);
} }
.status-dot.connecting { &.connecting {
background: #f59e0b; background: var(--frost-warning-color);
animation: pulse 1s infinite; animation: pulse 1s infinite;
} }
.status-dot.disconnected { &.disconnected {
background: #999; background: var(--frost-color-text-tertiary);
} }
.status-dot.error { &.error {
background: #ef4444; background: var(--frost-error-color);
}
} }
@keyframes pulse { @keyframes pulse {
+100 -47
View File
@@ -1,14 +1,14 @@
<script setup> <script setup>
import { ref, computed } from 'vue'; import { ref, computed } from 'vue';
import { useMqttStore } from '../stores/mqtt.js'; import { useMqttStore } from '../stores/mqtt.js';
import { NCard, NSpace, NInput, NButton, NEmpty, useMessage } from 'naive-ui'; import { NCard, NSelect, NButton, NEmpty, useMessage } from 'naive-ui';
// 全局状态与消息 API // 全局状态与消息 API
const store = useMqttStore(); const store = useMqttStore();
const message = useMessage(); const message = useMessage();
// 主题过滤关键字 // 已选中的过滤主题列表
const filter = ref(''); const filter = ref([]);
/** /**
* 复制主题到剪贴板 * 复制主题到剪贴板
@@ -24,22 +24,65 @@ async function copyTopic(topic) {
} }
/** /**
* 根据过滤关键字筛选后的消息列表 * 可过滤的主题选项(来自已配置主题和已接收消息)
* @type {import('vue').ComputedRef<Array<{label: string, value: string}>>}
*/
const topicOptions = computed(() => {
let server = store.activeServer.value;
let serverTopics = server?.topics?.map((t) => t.topic) || [];
let messageTopics = store.messages.value.map((m) => m.topic);
let all = [...new Set([...serverTopics, ...messageTopics])];
all.sort((a, b) => a.localeCompare(b));
return all.map((topic) => ({ label: topic, value: topic }));
});
/**
* 判断消息主题是否匹配指定的过滤主题(支持 + 单层通配符与 # 多层通配符)
* @param {string} filterTopic - 过滤主题,可包含 + 或 #
* @param {string} messageTopic - 消息主题
* @returns {boolean} 是否匹配
*/
function topicMatchesFilter(filterTopic, messageTopic) {
if (filterTopic === '#') {
return true;
}
if (filterTopic.endsWith('/#')) {
let prefix = filterTopic.slice(0, -2);
return messageTopic === prefix || messageTopic.startsWith(prefix + '/');
}
let filterParts = filterTopic.split('/');
let messageParts = messageTopic.split('/');
if (filterParts.length !== messageParts.length) {
return false;
}
return filterParts.every((part, index) => part === '+' || part === messageParts[index]);
}
/**
* 根据选中主题筛选后的消息列表(支持 + / # 通配符)
* @type {import('vue').ComputedRef<Array<Object>>} * @type {import('vue').ComputedRef<Array<Object>>}
*/ */
const filteredMessages = computed(() => { const filteredMessages = computed(() => {
if (!filter.value.trim()) { if (!filter.value || filter.value.length === 0) {
return store.messages.value; return store.messages.value;
} }
let f = filter.value.trim().toLowerCase(); return store.messages.value.filter((m) =>
filter.value.some((f) => topicMatchesFilter(f, m.topic))
);
return store.messages.value.filter((m) => { });
return m.topic.toLowerCase().includes(f);
});
})
/** /**
* 清空当前活动服务器的消息列表 * 清空当前活动服务器的消息列表
@@ -79,10 +122,8 @@ function formatPayload(payload) {
}" }"
> >
<template #header> <template #header>
<n-space <div
align="center" style="display: flex; flex-direction: column; gap: 8px; width: 100%"
justify="space-between"
style="width: 100%"
> >
<div> <div>
<span>消息监控</span> <span>消息监控</span>
@@ -90,20 +131,26 @@ function formatPayload(payload) {
style="font-size: 12px; opacity: 0.55; margin-left: 8px" style="font-size: 12px; opacity: 0.55; margin-left: 8px"
>实时查看接收和发送的 MQTT 消息</span> >实时查看接收和发送的 MQTT 消息</span>
</div> </div>
<n-space> <div
<n-input style="display: flex; align-items: center; gap: 8px; width: 100%"
>
<n-select
v-model:value="filter" v-model:value="filter"
size="small" size="small"
placeholder="过滤主题..." placeholder="过滤主题..."
style="width: 180px" style="flex: 1 1 auto; max-width: 480px; min-width: 0"
multiple
filterable
clearable clearable
max-tag-count="responsive"
:options="topicOptions"
/> />
<n-button <n-button
size="small" size="small"
@click="handleClear" @click="handleClear"
>清空消息</n-button> >清空消息</n-button>
</n-space> </div>
</n-space> </div>
</template> </template>
<n-empty <n-empty
v-if="filteredMessages.length === 0" v-if="filteredMessages.length === 0"
@@ -154,29 +201,29 @@ function formatPayload(payload) {
</template> </template>
<style scoped> <style scoped lang="less">
.messages-view { .messages-view {
width: 100%; width: 100%;
height: 100%; height: 100%;
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
}
.messages-view :deep(.n-card) { :deep(.n-card) {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
min-height: 0; min-height: 0;
} }
.messages-view :deep(.n-card__content) { :deep(.n-card__content) {
flex: 1 1 auto; flex: 1 1 auto;
min-height: 0; min-height: 0;
overflow: hidden; overflow: hidden;
}
} }
.msg-log { .msg-log {
background: #f5f7fa; background: var(--frost-color-bg-light);
border-radius: 6px; border-radius: 6px;
padding: 12px; padding: 12px;
height: 100%; height: 100%;
@@ -192,7 +239,7 @@ function formatPayload(payload) {
.msg-entry { .msg-entry {
padding: 8px 0; padding: 8px 0;
border-bottom: 1px solid rgba(0, 0, 0, 0.06); border-bottom: 1px solid var(--frost-shadow-black-6);
} }
.msg-header { .msg-header {
@@ -203,45 +250,51 @@ function formatPayload(payload) {
} }
.msg-time { .msg-time {
color: #999; color: var(--frost-color-text-tertiary);
flex-shrink: 0; flex-shrink: 0;
} }
.msg-dir { .msg-dir {
flex-shrink: 0; flex-shrink: 0;
font-weight: 700; font-weight: 700;
width: 28px; width: 28px;
&.pub {
color: var(--frost-info-color);
}
&.sub {
color: var(--frost-success-color);
}
} }
.msg-dir.pub {
color: #3b82f6;
}
.msg-dir.sub {
color: #22c55e;
}
.msg-topic { .msg-topic {
color: #d25a00; color: var(--frost-color-topic);
min-width: 0; min-width: 0;
flex: 1; flex: 1;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
cursor: pointer; cursor: pointer;
&:hover {
text-decoration: underline;
}
} }
.msg-topic:hover {
text-decoration: underline;
}
.msg-payload { .msg-payload {
margin: 6px 0 0; margin: 6px 0 0;
padding: 8px 10px; padding: 8px 10px;
border-radius: 4px; border-radius: 4px;
background: rgba(0, 0, 0, 0.04); background: var(--frost-shadow-black-4);
color: #333; color: #333;
font: inherit; font: inherit;
white-space: pre-wrap; white-space: pre-wrap;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.msg-meta { .msg-meta {
color: #999; color: var(--frost-color-text-tertiary);
font-size: 11px; font-size: 11px;
flex-shrink: 0; flex-shrink: 0;
} }
+75 -75
View File
@@ -299,7 +299,7 @@ async function copyTopic(topic) {
</template> </template>
<style scoped> <style scoped lang="less">
.publish-view { .publish-view {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -307,61 +307,61 @@ async function copyTopic(topic) {
height: 100%; height: 100%;
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
}
.publish-view :deep(.n-tabs) { :deep(.n-tabs) {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
min-height: 0; min-height: 0;
} }
.publish-view :deep(.n-tabs-nav) { :deep(.n-tabs-nav) {
flex-shrink: 0; flex-shrink: 0;
} }
.publish-view :deep(.n-tabs-pane-wrapper) { :deep(.n-tabs-pane-wrapper) {
flex: 1 1 auto; flex: 1 1 auto;
min-height: 0; min-height: 0;
overflow: hidden; overflow: hidden;
} }
.publish-view :deep(.n-tabs-tab-panels), :deep(.n-tabs-tab-panels),
.publish-view :deep(.n-tabs-pane-wrapper) { :deep(.n-tabs-pane-wrapper) {
height: 100%; height: 100%;
min-height: 0; min-height: 0;
} }
.publish-view :deep(.n-tab-pane) { :deep(.n-tab-pane) {
box-sizing: border-box; box-sizing: border-box;
height: 100%; height: 100%;
min-height: 0; min-height: 0;
} }
.publish-view :deep(.n-tab-pane > .n-card) { :deep(.n-tab-pane > .n-card) {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
min-height: 0; min-height: 0;
} }
.publish-view :deep(.n-card__content) { :deep(.n-card__content) {
flex: 1 1 auto; flex: 1 1 auto;
min-height: 0; min-height: 0;
overflow: hidden; overflow: hidden;
}
.publish-view :deep(.n-card__content > div) { > div {
height: 100%; height: 100%;
min-height: 0; min-height: 0;
}
}
} }
.topic-field { .topic-field {
width: 100%; width: 100%;
}
.topic-field :deep(.n-form-item-blank) { :deep(.n-form-item-blank) {
width: 100%; width: 100%;
}
} }
.publish-form { .publish-form {
@@ -369,34 +369,34 @@ async function copyTopic(topic) {
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
min-height: 0; min-height: 0;
}
.publish-form .payload-field { .payload-field {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
}
.publish-form .payload-field :deep(.n-form-item-blank) { :deep(.n-form-item-blank) {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
} }
.publish-form .payload-field :deep(.n-input) { :deep(.n-input) {
height: 100%; height: 100%;
} }
.publish-form .payload-field :deep(.n-input__textarea-el), :deep(.n-input__textarea-el),
.publish-form .payload-field :deep(textarea) { :deep(textarea) {
resize: none !important; resize: none !important;
height: 100% !important; height: 100% !important;
min-height: 0 !important; min-height: 0 !important;
font-family: monospace; font-family: monospace;
}
}
} }
.msg-log { .msg-log {
background: #f5f7fa; background: var(--frost-color-bg-light);
border-radius: 6px; border-radius: 6px;
padding: 12px; padding: 12px;
height: 100%; height: 100%;
@@ -411,7 +411,7 @@ async function copyTopic(topic) {
.msg-entry { .msg-entry {
padding: 8px 0; padding: 8px 0;
border-bottom: 1px solid rgba(0, 0, 0, 0.06); border-bottom: 1px solid var(--frost-shadow-black-6);
} }
.msg-header { .msg-header {
@@ -422,7 +422,7 @@ async function copyTopic(topic) {
} }
.msg-time { .msg-time {
color: #999; color: var(--frost-color-text-tertiary);
flex-shrink: 0; flex-shrink: 0;
} }
@@ -430,31 +430,31 @@ async function copyTopic(topic) {
flex-shrink: 0; flex-shrink: 0;
font-weight: 700; font-weight: 700;
width: 28px; width: 28px;
}
.msg-dir.pub { &.pub {
color: #3b82f6; color: var(--frost-info-color);
}
} }
.msg-topic { .msg-topic {
color: #d25a00; color: var(--frost-color-topic);
min-width: 0; min-width: 0;
flex: 1; flex: 1;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
cursor: pointer; cursor: pointer;
}
.msg-topic:hover { &:hover {
text-decoration: underline; text-decoration: underline;
}
} }
.msg-payload { .msg-payload {
margin: 6px 0 0; margin: 6px 0 0;
padding: 8px 10px; padding: 8px 10px;
border-radius: 4px; border-radius: 4px;
background: rgba(0, 0, 0, 0.04); background: var(--frost-shadow-black-4);
color: #333; color: #333;
font: inherit; font: inherit;
white-space: pre-wrap; white-space: pre-wrap;
@@ -462,7 +462,7 @@ async function copyTopic(topic) {
} }
.msg-meta { .msg-meta {
color: #999; color: var(--frost-color-text-tertiary);
font-size: 11px; font-size: 11px;
flex-shrink: 0; 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>
+226 -76
View File
@@ -1,7 +1,7 @@
<script setup> <script setup>
import { ref, watch } from 'vue'; import { ref, watch } from 'vue';
import { useMqttStore } from '../stores/mqtt.js'; import { useMqttStore } from '../stores/mqtt.js';
import { NCard, NSpace, NFormItem, NInput, NSelect, NButton, NEmpty, NList, NListItem, NTag, NEllipsis, useMessage } from 'naive-ui'; import { NCard, NSpace, NFormItem, NInput, NSelect, NButton, NEmpty, NList, NListItem, NTag, useMessage } from 'naive-ui';
// 全局状态与消息 API // 全局状态与消息 API
const store = useMqttStore(); const store = useMqttStore();
@@ -10,11 +10,13 @@ const msg = useMessage();
// 主题输入与 QoS 选择 // 主题输入与 QoS 选择
const topicInput = ref(''); const topicInput = ref('');
const qos = ref(2); const qos = ref(2);
const comment = ref('');
// 编辑状态 // 编辑状态
const editingTopicId = ref(null); const editingTopicId = ref(null);
const editingTopic = ref(''); const editingTopic = ref('');
const editingQos = ref(2); const editingQos = ref(2);
const editingComment = ref('');
// QoS 下拉选项 // QoS 下拉选项
const qosOptions = [ const qosOptions = [
@@ -84,11 +86,12 @@ async function handleAddTopic() {
return; return;
} }
let result = await store.addTopic(srv.id, topic, qos.value); let result = await store.addTopic(srv.id, topic, qos.value, comment.value.trim());
if (result) { if (result) {
msg.success('主题已添加' + (srv.status === 'connected' ? '并已订阅' : '')); msg.success('主题已添加' + (srv.status === 'connected' ? '并已订阅' : ''));
topicInput.value = ''; topicInput.value = '';
comment.value = '';
} }
} }
@@ -173,6 +176,7 @@ function startEdit(t) {
editingTopicId.value = t.id || t.topic; editingTopicId.value = t.id || t.topic;
editingTopic.value = t.topic; editingTopic.value = t.topic;
editingQos.value = t.qos ?? 2; editingQos.value = t.qos ?? 2;
editingComment.value = t.comment ?? '';
} }
/** /**
@@ -182,6 +186,7 @@ function cancelEdit() {
editingTopicId.value = null; editingTopicId.value = null;
editingTopic.value = ''; editingTopic.value = '';
editingQos.value = 2; editingQos.value = 2;
editingComment.value = '';
} }
/** /**
@@ -213,7 +218,13 @@ async function handleEditSave(t) {
} }
let topicId = t.id || t.topic; let topicId = t.id || t.topic;
let result = await store.updateTopic(srv.id, topicId, topicName, editingQos.value); let result = await store.updateTopic(
srv.id,
topicId,
topicName,
editingQos.value,
editingComment.value.trim()
);
if (result) { if (result) {
msg.success('主题已更新'); msg.success('主题已更新');
@@ -249,6 +260,16 @@ async function handleEditSave(t) {
@keydown.enter="handleAddTopic" @keydown.enter="handleAddTopic"
/> />
</n-form-item> </n-form-item>
<n-form-item
class="comment-field"
label="备注"
>
<n-input
v-model:value="comment"
placeholder="可选备注"
@keydown.enter="handleAddTopic"
/>
</n-form-item>
<n-form-item <n-form-item
class="qos-field" class="qos-field"
label="QoS" label="QoS"
@@ -262,9 +283,7 @@ async function handleEditSave(t) {
class="topic-submit" class="topic-submit"
type="primary" type="primary"
@click="handleAddTopic" @click="handleAddTopic"
> >+ 添加订阅</n-button>
+ 添加订阅
</n-button>
</div> </div>
</n-card> </n-card>
@@ -284,9 +303,7 @@ async function handleEditSave(t) {
<n-button <n-button
size="small" size="small"
@click="handleSubscribeAll" @click="handleSubscribeAll"
> >全部订阅</n-button>
全部订阅
</n-button>
</template> </template>
<n-empty <n-empty
v-if="!store.activeServer.value.topics || store.activeServer.value.topics.length === 0" v-if="!store.activeServer.value.topics || store.activeServer.value.topics.length === 0"
@@ -302,86 +319,96 @@ async function handleEditSave(t) {
:key="t.id || t.topic" :key="t.id || t.topic"
class="topic-item" class="topic-item"
> >
<template <template #prefix>
v-if="editingTopicId !== (t.id || t.topic)"
#prefix
>
<n-tag <n-tag
class="topic-qos-tag"
:type="t.subscribed ? 'success' : 'default'" :type="t.subscribed ? 'success' : 'default'"
size="small" size="small"
> >QoS {{ t.qos }}</n-tag>
QoS {{ t.qos }}
</n-tag>
</template> </template>
<div <div class="topic-content">
v-if="editingTopicId === (t.id || t.topic)" <div
class="topic-edit-row" class="topic-name"
> :class="{ 'is-hidden': editingTopicId === (t.id || t.topic) }"
<n-input
v-model:value="editingTopic"
size="small"
placeholder="主题名称"
@keydown.enter="handleEditSave(t)"
/>
<n-select
v-model:value="editingQos"
size="small"
:options="qosOptions"
style="width: 160px; flex-shrink: 0"
/>
<n-space
:size="4"
:wrap="false"
> >
<n-button <span
size="tiny" class="topic-label"
type="primary" :title="t.topic"
@click="handleEditSave(t)" >{{ t.topic }}</span>
<div class="topic-comment">
<span class="topic-comment-prefix">备注</span>
<span
class="topic-comment-text"
:title="t.comment"
>{{ t.comment }}</span>
</div>
</div>
<div
class="topic-edit-row"
:class="{ 'is-visible': editingTopicId === (t.id || t.topic) }"
>
<n-input
v-model:value="editingTopic"
class="topic-edit-topic"
size="small"
placeholder="主题名称"
@keydown.enter="handleEditSave(t)"
/>
<n-input
v-model:value="editingComment"
class="topic-edit-comment"
size="small"
placeholder="备注"
/>
<n-select
v-model:value="editingQos"
class="topic-edit-qos"
size="small"
:options="qosOptions"
/>
<n-space
class="topic-edit-actions"
:size="4"
:wrap="false"
> >
保存 <n-button
</n-button> size="tiny"
<n-button type="primary"
size="tiny" @click="handleEditSave(t)"
@click="cancelEdit" >保存</n-button>
> <n-button
取消 size="tiny"
</n-button> @click="cancelEdit"
</n-space> >取消</n-button>
</div> </n-space>
<div </div>
v-else
class="topic-name"
>
<n-ellipsis>{{ t.topic }}</n-ellipsis>
</div> </div>
<template #suffix> <template #suffix>
<n-space <n-space
v-if="editingTopicId !== (t.id || t.topic)"
class="topic-actions" class="topic-actions"
:class="{ 'is-hidden': editingTopicId === (t.id || t.topic) }"
:size="4" :size="4"
:wrap="false" :wrap="false"
> >
<n-button <n-button
size="tiny" size="tiny"
@click="startEdit(t)" @click="startEdit(t)"
> >编辑</n-button>
编辑
</n-button>
<n-button <n-button
class="topic-action-sub"
size="tiny" size="tiny"
:type="t.subscribed ? 'error' : 'success'" :type="t.subscribed ? 'success' : 'primary'"
@click="handleToggleSub(t.topic)" @click="handleToggleSub(t.topic)"
> >
{{ t.subscribed ? '取消订阅' : '订阅' }} {{ t.subscribed ? '已订阅' : '未订阅' }}
</n-button> </n-button>
<n-button <n-button
class="topic-action-remove"
size="tiny" size="tiny"
type="error" type="error"
text text
@click.stop="handleRemoveTopic(t.topic)" @click.stop="handleRemoveTopic(t.topic)"
> >&#10005;</n-button>
&#10005;
</n-button>
</n-space> </n-space>
</template> </template>
</n-list-item> </n-list-item>
@@ -399,7 +426,7 @@ async function handleEditSave(t) {
</template> </template>
<style scoped> <style scoped lang="less">
.subscribe-view { .subscribe-view {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -443,45 +470,168 @@ async function handleEditSave(t) {
width: 160px; width: 160px;
} }
.comment-field {
flex: 1 1 180px;
width: auto;
}
.topic-submit { .topic-submit {
flex: 0 0 auto; flex: 0 0 auto;
} }
.topic-field :deep(.n-form-item-blank), .topic-field,
.qos-field :deep(.n-form-item-blank) { .qos-field,
width: 100%; .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; display: flex;
align-items: center; align-items: center;
flex: 1;
width: 100%;
height: 100%;
min-width: 0; min-width: 0;
gap: 12px; overflow: hidden;
} }
.topic-name { .topic-name {
min-width: 0; display: flex;
flex: 1; flex: 1;
font-family: monospace; flex-direction: column;
justify-content: center;
align-items: stretch;
width: 100%;
height: 40px;
min-width: 0;
min-height: 0;
gap: 2px;
font-size: 13px; font-size: 13px;
overflow: hidden;
visibility: visible;
&.is-hidden {
visibility: hidden;
}
}
.topic-label {
min-width: 0;
overflow: hidden;
font-family: monospace;
line-height: 18px;
text-overflow: ellipsis;
white-space: nowrap;
}
.topic-comment {
display: flex;
align-items: center;
min-width: 0;
max-width: 100%;
padding: 1px 4px;
gap: 4px;
border: 1px solid rgba(14, 165, 160, 0.16);
border-radius: 4px;
background: rgba(20, 184, 166, 0.06);
color: var(--n-text-color-3);
font-size: 11px;
line-height: 16px;
&-prefix {
flex-shrink: 0;
color: #0f766e;
}
&-text {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.topic-qos-tag {
min-width: 56px;
justify-content: center;
} }
.topic-actions { .topic-actions {
flex-shrink: 0; flex-shrink: 0;
visibility: visible;
&.is-hidden {
visibility: hidden;
pointer-events: none;
}
.n-button {
min-width: 40px;
}
.topic-action-sub {
min-width: auto;
}
.topic-action-remove {
min-width: 24px;
}
} }
.topic-edit-row { .topic-edit-row {
position: absolute;
inset: 0;
display: flex; display: flex;
align-items: center; align-items: center;
flex-wrap: wrap; flex-wrap: nowrap;
gap: 8px; gap: 8px;
flex: 1; width: 100%;
height: 100%;
min-width: 0;
overflow: hidden;
visibility: hidden;
pointer-events: none;
&.is-visible {
visibility: visible;
pointer-events: auto;
}
}
.topic-edit-topic {
flex: 1 1 auto;
min-width: 0; min-width: 0;
} }
.topic-edit-row .n-input { .topic-edit-comment {
flex: 1; flex: 1 1 auto;
min-width: 180px; min-width: 0;
}
.topic-edit-qos {
flex: 0 0 auto;
width: 160px;
min-width: 80px;
} }
@media (max-width: 720px) { @media (max-width: 720px) {
+4
View File
@@ -17,6 +17,8 @@ export interface MqttTopic {
topic: string; topic: string;
/** 订阅 QoS 等级 */ /** 订阅 QoS 等级 */
qos: MqttQoS; qos: MqttQoS;
/** 主题备注,空字符串表示未备注 */
comment: string;
/** 当前是否已订阅 */ /** 当前是否已订阅 */
subscribed: boolean; subscribed: boolean;
} }
@@ -29,6 +31,8 @@ export interface TopicConfig {
topic: string; topic: string;
/** 订阅 QoS 等级,默认 1 */ /** 订阅 QoS 等级,默认 1 */
qos?: MqttQoS; qos?: MqttQoS;
/** 主题备注,默认空字符串 */
comment?: string;
} }
/** MQTT 服务器配置(不含运行时状态) */ /** MQTT 服务器配置(不含运行时状态) */
+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 空格缩进。
- 元素标签内文本简短时,将文本与结束标签放在同一行;较长表达式或动态内容可单独成行。
- 样式使用 Less;Vue SFC 中通过 `<style lang="less">` / `<style lang="less" scoped>` 声明。
- 类名使用 kebab-case;布局和组件私有样式就近放在对应组件中。
- 善用嵌套规则、`&` 父选择器减少重复;全局主题色等公共变量在 App.vue 中通过 CSS 自定义属性定义,命名与 `themeOverrides` 对应(例如 `--frost-primary-color`)。
- 组件内直接使用 `var(--xxx)` 引用全局变量,不再单独定义 Less 变量。
## Electron 分层
- `src/main` 处理窗口、IPC 与 MQTT 等主进程逻辑。
- `src/preload` 统一封装并暴露 `window.api`,渲染进程不直接使用 Electron IPC。
- `src/renderer` 放置 Vue 页面、组件和状态管理;通过 `window.api` 调用主进程能力。
## ESLint
- 使用 `@electron-toolkit/eslint-config` 与 Vue 推荐规则作为基础。
- 提交前执行 `pnpm lint`,并修复实际报告的问题。