feat: 初始版本,支持配置和连接多个 MQTT 服务器,支持配置订阅的主题列表,支持消息历史记录
This commit is contained in:
@@ -3,7 +3,13 @@ import { defineConfig } from 'electron-vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
main: {},
|
||||
main: {
|
||||
build: {
|
||||
rollupOptions: {
|
||||
external: ['mqtt']
|
||||
}
|
||||
}
|
||||
},
|
||||
preload: {},
|
||||
renderer: {
|
||||
resolve: {
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@electron-toolkit/preload": "^3.0.2",
|
||||
"@electron-toolkit/utils": "^4.0.0"
|
||||
"@electron-toolkit/utils": "^4.0.0",
|
||||
"mqtt": "^5.10.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron-toolkit/eslint-config": "^2.1.0",
|
||||
|
||||
295
app/pnpm-lock.yaml
generated
295
app/pnpm-lock.yaml
generated
@@ -14,6 +14,9 @@ importers:
|
||||
'@electron-toolkit/utils':
|
||||
specifier: ^4.0.0
|
||||
version: 4.0.0(electron@39.8.10)
|
||||
mqtt:
|
||||
specifier: ^5.10.3
|
||||
version: 5.15.2
|
||||
devDependencies:
|
||||
'@electron-toolkit/eslint-config':
|
||||
specifier: ^2.1.0
|
||||
@@ -122,6 +125,10 @@ packages:
|
||||
peerDependencies:
|
||||
'@babel/core': ^7.0.0-0
|
||||
|
||||
'@babel/runtime@7.29.7':
|
||||
resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/template@7.29.7':
|
||||
resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
@@ -823,9 +830,15 @@ packages:
|
||||
'@types/node@26.1.1':
|
||||
resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==}
|
||||
|
||||
'@types/readable-stream@4.0.24':
|
||||
resolution: {integrity: sha512-NRvUNC/JFGPJvqdAfEve8oginbM6V08u5NzLWpG8MwA2kTPOLnqk+wpwuPT+mp3aUsxyuT6m2gnrPuHYCruzEg==}
|
||||
|
||||
'@types/responselike@1.0.3':
|
||||
resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==}
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
|
||||
|
||||
'@types/yauzl@2.10.3':
|
||||
resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==}
|
||||
|
||||
@@ -873,6 +886,10 @@ packages:
|
||||
resolution: {integrity: sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==}
|
||||
engines: {node: ^20.17.0 || >=22.9.0}
|
||||
|
||||
abort-controller@3.0.0:
|
||||
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
|
||||
engines: {node: '>=6.5'}
|
||||
|
||||
acorn-jsx@5.3.2:
|
||||
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
|
||||
peerDependencies:
|
||||
@@ -950,6 +967,9 @@ packages:
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
bl@6.1.6:
|
||||
resolution: {integrity: sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg==}
|
||||
|
||||
bluebird@3.7.2:
|
||||
resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==}
|
||||
|
||||
@@ -970,6 +990,9 @@ packages:
|
||||
resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
|
||||
broker-factory@3.1.15:
|
||||
resolution: {integrity: sha512-ko+aWvgNuP49meGrdjUu7rC+Y+Wai3cCPxP3xWwHsHfehFjOh5ZQM2yC4gEB2UddeZ/YXhm0K1eG/L6fxym2Og==}
|
||||
|
||||
browserslist@4.28.6:
|
||||
resolution: {integrity: sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==}
|
||||
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
||||
@@ -981,6 +1004,9 @@ packages:
|
||||
buffer-from@1.1.2:
|
||||
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
||||
|
||||
buffer@6.0.3:
|
||||
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
|
||||
|
||||
builder-util-runtime@9.7.0:
|
||||
resolution: {integrity: sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
@@ -1061,6 +1087,9 @@ packages:
|
||||
resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==}
|
||||
engines: {node: ^12.20.0 || >=14}
|
||||
|
||||
commist@3.2.0:
|
||||
resolution: {integrity: sha512-4PIMoPniho+LqXmpS5d3NuGYncG6XWlkBSVGiWycL22dd42OYdUGil2CWuzklaJoNxyxUSpO4MKIBU94viWNAw==}
|
||||
|
||||
compare-version@0.1.2:
|
||||
resolution: {integrity: sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -1068,6 +1097,10 @@ packages:
|
||||
concat-map@0.0.1:
|
||||
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
|
||||
|
||||
concat-stream@2.0.0:
|
||||
resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==}
|
||||
engines: {'0': node >= 6.0}
|
||||
|
||||
convert-source-map@2.0.0:
|
||||
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
||||
|
||||
@@ -1343,6 +1376,14 @@ packages:
|
||||
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
event-target-shim@5.0.1:
|
||||
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
events@3.3.0:
|
||||
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
|
||||
engines: {node: '>=0.8.x'}
|
||||
|
||||
evtd@0.2.4:
|
||||
resolution: {integrity: sha512-qaeGN5bx63s/AXgQo8gj6fBkxge+OoLddLniox5qtLAEY5HSnuSlISXVPxnSae1dWblvTh4/HoMIB+mbMsvZzw==}
|
||||
|
||||
@@ -1366,6 +1407,10 @@ packages:
|
||||
fast-levenshtein@2.0.6:
|
||||
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
|
||||
|
||||
fast-unique-numbers@9.0.27:
|
||||
resolution: {integrity: sha512-nDA9ADeINN8SA2u2wCtU+siWFTTDqQR37XvgPIDDmboWQeExz7X0mImxuaN+kJddliIqy2FpVRmnvRZ+j8i1/A==}
|
||||
engines: {node: '>=18.2.0'}
|
||||
|
||||
fast-uri@3.1.3:
|
||||
resolution: {integrity: sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==}
|
||||
|
||||
@@ -1512,6 +1557,9 @@ packages:
|
||||
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
help-me@5.0.0:
|
||||
resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==}
|
||||
|
||||
highlight.js@11.11.1:
|
||||
resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
@@ -1535,6 +1583,9 @@ packages:
|
||||
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
ieee754@1.2.1:
|
||||
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
|
||||
|
||||
ignore@5.3.2:
|
||||
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
|
||||
engines: {node: '>= 4'}
|
||||
@@ -1554,6 +1605,10 @@ packages:
|
||||
inherits@2.0.4:
|
||||
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
||||
|
||||
ip-address@10.2.0:
|
||||
resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==}
|
||||
engines: {node: '>= 12'}
|
||||
|
||||
is-extglob@2.1.1:
|
||||
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -1597,6 +1652,9 @@ packages:
|
||||
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
|
||||
hasBin: true
|
||||
|
||||
js-sdsl@4.3.0:
|
||||
resolution: {integrity: sha512-mifzlm2+5nZ+lEcLJMoBK0/IH/bDg8XnJfd/Wq6IP+xoCjLZsTOnV2QpxlVbX9bMnkl5PdEjNtBJ9Cj1NjifhQ==}
|
||||
|
||||
js-tokens@4.0.0:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
|
||||
@@ -1662,6 +1720,9 @@ packages:
|
||||
resolution: {integrity: sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
lru-cache@10.4.3:
|
||||
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
|
||||
|
||||
lru-cache@5.1.1:
|
||||
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
||||
|
||||
@@ -1731,6 +1792,14 @@ packages:
|
||||
resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==}
|
||||
hasBin: true
|
||||
|
||||
mqtt-packet@9.0.2:
|
||||
resolution: {integrity: sha512-MvIY0B8/qjq7bKxdN1eD+nrljoeaai+qjLJgfRn3TiMuz0pamsIWY2bFODPZMSNmabsLANXsLl4EMoWvlaTZWA==}
|
||||
|
||||
mqtt@5.15.2:
|
||||
resolution: {integrity: sha512-VWZU2CSUY3U3oN0PSBRDE5SNsFi4zqqNeQ/uv3pZWqY3CrBXD/dhd0ZLjlsk5YnebGlrapi4lRVNJPUNQ5aZ3w==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
hasBin: true
|
||||
|
||||
ms@2.1.3:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
@@ -1779,6 +1848,9 @@ packages:
|
||||
nth-check@2.1.1:
|
||||
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
|
||||
|
||||
number-allocator@1.0.14:
|
||||
resolution: {integrity: sha512-OrL44UTVAvkKdOdRQZIJpLkAdjXGTRda052sN4sO77bKEzYYqWKMBjQvrJFzqygI99gL6Z4u2xctPW1tB8ErvA==}
|
||||
|
||||
object-keys@1.1.1:
|
||||
resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -1873,6 +1945,10 @@ packages:
|
||||
process-nextick-args@2.0.1:
|
||||
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
|
||||
|
||||
process@0.11.10:
|
||||
resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
|
||||
engines: {node: '>= 0.6.0'}
|
||||
|
||||
progress@2.0.3:
|
||||
resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
@@ -1909,6 +1985,14 @@ packages:
|
||||
readable-stream@2.3.8:
|
||||
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
|
||||
|
||||
readable-stream@3.6.2:
|
||||
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
readable-stream@4.7.0:
|
||||
resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==}
|
||||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
||||
|
||||
require-directory@2.1.1:
|
||||
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -1935,6 +2019,9 @@ packages:
|
||||
resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
rfdc@1.4.1:
|
||||
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
|
||||
|
||||
rimraf@2.6.3:
|
||||
resolution: {integrity: sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==}
|
||||
deprecated: Rimraf versions prior to v4 are no longer supported
|
||||
@@ -1952,6 +2039,9 @@ packages:
|
||||
safe-buffer@5.1.2:
|
||||
resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
|
||||
|
||||
safe-buffer@5.2.1:
|
||||
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
|
||||
|
||||
sanitize-filename@1.6.4:
|
||||
resolution: {integrity: sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg==}
|
||||
|
||||
@@ -2002,6 +2092,14 @@ packages:
|
||||
resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
smart-buffer@4.2.0:
|
||||
resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==}
|
||||
engines: {node: '>= 6.0.0', npm: '>= 3.0.0'}
|
||||
|
||||
socks@2.8.9:
|
||||
resolution: {integrity: sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==}
|
||||
engines: {node: '>= 10.0.0', npm: '>= 3.0.0'}
|
||||
|
||||
source-map-js@1.2.1:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -2013,6 +2111,10 @@ packages:
|
||||
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
split2@4.2.0:
|
||||
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
|
||||
engines: {node: '>= 10.x'}
|
||||
|
||||
sprintf-js@1.1.3:
|
||||
resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==}
|
||||
|
||||
@@ -2027,6 +2129,9 @@ packages:
|
||||
string_decoder@1.1.1:
|
||||
resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
|
||||
|
||||
string_decoder@1.3.0:
|
||||
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
|
||||
|
||||
strip-ansi@6.0.1:
|
||||
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -2089,6 +2194,9 @@ packages:
|
||||
resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
typedarray@0.0.6:
|
||||
resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==}
|
||||
|
||||
undici-types@6.21.0:
|
||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||
|
||||
@@ -2216,6 +2324,18 @@ packages:
|
||||
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
worker-factory@7.0.50:
|
||||
resolution: {integrity: sha512-hhwc0G+sFwM4qBuhJIUBn2p1Jf8v/FwmLUANBf/Q+Lt2uI8mfIZQhXaZQACodQD4R7Zp6cn/6702bIvNn2puJQ==}
|
||||
|
||||
worker-timers-broker@8.0.18:
|
||||
resolution: {integrity: sha512-FrjzDVX1wKfZN0gRbCFqv8VHuTncG4sbI/WGEg4tSSQeIsnwqg4YBYWMAHYLJtUDEYYmiK65UKFrVMVk2irDSg==}
|
||||
|
||||
worker-timers-worker@9.0.15:
|
||||
resolution: {integrity: sha512-KKUe7lZ/Aignr51H6hOUik8LwTnIgojH/1lwhli8A8qIEIyewogZTpNpMW5B6BF7nmwBOkUoTYgf1H3QShcjSA==}
|
||||
|
||||
worker-timers@8.0.33:
|
||||
resolution: {integrity: sha512-RQVlKkek80v8M6SHvdMKiywaXFmX3XTpYTXTw0r62PdXB06t74XNxcTuG8wsQwnjorAQymNQyyQ0rzv7PykEMQ==}
|
||||
|
||||
wrap-ansi@7.0.0:
|
||||
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -2223,6 +2343,18 @@ packages:
|
||||
wrappy@1.0.2:
|
||||
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
||||
|
||||
ws@8.21.1:
|
||||
resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
peerDependencies:
|
||||
bufferutil: ^4.0.1
|
||||
utf-8-validate: '>=5.0.2'
|
||||
peerDependenciesMeta:
|
||||
bufferutil:
|
||||
optional: true
|
||||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
xml-name-validator@4.0.0:
|
||||
resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -2346,6 +2478,8 @@ snapshots:
|
||||
'@babel/core': 7.29.7
|
||||
'@babel/helper-plugin-utils': 7.29.7
|
||||
|
||||
'@babel/runtime@7.29.7': {}
|
||||
|
||||
'@babel/template@7.29.7':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.7
|
||||
@@ -2905,10 +3039,18 @@ snapshots:
|
||||
dependencies:
|
||||
undici-types: 8.3.0
|
||||
|
||||
'@types/readable-stream@4.0.24':
|
||||
dependencies:
|
||||
'@types/node': 26.1.1
|
||||
|
||||
'@types/responselike@1.0.3':
|
||||
dependencies:
|
||||
'@types/node': 22.20.1
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
dependencies:
|
||||
'@types/node': 26.1.1
|
||||
|
||||
'@types/yauzl@2.10.3':
|
||||
dependencies:
|
||||
'@types/node': 22.20.1
|
||||
@@ -2978,6 +3120,10 @@ snapshots:
|
||||
|
||||
abbrev@4.0.0: {}
|
||||
|
||||
abort-controller@3.0.0:
|
||||
dependencies:
|
||||
event-target-shim: 5.0.1
|
||||
|
||||
acorn-jsx@5.3.2(acorn@8.17.0):
|
||||
dependencies:
|
||||
acorn: 8.17.0
|
||||
@@ -3082,6 +3228,13 @@ snapshots:
|
||||
|
||||
baseline-browser-mapping@2.10.43: {}
|
||||
|
||||
bl@6.1.6:
|
||||
dependencies:
|
||||
'@types/readable-stream': 4.0.24
|
||||
buffer: 6.0.3
|
||||
inherits: 2.0.4
|
||||
readable-stream: 4.7.0
|
||||
|
||||
bluebird@3.7.2: {}
|
||||
|
||||
boolbase@1.0.0: {}
|
||||
@@ -3102,6 +3255,13 @@ snapshots:
|
||||
dependencies:
|
||||
balanced-match: 4.0.4
|
||||
|
||||
broker-factory@3.1.15:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.7
|
||||
fast-unique-numbers: 9.0.27
|
||||
tslib: 2.8.1
|
||||
worker-factory: 7.0.50
|
||||
|
||||
browserslist@4.28.6:
|
||||
dependencies:
|
||||
baseline-browser-mapping: 2.10.43
|
||||
@@ -3114,6 +3274,11 @@ snapshots:
|
||||
|
||||
buffer-from@1.1.2: {}
|
||||
|
||||
buffer@6.0.3:
|
||||
dependencies:
|
||||
base64-js: 1.5.1
|
||||
ieee754: 1.2.1
|
||||
|
||||
builder-util-runtime@9.7.0:
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
@@ -3203,10 +3368,19 @@ snapshots:
|
||||
commander@9.5.0:
|
||||
optional: true
|
||||
|
||||
commist@3.2.0: {}
|
||||
|
||||
compare-version@0.1.2: {}
|
||||
|
||||
concat-map@0.0.1: {}
|
||||
|
||||
concat-stream@2.0.0:
|
||||
dependencies:
|
||||
buffer-from: 1.1.2
|
||||
inherits: 2.0.4
|
||||
readable-stream: 3.6.2
|
||||
typedarray: 0.0.6
|
||||
|
||||
convert-source-map@2.0.0: {}
|
||||
|
||||
core-util-is@1.0.3: {}
|
||||
@@ -3577,6 +3751,10 @@ snapshots:
|
||||
|
||||
esutils@2.0.3: {}
|
||||
|
||||
event-target-shim@5.0.1: {}
|
||||
|
||||
events@3.3.0: {}
|
||||
|
||||
evtd@0.2.4: {}
|
||||
|
||||
exponential-backoff@3.1.3: {}
|
||||
@@ -3599,6 +3777,11 @@ snapshots:
|
||||
|
||||
fast-levenshtein@2.0.6: {}
|
||||
|
||||
fast-unique-numbers@9.0.27:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.7
|
||||
tslib: 2.8.1
|
||||
|
||||
fast-uri@3.1.3: {}
|
||||
|
||||
fd-slicer@1.1.0:
|
||||
@@ -3775,6 +3958,8 @@ snapshots:
|
||||
dependencies:
|
||||
function-bind: 1.1.2
|
||||
|
||||
help-me@5.0.0: {}
|
||||
|
||||
highlight.js@11.11.1: {}
|
||||
|
||||
hosted-git-info@4.1.0:
|
||||
@@ -3802,6 +3987,8 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
ieee754@1.2.1: {}
|
||||
|
||||
ignore@5.3.2: {}
|
||||
|
||||
import-fresh@3.3.1:
|
||||
@@ -3818,6 +4005,8 @@ snapshots:
|
||||
|
||||
inherits@2.0.4: {}
|
||||
|
||||
ip-address@10.2.0: {}
|
||||
|
||||
is-extglob@2.1.1: {}
|
||||
|
||||
is-fullwidth-code-point@3.0.0: {}
|
||||
@@ -3846,6 +4035,8 @@ snapshots:
|
||||
|
||||
jiti@2.7.0: {}
|
||||
|
||||
js-sdsl@4.3.0: {}
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
|
||||
js-yaml@4.3.0:
|
||||
@@ -3900,6 +4091,8 @@ snapshots:
|
||||
|
||||
lowercase-keys@2.0.0: {}
|
||||
|
||||
lru-cache@10.4.3: {}
|
||||
|
||||
lru-cache@5.1.1:
|
||||
dependencies:
|
||||
yallist: 3.1.1
|
||||
@@ -3959,6 +4152,37 @@ snapshots:
|
||||
dependencies:
|
||||
minimist: 1.2.8
|
||||
|
||||
mqtt-packet@9.0.2:
|
||||
dependencies:
|
||||
bl: 6.1.6
|
||||
debug: 4.4.3
|
||||
process-nextick-args: 2.0.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
mqtt@5.15.2:
|
||||
dependencies:
|
||||
'@types/readable-stream': 4.0.24
|
||||
'@types/ws': 8.18.1
|
||||
commist: 3.2.0
|
||||
concat-stream: 2.0.0
|
||||
debug: 4.4.3
|
||||
help-me: 5.0.0
|
||||
lru-cache: 10.4.3
|
||||
minimist: 1.2.8
|
||||
mqtt-packet: 9.0.2
|
||||
number-allocator: 1.0.14
|
||||
readable-stream: 4.7.0
|
||||
rfdc: 1.4.1
|
||||
socks: 2.8.9
|
||||
split2: 4.2.0
|
||||
worker-timers: 8.0.33
|
||||
ws: 8.21.1
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- supports-color
|
||||
- utf-8-validate
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
naive-ui@2.44.1(vue@3.5.39):
|
||||
@@ -4022,6 +4246,13 @@ snapshots:
|
||||
dependencies:
|
||||
boolbase: 1.0.0
|
||||
|
||||
number-allocator@1.0.14:
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
js-sdsl: 4.3.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
object-keys@1.1.1:
|
||||
optional: true
|
||||
|
||||
@@ -4109,6 +4340,8 @@ snapshots:
|
||||
|
||||
process-nextick-args@2.0.1: {}
|
||||
|
||||
process@0.11.10: {}
|
||||
|
||||
progress@2.0.3: {}
|
||||
|
||||
promise-retry@2.0.1:
|
||||
@@ -4153,6 +4386,20 @@ snapshots:
|
||||
string_decoder: 1.1.1
|
||||
util-deprecate: 1.0.2
|
||||
|
||||
readable-stream@3.6.2:
|
||||
dependencies:
|
||||
inherits: 2.0.4
|
||||
string_decoder: 1.1.1
|
||||
util-deprecate: 1.0.2
|
||||
|
||||
readable-stream@4.7.0:
|
||||
dependencies:
|
||||
abort-controller: 3.0.0
|
||||
buffer: 6.0.3
|
||||
events: 3.3.0
|
||||
process: 0.11.10
|
||||
string_decoder: 1.3.0
|
||||
|
||||
require-directory@2.1.1: {}
|
||||
|
||||
require-from-string@2.0.2: {}
|
||||
@@ -4171,6 +4418,8 @@ snapshots:
|
||||
|
||||
retry@0.12.0: {}
|
||||
|
||||
rfdc@1.4.1: {}
|
||||
|
||||
rimraf@2.6.3:
|
||||
dependencies:
|
||||
glob: 7.2.3
|
||||
@@ -4218,6 +4467,8 @@ snapshots:
|
||||
|
||||
safe-buffer@5.1.2: {}
|
||||
|
||||
safe-buffer@5.2.1: {}
|
||||
|
||||
sanitize-filename@1.6.4:
|
||||
dependencies:
|
||||
truncate-utf8-bytes: 1.0.2
|
||||
@@ -4254,6 +4505,13 @@ snapshots:
|
||||
dependencies:
|
||||
semver: 7.8.5
|
||||
|
||||
smart-buffer@4.2.0: {}
|
||||
|
||||
socks@2.8.9:
|
||||
dependencies:
|
||||
ip-address: 10.2.0
|
||||
smart-buffer: 4.2.0
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
source-map-support@0.5.21:
|
||||
@@ -4263,6 +4521,8 @@ snapshots:
|
||||
|
||||
source-map@0.6.1: {}
|
||||
|
||||
split2@4.2.0: {}
|
||||
|
||||
sprintf-js@1.1.3:
|
||||
optional: true
|
||||
|
||||
@@ -4278,6 +4538,10 @@ snapshots:
|
||||
dependencies:
|
||||
safe-buffer: 5.1.2
|
||||
|
||||
string_decoder@1.3.0:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
strip-ansi@6.0.1:
|
||||
dependencies:
|
||||
ansi-regex: 5.0.1
|
||||
@@ -4346,6 +4610,8 @@ snapshots:
|
||||
type-fest@0.13.1:
|
||||
optional: true
|
||||
|
||||
typedarray@0.0.6: {}
|
||||
|
||||
undici-types@6.21.0: {}
|
||||
|
||||
undici-types@8.3.0: {}
|
||||
@@ -4454,6 +4720,33 @@ snapshots:
|
||||
|
||||
word-wrap@1.2.5: {}
|
||||
|
||||
worker-factory@7.0.50:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.7
|
||||
fast-unique-numbers: 9.0.27
|
||||
tslib: 2.8.1
|
||||
|
||||
worker-timers-broker@8.0.18:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.7
|
||||
broker-factory: 3.1.15
|
||||
fast-unique-numbers: 9.0.27
|
||||
tslib: 2.8.1
|
||||
worker-timers-worker: 9.0.15
|
||||
|
||||
worker-timers-worker@9.0.15:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.7
|
||||
tslib: 2.8.1
|
||||
worker-factory: 7.0.50
|
||||
|
||||
worker-timers@8.0.33:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.7
|
||||
tslib: 2.8.1
|
||||
worker-timers-broker: 8.0.18
|
||||
worker-timers-worker: 9.0.15
|
||||
|
||||
wrap-ansi@7.0.0:
|
||||
dependencies:
|
||||
ansi-styles: 4.3.0
|
||||
@@ -4462,6 +4755,8 @@ snapshots:
|
||||
|
||||
wrappy@1.0.2: {}
|
||||
|
||||
ws@8.21.1: {}
|
||||
|
||||
xml-name-validator@4.0.0: {}
|
||||
|
||||
xmlbuilder@15.1.1: {}
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { app, shell, BrowserWindow, ipcMain } from 'electron'
|
||||
import { app, shell, BrowserWindow } from 'electron'
|
||||
import { join } from 'path'
|
||||
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
||||
import icon from '../../resources/icon.png?asset'
|
||||
import { registerIpcHandlers } from './ipc-handlers.js'
|
||||
|
||||
function createWindow() {
|
||||
// Create the browser window.
|
||||
const mainWindow = new BrowserWindow({
|
||||
width: 900,
|
||||
height: 670,
|
||||
width: 1200,
|
||||
height: 800,
|
||||
minWidth: 900,
|
||||
minHeight: 600,
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
...(process.platform === 'linux' ? { icon } : {}),
|
||||
@@ -49,8 +52,8 @@ app.whenReady().then(() => {
|
||||
optimizer.watchWindowShortcuts(window)
|
||||
})
|
||||
|
||||
// IPC test
|
||||
ipcMain.on('ping', () => console.log('pong'))
|
||||
// 注册 IPC 处理器
|
||||
registerIpcHandlers()
|
||||
|
||||
createWindow()
|
||||
|
||||
|
||||
119
app/src/main/ipc-handlers.js
Normal file
119
app/src/main/ipc-handlers.js
Normal file
@@ -0,0 +1,119 @@
|
||||
import { ipcMain, BrowserWindow } from 'electron'
|
||||
import mqttManager from './mqtt-manager.js'
|
||||
|
||||
export function registerIpcHandlers() {
|
||||
// 获取主窗口(用于推送事件)
|
||||
function getMainWindow() {
|
||||
const windows = BrowserWindow.getAllWindows()
|
||||
return windows.length > 0 ? windows[0] : null
|
||||
}
|
||||
|
||||
// 向渲染进程发送事件
|
||||
function sendToRenderer(channel, data) {
|
||||
const win = getMainWindow()
|
||||
if (win && !win.isDestroyed()) {
|
||||
win.webContents.send(channel, data)
|
||||
}
|
||||
}
|
||||
|
||||
// 注册 MQTT 管理器的事件监听
|
||||
mqttManager.addListener((event, data) => {
|
||||
switch (event) {
|
||||
case 'status-change':
|
||||
sendToRenderer('mqtt:status-change', data)
|
||||
break
|
||||
case 'message':
|
||||
sendToRenderer('mqtt:message', data)
|
||||
break
|
||||
case 'subscription-change':
|
||||
sendToRenderer('mqtt:subscription-change', data)
|
||||
break
|
||||
case 'subscription-error':
|
||||
sendToRenderer('mqtt:subscription-error', data)
|
||||
break
|
||||
case 'publish-error':
|
||||
sendToRenderer('mqtt:publish-error', data)
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
// ==================== 服务器管理 ====================
|
||||
ipcMain.handle('mqtt:get-servers', () => {
|
||||
return mqttManager.getServers()
|
||||
})
|
||||
|
||||
ipcMain.handle('mqtt:add-server', (_, config) => {
|
||||
return mqttManager.addServer(config)
|
||||
})
|
||||
|
||||
ipcMain.handle('mqtt:update-server', (_, id, config) => {
|
||||
return mqttManager.updateServer(id, config)
|
||||
})
|
||||
|
||||
ipcMain.handle('mqtt:delete-server', (_, id) => {
|
||||
mqttManager.deleteServer(id)
|
||||
return true
|
||||
})
|
||||
|
||||
// ==================== 连接管理 ====================
|
||||
ipcMain.handle('mqtt:connect', (_, id) => {
|
||||
return mqttManager.connect(id)
|
||||
})
|
||||
|
||||
ipcMain.handle('mqtt:disconnect', (_, id) => {
|
||||
mqttManager.disconnect(id)
|
||||
return true
|
||||
})
|
||||
|
||||
// ==================== 主题管理 ====================
|
||||
ipcMain.handle('mqtt:add-topic', (_, serverId, topic, qos) => {
|
||||
return mqttManager.addTopic(serverId, topic, qos)
|
||||
})
|
||||
|
||||
ipcMain.handle('mqtt:remove-topic', (_, serverId, topic) => {
|
||||
return mqttManager.removeTopic(serverId, topic)
|
||||
})
|
||||
|
||||
ipcMain.handle('mqtt:subscribe', (_, serverId, topic) => {
|
||||
return mqttManager.subscribe(serverId, topic)
|
||||
})
|
||||
|
||||
ipcMain.handle('mqtt:unsubscribe', (_, serverId, topic) => {
|
||||
return mqttManager.unsubscribe(serverId, topic)
|
||||
})
|
||||
|
||||
ipcMain.handle('mqtt:subscribe-all', (_, serverId) => {
|
||||
return mqttManager.subscribeAll(serverId)
|
||||
})
|
||||
|
||||
ipcMain.handle('mqtt:update-topic', (_, serverId, topicId, topic, qos) => {
|
||||
return mqttManager.updateTopic(serverId, topicId, topic, qos)
|
||||
})
|
||||
|
||||
// ==================== 消息发布 ====================
|
||||
ipcMain.handle('mqtt:publish', (_, serverId, topic, payload, opts) => {
|
||||
return mqttManager.publish(serverId, topic, payload, opts)
|
||||
})
|
||||
|
||||
// ==================== 消息管理 ====================
|
||||
ipcMain.handle('mqtt:get-messages', (_, serverId) => {
|
||||
return mqttManager.getMessages(serverId)
|
||||
})
|
||||
|
||||
ipcMain.handle('mqtt:get-publish-history', (_, serverId) => {
|
||||
return mqttManager.getPublishHistory(serverId)
|
||||
})
|
||||
|
||||
ipcMain.handle('mqtt:clear-messages', (_, serverId) => {
|
||||
mqttManager.clearMessages(serverId)
|
||||
return true
|
||||
})
|
||||
|
||||
ipcMain.handle('mqtt:clear-publish-history', (_, serverId) => {
|
||||
mqttManager.clearPublishHistory(serverId)
|
||||
return true
|
||||
})
|
||||
|
||||
// 兼容旧的 ping 测试
|
||||
ipcMain.on('ping', () => console.log('pong'))
|
||||
}
|
||||
524
app/src/main/mqtt-manager.js
Normal file
524
app/src/main/mqtt-manager.js
Normal file
@@ -0,0 +1,524 @@
|
||||
import mqtt from 'mqtt'
|
||||
import { app } from 'electron'
|
||||
import { join } from 'path'
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'
|
||||
|
||||
class MqttManager {
|
||||
constructor() {
|
||||
this.clients = new Map() // serverId -> mqtt client
|
||||
this.servers = new Map() // serverId -> server config
|
||||
this.messages = new Map() // serverId -> message[]
|
||||
this.publishHistory = new Map() // serverId -> publishHistory[]
|
||||
this.listeners = new Set() // IPC callback functions
|
||||
|
||||
this.dataPath = join(app.getPath('userData'), 'frost-mqtt-data.json')
|
||||
this.loadData()
|
||||
}
|
||||
|
||||
// ==================== 数据持久化 ====================
|
||||
getDataDir() {
|
||||
const dir = app.getPath('userData')
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
loadData() {
|
||||
try {
|
||||
if (existsSync(this.dataPath)) {
|
||||
const raw = readFileSync(this.dataPath, 'utf-8')
|
||||
const data = JSON.parse(raw)
|
||||
if (data.servers) {
|
||||
data.servers.forEach((s) => {
|
||||
s.status = 'disconnected'
|
||||
s.topics = (s.topics || []).map((t) => ({
|
||||
...t,
|
||||
id: t.id || 'topic_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6),
|
||||
subscribed: false
|
||||
}))
|
||||
this.servers.set(s.id, s)
|
||||
})
|
||||
}
|
||||
if (data.messages) {
|
||||
Object.entries(data.messages).forEach(([id, msgs]) => {
|
||||
this.messages.set(id, msgs)
|
||||
})
|
||||
}
|
||||
if (data.publishHistory) {
|
||||
Object.entries(data.publishHistory).forEach(([id, history]) => {
|
||||
this.publishHistory.set(id, history)
|
||||
})
|
||||
}
|
||||
} else {
|
||||
this.initDefaultServers()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载数据失败:', e.message)
|
||||
this.initDefaultServers()
|
||||
}
|
||||
}
|
||||
|
||||
saveData() {
|
||||
try {
|
||||
const data = {
|
||||
servers: Array.from(this.servers.values()).map((s) => ({
|
||||
...s,
|
||||
status: 'disconnected',
|
||||
topics: s.topics.map((t) => ({ ...t, subscribed: false }))
|
||||
})),
|
||||
messages: Object.fromEntries(this.messages),
|
||||
publishHistory: Object.fromEntries(this.publishHistory)
|
||||
}
|
||||
writeFileSync(this.dataPath, JSON.stringify(data, null, 2), 'utf-8')
|
||||
} catch (e) {
|
||||
console.error('保存数据失败:', e.message)
|
||||
}
|
||||
}
|
||||
|
||||
initDefaultServers() {
|
||||
const defaults = [
|
||||
{
|
||||
id: 'srv_001',
|
||||
name: 'EMQX 公共测试',
|
||||
host: 'broker.emqx.io',
|
||||
port: 1883,
|
||||
protocol: 'mqtt',
|
||||
clientId: 'frost_client_' + Math.random().toString(36).slice(2, 8),
|
||||
username: '',
|
||||
password: '',
|
||||
keepAlive: 60,
|
||||
cleanSession: true,
|
||||
status: 'disconnected',
|
||||
topics: [
|
||||
{ id: 'topic_default_001', topic: 'sensor/+/temperature', qos: 1, subscribed: false },
|
||||
{ id: 'topic_default_002', topic: 'device/status', qos: 2, subscribed: false }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'srv_002',
|
||||
name: '本地 Mosquitto',
|
||||
host: 'localhost',
|
||||
port: 1883,
|
||||
protocol: 'mqtt',
|
||||
clientId: 'frost_local_' + Math.random().toString(36).slice(2, 8),
|
||||
username: '',
|
||||
password: '',
|
||||
keepAlive: 60,
|
||||
cleanSession: true,
|
||||
status: 'disconnected',
|
||||
topics: [
|
||||
{ id: 'topic_default_003', topic: 'home/+/light', qos: 1, subscribed: false },
|
||||
{ id: 'topic_default_004', topic: 'home/alarm', qos: 2, subscribed: false }
|
||||
]
|
||||
}
|
||||
]
|
||||
defaults.forEach((s) => {
|
||||
this.servers.set(s.id, s)
|
||||
this.messages.set(s.id, [])
|
||||
this.publishHistory.set(s.id, [])
|
||||
})
|
||||
this.saveData()
|
||||
}
|
||||
|
||||
// ==================== 监听器 ====================
|
||||
addListener(callback) {
|
||||
this.listeners.add(callback)
|
||||
}
|
||||
|
||||
removeListener(callback) {
|
||||
this.listeners.delete(callback)
|
||||
}
|
||||
|
||||
notify(event, data) {
|
||||
this.listeners.forEach((cb) => {
|
||||
try {
|
||||
cb(event, data)
|
||||
} catch (e) {
|
||||
console.error('通知失败:', e)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== 服务器管理 ====================
|
||||
getServers() {
|
||||
return Array.from(this.servers.values()).map((s) => ({
|
||||
...s,
|
||||
password: s.password ? '******' : ''
|
||||
}))
|
||||
}
|
||||
|
||||
getServer(id) {
|
||||
return this.servers.get(id) || null
|
||||
}
|
||||
|
||||
addServer(config) {
|
||||
const id = 'srv_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6)
|
||||
const server = {
|
||||
id,
|
||||
name: config.name,
|
||||
host: config.host,
|
||||
port: config.port || 1883,
|
||||
protocol: config.protocol || 'mqtt',
|
||||
clientId: config.clientId || 'frost_' + Math.random().toString(36).slice(2, 10),
|
||||
username: config.username || '',
|
||||
password: config.password || '',
|
||||
keepAlive: config.keepAlive || 60,
|
||||
cleanSession: config.cleanSession !== false,
|
||||
status: 'disconnected',
|
||||
topics: []
|
||||
}
|
||||
this.servers.set(id, server)
|
||||
this.messages.set(id, [])
|
||||
this.publishHistory.set(id, [])
|
||||
this.saveData()
|
||||
return server
|
||||
}
|
||||
|
||||
updateServer(id, config) {
|
||||
const server = this.servers.get(id)
|
||||
if (!server) return null
|
||||
Object.assign(server, {
|
||||
name: config.name,
|
||||
host: config.host,
|
||||
port: config.port || 1883,
|
||||
protocol: config.protocol || 'mqtt',
|
||||
clientId: config.clientId || server.clientId,
|
||||
username: config.username || '',
|
||||
keepAlive: config.keepAlive || 60,
|
||||
cleanSession: config.cleanSession !== false
|
||||
})
|
||||
if (config.password) {
|
||||
server.password = config.password
|
||||
}
|
||||
this.saveData()
|
||||
return server
|
||||
}
|
||||
|
||||
deleteServer(id) {
|
||||
// 先断开连接
|
||||
this.disconnect(id)
|
||||
this.servers.delete(id)
|
||||
this.messages.delete(id)
|
||||
this.publishHistory.delete(id)
|
||||
this.saveData()
|
||||
}
|
||||
|
||||
// ==================== 连接管理 ====================
|
||||
connect(id) {
|
||||
const server = this.servers.get(id)
|
||||
if (!server) return false
|
||||
|
||||
// 如果已连接,先断开
|
||||
if (this.clients.has(id)) {
|
||||
this.disconnect(id)
|
||||
}
|
||||
|
||||
server.status = 'connecting'
|
||||
this.notify('status-change', { id, status: 'connecting' })
|
||||
|
||||
const protocol = server.protocol || 'mqtt'
|
||||
const url = `${protocol}://${server.host}:${server.port}`
|
||||
|
||||
const options = {
|
||||
clientId: server.clientId,
|
||||
username: server.username || undefined,
|
||||
password: server.password || undefined,
|
||||
keepalive: server.keepAlive,
|
||||
clean: server.cleanSession,
|
||||
reconnectPeriod: 5000,
|
||||
connectTimeout: 10000
|
||||
}
|
||||
|
||||
try {
|
||||
const client = mqtt.connect(url, options)
|
||||
|
||||
client.on('connect', () => {
|
||||
server.status = 'connected'
|
||||
this.notify('status-change', { id, status: 'connected' })
|
||||
// 自动订阅已有主题
|
||||
server.topics.forEach((t) => {
|
||||
this._doSubscribe(id, t.topic, { qos: t.qos })
|
||||
})
|
||||
})
|
||||
|
||||
client.on('error', (err) => {
|
||||
console.error(`[${server.name}] 连接错误:`, err.message)
|
||||
server.status = 'error'
|
||||
this.notify('status-change', { id, status: 'error', error: err.message })
|
||||
})
|
||||
|
||||
client.on('close', () => {
|
||||
if (server.status === 'connected') {
|
||||
server.status = 'disconnected'
|
||||
server.topics.forEach((t) => {
|
||||
t.subscribed = false
|
||||
})
|
||||
this.notify('status-change', { id, status: 'disconnected' })
|
||||
}
|
||||
})
|
||||
|
||||
client.on('message', (topic, payload, packet) => {
|
||||
const msg = {
|
||||
time:
|
||||
new Date().toLocaleTimeString('zh-CN', { hour12: false }) +
|
||||
'.' +
|
||||
String(Date.now() % 1000).padStart(3, '0'),
|
||||
topic,
|
||||
payload: payload.toString(),
|
||||
qos: packet.qos,
|
||||
retain: packet.retain,
|
||||
direction: 'sub',
|
||||
id: 'msg_' + Date.now()
|
||||
}
|
||||
const msgs = this.messages.get(id) || []
|
||||
msgs.unshift(msg)
|
||||
if (msgs.length > 500) msgs.pop()
|
||||
this.messages.set(id, msgs)
|
||||
this.notify('message', { serverId: id, message: msg })
|
||||
})
|
||||
|
||||
this.clients.set(id, client)
|
||||
return true
|
||||
} catch (err) {
|
||||
server.status = 'error'
|
||||
this.notify('status-change', { id, status: 'error', error: err.message })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
disconnect(id) {
|
||||
const client = this.clients.get(id)
|
||||
if (client) {
|
||||
client.end(true)
|
||||
this.clients.delete(id)
|
||||
}
|
||||
const server = this.servers.get(id)
|
||||
if (server) {
|
||||
server.status = 'disconnected'
|
||||
server.topics.forEach((t) => {
|
||||
t.subscribed = false
|
||||
})
|
||||
this.notify('status-change', { id, status: 'disconnected' })
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 订阅管理 ====================
|
||||
_doSubscribe(serverId, topic, opts) {
|
||||
const client = this.clients.get(serverId)
|
||||
const server = this.servers.get(serverId)
|
||||
if (!client || !server || server.status !== 'connected') return false
|
||||
|
||||
client.subscribe(topic, opts, (err) => {
|
||||
if (err) {
|
||||
console.error(`订阅失败 [${topic}]:`, err.message)
|
||||
this.notify('subscription-error', { serverId, topic, error: err.message })
|
||||
} else {
|
||||
const t = server.topics.find((t) => t.topic === topic)
|
||||
if (t) t.subscribed = true
|
||||
this.notify('subscription-change', { serverId, topic, subscribed: true })
|
||||
}
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
addTopic(serverId, topic, qos = 1) {
|
||||
const server = this.servers.get(serverId)
|
||||
if (!server) return false
|
||||
if (server.topics.find((t) => t.topic === topic)) return false
|
||||
|
||||
const topicId = 'topic_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6)
|
||||
server.topics.push({ id: topicId, topic, qos, subscribed: false })
|
||||
this.saveData()
|
||||
|
||||
// 如果已连接,自动订阅
|
||||
if (server.status === 'connected') {
|
||||
this._doSubscribe(serverId, topic, { qos })
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
removeTopic(serverId, topic) {
|
||||
const server = this.servers.get(serverId)
|
||||
if (!server) return false
|
||||
|
||||
// 先取消订阅
|
||||
this.unsubscribe(serverId, topic)
|
||||
server.topics = server.topics.filter((t) => t.topic !== topic)
|
||||
this.saveData()
|
||||
return true
|
||||
}
|
||||
|
||||
subscribe(serverId, topic) {
|
||||
const server = this.servers.get(serverId)
|
||||
if (!server) return false
|
||||
const t = server.topics.find((t) => t.topic === topic)
|
||||
if (!t) return false
|
||||
return this._doSubscribe(serverId, topic, { qos: t.qos })
|
||||
}
|
||||
|
||||
unsubscribe(serverId, topic) {
|
||||
const client = this.clients.get(serverId)
|
||||
const server = this.servers.get(serverId)
|
||||
if (!client || !server || server.status !== 'connected') return false
|
||||
|
||||
client.unsubscribe(topic, {}, (err) => {
|
||||
if (!err) {
|
||||
const t = server.topics.find((t) => t.topic === topic)
|
||||
if (t) t.subscribed = false
|
||||
this.notify('subscription-change', { serverId, topic, subscribed: false })
|
||||
}
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
updateTopic(serverId, topicId, newTopic, newQos) {
|
||||
const server = this.servers.get(serverId)
|
||||
if (!server) return false
|
||||
const t = server.topics.find((item) => item.id === topicId)
|
||||
if (!t) return false
|
||||
|
||||
const topicChanged = t.topic !== newTopic
|
||||
if (
|
||||
topicChanged &&
|
||||
server.topics.find((item) => item.topic === newTopic && item.id !== topicId)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
const oldTopic = t.topic
|
||||
const wasSubscribed = t.subscribed
|
||||
|
||||
// 如果主题名变更且已订阅,先取消旧主题订阅
|
||||
if (topicChanged && wasSubscribed) {
|
||||
this.unsubscribe(serverId, oldTopic)
|
||||
}
|
||||
|
||||
t.topic = newTopic
|
||||
t.qos = newQos
|
||||
|
||||
// 如果已连接且此前已订阅,重新订阅新主题
|
||||
if (server.status === 'connected' && wasSubscribed) {
|
||||
this._doSubscribe(serverId, newTopic, { qos: newQos })
|
||||
}
|
||||
|
||||
this.saveData()
|
||||
this.notify('subscription-change', {
|
||||
serverId,
|
||||
topic: newTopic,
|
||||
subscribed: t.subscribed,
|
||||
topicId
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
subscribeAll(serverId) {
|
||||
const client = this.clients.get(serverId)
|
||||
const server = this.servers.get(serverId)
|
||||
if (!client || !server || server.status !== 'connected') return false
|
||||
|
||||
const topicsToSubscribe = server.topics.filter((t) => !t.subscribed)
|
||||
if (topicsToSubscribe.length === 0) return true
|
||||
|
||||
const subMap = Object.fromEntries(topicsToSubscribe.map((t) => [t.topic, { qos: t.qos }]))
|
||||
|
||||
client.subscribe(subMap, (err) => {
|
||||
if (err) {
|
||||
console.error('批量订阅失败:', err.message)
|
||||
topicsToSubscribe.forEach((t) => {
|
||||
this.notify('subscription-error', { serverId, topic: t.topic, error: err.message })
|
||||
})
|
||||
} else {
|
||||
topicsToSubscribe.forEach((t) => {
|
||||
t.subscribed = true
|
||||
this.notify('subscription-change', {
|
||||
serverId,
|
||||
topic: t.topic,
|
||||
subscribed: true,
|
||||
topicId: t.id
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
// ==================== 消息发布 ====================
|
||||
publish(serverId, topic, payload, opts = {}) {
|
||||
const client = this.clients.get(serverId)
|
||||
const server = this.servers.get(serverId)
|
||||
if (!client || !server || server.status !== 'connected') return false
|
||||
|
||||
const pubOpts = {
|
||||
qos: opts.qos ?? 1,
|
||||
retain: opts.retain ?? false
|
||||
}
|
||||
|
||||
client.publish(topic, payload, pubOpts, (err) => {
|
||||
if (err) {
|
||||
console.error(`发布失败 [${topic}]:`, err.message)
|
||||
this.notify('publish-error', { serverId, topic, error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 记录发布历史
|
||||
const pubEntry = {
|
||||
time:
|
||||
new Date().toLocaleTimeString('zh-CN', { hour12: false }) +
|
||||
'.' +
|
||||
String(Date.now() % 1000).padStart(3, '0'),
|
||||
topic,
|
||||
payload,
|
||||
qos: pubOpts.qos,
|
||||
retain: pubOpts.retain,
|
||||
direction: 'pub'
|
||||
}
|
||||
const history = this.publishHistory.get(serverId) || []
|
||||
history.unshift(pubEntry)
|
||||
if (history.length > 50) history.pop()
|
||||
this.publishHistory.set(serverId, history)
|
||||
|
||||
// 同时添加到消息日志
|
||||
const msgs = this.messages.get(serverId) || []
|
||||
msgs.unshift({
|
||||
...pubEntry,
|
||||
id: 'msg_' + Date.now()
|
||||
})
|
||||
if (msgs.length > 500) msgs.pop()
|
||||
this.messages.set(serverId, msgs)
|
||||
|
||||
this.notify('message', {
|
||||
serverId,
|
||||
message: {
|
||||
...pubEntry,
|
||||
id: 'msg_' + Date.now()
|
||||
}
|
||||
})
|
||||
|
||||
this.saveData()
|
||||
return true
|
||||
}
|
||||
|
||||
// ==================== 消息管理 ====================
|
||||
getMessages(serverId) {
|
||||
return this.messages.get(serverId) || []
|
||||
}
|
||||
|
||||
getPublishHistory(serverId) {
|
||||
return this.publishHistory.get(serverId) || []
|
||||
}
|
||||
|
||||
clearMessages(serverId) {
|
||||
this.messages.set(serverId, [])
|
||||
this.saveData()
|
||||
}
|
||||
|
||||
clearPublishHistory(serverId) {
|
||||
this.publishHistory.set(serverId, [])
|
||||
this.saveData()
|
||||
}
|
||||
}
|
||||
|
||||
// 单例
|
||||
const mqttManager = new MqttManager()
|
||||
export default mqttManager
|
||||
@@ -1,8 +1,64 @@
|
||||
import { contextBridge } from 'electron'
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
import { electronAPI } from '@electron-toolkit/preload'
|
||||
|
||||
// Custom APIs for renderer
|
||||
const api = {}
|
||||
const api = {
|
||||
// 服务器管理
|
||||
getServers: () => ipcRenderer.invoke('mqtt:get-servers'),
|
||||
addServer: (config) => ipcRenderer.invoke('mqtt:add-server', config),
|
||||
updateServer: (id, config) => ipcRenderer.invoke('mqtt:update-server', id, config),
|
||||
deleteServer: (id) => ipcRenderer.invoke('mqtt:delete-server', id),
|
||||
|
||||
// 连接管理
|
||||
connect: (id) => ipcRenderer.invoke('mqtt:connect', id),
|
||||
disconnect: (id) => ipcRenderer.invoke('mqtt:disconnect', id),
|
||||
|
||||
// 主题管理
|
||||
addTopic: (serverId, topic, qos) => ipcRenderer.invoke('mqtt:add-topic', serverId, topic, qos),
|
||||
removeTopic: (serverId, topic) => ipcRenderer.invoke('mqtt:remove-topic', serverId, topic),
|
||||
subscribe: (serverId, topic) => ipcRenderer.invoke('mqtt:subscribe', serverId, topic),
|
||||
unsubscribe: (serverId, topic) => ipcRenderer.invoke('mqtt:unsubscribe', serverId, topic),
|
||||
subscribeAll: (serverId) => ipcRenderer.invoke('mqtt:subscribe-all', serverId),
|
||||
updateTopic: (serverId, topicId, topic, qos) =>
|
||||
ipcRenderer.invoke('mqtt:update-topic', serverId, topicId, topic, qos),
|
||||
|
||||
// 消息发布
|
||||
publish: (serverId, topic, payload, opts) =>
|
||||
ipcRenderer.invoke('mqtt:publish', serverId, topic, payload, opts),
|
||||
|
||||
// 消息管理
|
||||
getMessages: (serverId) => ipcRenderer.invoke('mqtt:get-messages', serverId),
|
||||
getPublishHistory: (serverId) => ipcRenderer.invoke('mqtt:get-publish-history', serverId),
|
||||
clearMessages: (serverId) => ipcRenderer.invoke('mqtt:clear-messages', serverId),
|
||||
clearPublishHistory: (serverId) => ipcRenderer.invoke('mqtt:clear-publish-history', serverId),
|
||||
|
||||
// 事件监听
|
||||
onStatusChange: (callback) => {
|
||||
const handler = (_, data) => callback(data)
|
||||
ipcRenderer.on('mqtt:status-change', handler)
|
||||
return () => ipcRenderer.removeListener('mqtt:status-change', handler)
|
||||
},
|
||||
onMessage: (callback) => {
|
||||
const handler = (_, data) => callback(data)
|
||||
ipcRenderer.on('mqtt:message', handler)
|
||||
return () => ipcRenderer.removeListener('mqtt:message', handler)
|
||||
},
|
||||
onSubscriptionChange: (callback) => {
|
||||
const handler = (_, data) => callback(data)
|
||||
ipcRenderer.on('mqtt:subscription-change', handler)
|
||||
return () => ipcRenderer.removeListener('mqtt:subscription-change', handler)
|
||||
},
|
||||
onSubscriptionError: (callback) => {
|
||||
const handler = (_, data) => callback(data)
|
||||
ipcRenderer.on('mqtt:subscription-error', handler)
|
||||
return () => ipcRenderer.removeListener('mqtt:subscription-error', handler)
|
||||
},
|
||||
onPublishError: (callback) => {
|
||||
const handler = (_, data) => callback(data)
|
||||
ipcRenderer.on('mqtt:publish-error', handler)
|
||||
return () => ipcRenderer.removeListener('mqtt:publish-error', handler)
|
||||
}
|
||||
}
|
||||
|
||||
// Use `contextBridge` APIs to expose Electron APIs to
|
||||
// renderer only if context isolation is enabled, otherwise
|
||||
|
||||
@@ -2,11 +2,10 @@
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Electron</title>
|
||||
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
|
||||
<title>Frost MQTT Client</title>
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:"
|
||||
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws: wss: mqtt: mqtts:"
|
||||
/>
|
||||
</head>
|
||||
|
||||
@@ -14,4 +13,4 @@
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
@@ -1,24 +1,156 @@
|
||||
<script setup>
|
||||
import Versions from './components/Versions.vue'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import {
|
||||
NLayout,
|
||||
NMessageProvider,
|
||||
NDialogProvider,
|
||||
NConfigProvider,
|
||||
NTabs,
|
||||
NTabPane,
|
||||
zhCN,
|
||||
dateZhCN
|
||||
} from 'naive-ui'
|
||||
import Sidebar from './components/Sidebar.vue'
|
||||
import ServerModal from './components/ServerModal.vue'
|
||||
import Dashboard from './views/Dashboard.vue'
|
||||
import Subscribe from './views/Subscribe.vue'
|
||||
import Publish from './views/Publish.vue'
|
||||
import Messages from './views/Messages.vue'
|
||||
import { useMqttStore } from './stores/mqtt.js'
|
||||
|
||||
const ipcHandle = () => window.electron.ipcRenderer.send('ping')
|
||||
const store = useMqttStore()
|
||||
|
||||
const activeTab = ref('dashboard')
|
||||
const showServerModal = ref(false)
|
||||
const editServerData = ref(null)
|
||||
|
||||
onMounted(async () => {
|
||||
await store.init()
|
||||
})
|
||||
|
||||
function handleAddServer() {
|
||||
editServerData.value = null
|
||||
showServerModal.value = true
|
||||
}
|
||||
|
||||
function handleEditServer(server) {
|
||||
editServerData.value = server
|
||||
showServerModal.value = true
|
||||
}
|
||||
|
||||
function handleServerSaved() {
|
||||
// 刷新数据
|
||||
}
|
||||
|
||||
function handleSwitchTab(tab) {
|
||||
activeTab.value = tab
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<img alt="logo" class="logo" src="./assets/electron.svg" />
|
||||
<div class="creator">Powered by electron-vite</div>
|
||||
<div class="text">
|
||||
Build an Electron app with
|
||||
<span class="vue">Vue</span>
|
||||
</div>
|
||||
<p class="tip">Please try pressing <code>F12</code> to open the devTool</p>
|
||||
<div class="actions">
|
||||
<div class="action">
|
||||
<a href="https://electron-vite.org/" target="_blank" rel="noreferrer">Documentation</a>
|
||||
</div>
|
||||
<div class="action">
|
||||
<a target="_blank" rel="noreferrer" @click="ipcHandle">Send IPC</a>
|
||||
</div>
|
||||
</div>
|
||||
<Versions />
|
||||
<n-config-provider :locale="zhCN" :date-locale="dateZhCN" :theme="null">
|
||||
<n-message-provider placement="bottom">
|
||||
<n-dialog-provider>
|
||||
<n-layout has-sider position="absolute">
|
||||
<!-- 侧边栏 -->
|
||||
<Sidebar @add-server="handleAddServer" @edit-server="handleEditServer" />
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<n-layout content-class="main-layout-content">
|
||||
<n-tabs v-model:value="activeTab" type="line" size="medium" style="padding: 0 16px">
|
||||
<n-tab-pane name="dashboard" tab="仪表盘">
|
||||
<div class="tab-content dashboard-content">
|
||||
<Dashboard @switch-tab="handleSwitchTab" @edit-server="handleEditServer" />
|
||||
</div>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="subscribe" tab="主题订阅">
|
||||
<div class="tab-content">
|
||||
<Subscribe />
|
||||
</div>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="messages" tab="消息中心">
|
||||
<div class="tab-content">
|
||||
<div class="message-center-layout">
|
||||
<section class="publish-panel">
|
||||
<Publish />
|
||||
</section>
|
||||
<section class="messages-panel">
|
||||
<Messages />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</n-layout>
|
||||
|
||||
<ServerModal
|
||||
v-model:show="showServerModal"
|
||||
:edit-server="editServerData"
|
||||
@saved="handleServerSaved"
|
||||
/>
|
||||
</n-layout>
|
||||
</n-dialog-provider>
|
||||
</n-message-provider>
|
||||
</n-config-provider>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.main-layout-content {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
box-sizing: border-box;
|
||||
padding: 12px 0;
|
||||
height: calc(100vh - 50px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dashboard-content {
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.message-center-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(320px, 0.9fr) minmax(0, 1.6fr);
|
||||
gap: 12px;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.publish-panel,
|
||||
.messages-panel {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.publish-panel {
|
||||
box-sizing: border-box;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding-right: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.messages-panel {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.message-center-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
166
app/src/renderer/src/components/ServerModal.vue
Normal file
166
app/src/renderer/src/components/ServerModal.vue
Normal file
@@ -0,0 +1,166 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import {
|
||||
NModal,
|
||||
NForm,
|
||||
NFormItem,
|
||||
NGrid,
|
||||
NFormItemGi,
|
||||
NInput,
|
||||
NInputNumber,
|
||||
NSelect,
|
||||
NSwitch,
|
||||
NButton,
|
||||
useMessage
|
||||
} from 'naive-ui'
|
||||
import { useMqttStore } from '../stores/mqtt.js'
|
||||
|
||||
const props = defineProps({
|
||||
show: Boolean,
|
||||
editServer: Object
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:show', 'saved'])
|
||||
|
||||
const store = useMqttStore()
|
||||
const msg = useMessage()
|
||||
|
||||
const formRef = ref(null)
|
||||
const form = ref({
|
||||
name: '',
|
||||
host: '',
|
||||
port: 1883,
|
||||
protocol: 'mqtt',
|
||||
clientId: '',
|
||||
username: '',
|
||||
password: '',
|
||||
keepAlive: 60,
|
||||
cleanSession: true
|
||||
})
|
||||
|
||||
const rules = {
|
||||
name: { required: true, message: '请输入服务器名称', trigger: 'blur' },
|
||||
host: { required: true, message: '请输入主机地址', trigger: 'blur' },
|
||||
port: { required: true, type: 'number', message: '请输入端口号', trigger: 'blur' }
|
||||
}
|
||||
|
||||
const protocolOptions = [
|
||||
{ label: 'mqtt:// (默认)', value: 'mqtt' },
|
||||
{ label: 'mqtts:// (TLS)', value: 'mqtts' },
|
||||
{ label: 'ws:// (WebSocket)', value: 'ws' },
|
||||
{ label: 'wss:// (WebSocket TLS)', value: 'wss' }
|
||||
]
|
||||
|
||||
const isEdit = ref(false)
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(val) => {
|
||||
if (val) {
|
||||
if (props.editServer) {
|
||||
isEdit.value = true
|
||||
form.value = {
|
||||
name: props.editServer.name,
|
||||
host: props.editServer.host,
|
||||
port: props.editServer.port,
|
||||
protocol: props.editServer.protocol || 'mqtt',
|
||||
clientId: props.editServer.clientId || '',
|
||||
username: props.editServer.username || '',
|
||||
password: '',
|
||||
keepAlive: props.editServer.keepAlive || 60,
|
||||
cleanSession: props.editServer.cleanSession !== false
|
||||
}
|
||||
} else {
|
||||
isEdit.value = false
|
||||
form.value = {
|
||||
name: '',
|
||||
host: '',
|
||||
port: 1883,
|
||||
protocol: 'mqtt',
|
||||
clientId: 'frost_' + Math.random().toString(36).slice(2, 10),
|
||||
username: '',
|
||||
password: '',
|
||||
keepAlive: 60,
|
||||
cleanSession: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
async function handleSave() {
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (isEdit.value && props.editServer) {
|
||||
await store.updateServer(props.editServer.id, { ...form.value })
|
||||
msg.success('服务器配置已更新')
|
||||
} else {
|
||||
await store.addServer({ ...form.value })
|
||||
msg.success('服务器已添加')
|
||||
}
|
||||
emit('saved')
|
||||
emit('update:show', false)
|
||||
} catch (e) {
|
||||
msg.error('操作失败: ' + (e.message || '未知错误'))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-modal
|
||||
:show="show"
|
||||
:mask-closable="false"
|
||||
preset="card"
|
||||
:title="isEdit ? '编辑 MQTT 服务器' : '添加 MQTT 服务器'"
|
||||
style="width: 520px; max-width: 90vw"
|
||||
@update:show="$emit('update:show', $event)"
|
||||
>
|
||||
<n-form ref="formRef" :model="form" :rules="rules" label-placement="top" size="medium">
|
||||
<n-grid :cols="2" :x-gap="12">
|
||||
<n-form-item-gi label="服务器名称" path="name">
|
||||
<n-input v-model:value="form.name" placeholder="例如: 本地开发服务器" />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi label="主机地址" path="host">
|
||||
<n-input v-model:value="form.host" placeholder="例如: broker.emqx.io" />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi label="端口" path="port">
|
||||
<n-input-number v-model:value="form.port" :min="1" :max="65535" style="width: 100%" />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi label="协议">
|
||||
<n-select v-model:value="form.protocol" :options="protocolOptions" />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi label="客户端 ID">
|
||||
<n-input v-model:value="form.clientId" placeholder="自动生成" />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi label="Keep Alive (秒)">
|
||||
<n-input-number
|
||||
v-model:value="form.keepAlive"
|
||||
:min="1"
|
||||
:max="65535"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi label="用户名">
|
||||
<n-input v-model:value="form.username" placeholder="可选" />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi label="密码">
|
||||
<n-input v-model:value="form.password" type="password" placeholder="可选" />
|
||||
</n-form-item-gi>
|
||||
</n-grid>
|
||||
<n-form-item label="Clean Session">
|
||||
<n-switch v-model:value="form.cleanSession" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<template #footer>
|
||||
<div style="display: flex; justify-content: flex-end; gap: 8px">
|
||||
<n-button @click="$emit('update:show', false)">取消</n-button>
|
||||
<n-button type="primary" @click="handleSave">{{ isEdit ? '更新' : '保存' }}</n-button>
|
||||
</div>
|
||||
</template>
|
||||
</n-modal>
|
||||
</template>
|
||||
288
app/src/renderer/src/components/Sidebar.vue
Normal file
288
app/src/renderer/src/components/Sidebar.vue
Normal file
@@ -0,0 +1,288 @@
|
||||
<script setup>
|
||||
import { NLayoutSider, NButton, NTag, useMessage, useDialog } from 'naive-ui'
|
||||
import { useMqttStore } from '../stores/mqtt.js'
|
||||
|
||||
const emit = defineEmits(['add-server', 'edit-server'])
|
||||
const store = useMqttStore()
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
|
||||
async function handleConnect(server) {
|
||||
if (server.status === 'connected') {
|
||||
await store.disconnectServer(server.id)
|
||||
message.info('已断开连接: ' + server.name)
|
||||
} else {
|
||||
await store.connectServer(server.id)
|
||||
}
|
||||
}
|
||||
|
||||
function handleEdit(server) {
|
||||
emit('edit-server', server)
|
||||
}
|
||||
|
||||
function handleDelete(server) {
|
||||
dialog.warning({
|
||||
title: '确认删除',
|
||||
content: `确定要删除服务器 "${server.name}" 吗?`,
|
||||
positiveText: '删除',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: async () => {
|
||||
await store.deleteServer(server.id)
|
||||
message.info('服务器已删除')
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-layout-sider
|
||||
bordered
|
||||
collapse-mode="transform"
|
||||
:collapsed-width="0"
|
||||
:width="280"
|
||||
show-trigger="bar"
|
||||
:native-scrollbar="true"
|
||||
content-class="sidebar-scroll-container"
|
||||
:content-style="{ height: '100%', overflow: 'hidden' }"
|
||||
>
|
||||
<div class="sidebar-container">
|
||||
<div class="sidebar-header">
|
||||
<div class="sidebar-logo">F</div>
|
||||
<div>
|
||||
<div class="sidebar-title">Frost MQTT Client</div>
|
||||
<div class="sidebar-subtitle">多服务器 MQTT 客户端</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="server-list">
|
||||
<div
|
||||
v-for="srv in store.servers.value"
|
||||
:key="srv.id"
|
||||
class="server-item"
|
||||
:class="{ active: srv.id === store.activeServerId.value }"
|
||||
@click="store.setActiveServer(srv.id)"
|
||||
>
|
||||
<div class="server-item-top">
|
||||
<span class="server-name">{{ srv.name }}</span>
|
||||
<div class="server-actions">
|
||||
<n-button text size="tiny" @click.stop="handleEdit(srv)">
|
||||
<template #icon>
|
||||
<span style="font-size: 14px">✎</span>
|
||||
</template>
|
||||
</n-button>
|
||||
<n-button text size="tiny" type="error" @click.stop="handleDelete(srv)">
|
||||
<template #icon>
|
||||
<span style="font-size: 14px">✕</span>
|
||||
</template>
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="server-url">
|
||||
<n-tag
|
||||
:bordered="false"
|
||||
size="small"
|
||||
:type="
|
||||
srv.status === 'connected'
|
||||
? 'success'
|
||||
: srv.status === 'connecting'
|
||||
? 'warning'
|
||||
: 'default'
|
||||
"
|
||||
>
|
||||
<template #icon>
|
||||
<span class="status-dot" :class="srv.status" />
|
||||
</template>
|
||||
{{ srv.protocol }}://{{ srv.host }}:{{ srv.port }}
|
||||
</n-tag>
|
||||
</div>
|
||||
<div class="server-bottom">
|
||||
<span class="server-status-text">
|
||||
{{
|
||||
srv.status === 'connected'
|
||||
? '已连接'
|
||||
: srv.status === 'connecting'
|
||||
? '连接中...'
|
||||
: srv.status === 'error'
|
||||
? '连接失败'
|
||||
: '未连接'
|
||||
}}
|
||||
</span>
|
||||
<n-button
|
||||
size="tiny"
|
||||
:type="
|
||||
srv.status === 'connected'
|
||||
? 'error'
|
||||
: srv.status === 'connecting'
|
||||
? 'warning'
|
||||
: 'success'
|
||||
"
|
||||
:disabled="srv.status === 'connecting'"
|
||||
@click.stop="handleConnect(srv)"
|
||||
>
|
||||
{{
|
||||
srv.status === 'connected' ? '断开' : srv.status === 'connecting' ? '...' : '连接'
|
||||
}}
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<n-button type="primary" block @click="$emit('add-server')"> + 添加服务器 </n-button>
|
||||
</div>
|
||||
</div>
|
||||
</n-layout-sider>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.sidebar-scroll-container) {
|
||||
height: 100%;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
.sidebar-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--n-color);
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border-bottom: 1px solid var(--n-border-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-logo {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: #18a058;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.sidebar-subtitle {
|
||||
font-size: 11px;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.server-list {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.server-item {
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
margin-bottom: 4px;
|
||||
border: 1px solid transparent;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.server-item:hover {
|
||||
background: var(--n-color-hover);
|
||||
}
|
||||
|
||||
.server-item.active {
|
||||
background: var(--n-color-pressed);
|
||||
border-color: #18a058;
|
||||
}
|
||||
|
||||
.server-item-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.server-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.server-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.server-item:hover .server-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.server-url {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.server-bottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.server-status-text {
|
||||
font-size: 11px;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.status-dot.connected {
|
||||
background: #18a058;
|
||||
}
|
||||
.status-dot.connecting {
|
||||
background: #f0a020;
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
.status-dot.disconnected {
|
||||
background: #999;
|
||||
}
|
||||
.status-dot.error {
|
||||
background: #d03050;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 10px 12px;
|
||||
border-top: 1px solid var(--n-border-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,5 @@
|
||||
import './assets/main.css'
|
||||
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
const app = createApp(App)
|
||||
app.mount('#app')
|
||||
|
||||
238
app/src/renderer/src/stores/mqtt.js
Normal file
238
app/src/renderer/src/stores/mqtt.js
Normal file
@@ -0,0 +1,238 @@
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
// 全局状态(单例)
|
||||
const servers = ref([])
|
||||
const activeServerId = ref(null)
|
||||
const messages = ref([])
|
||||
const publishHistory = ref([])
|
||||
const loading = ref(false)
|
||||
|
||||
// 清理函数集合
|
||||
let cleanupFns = []
|
||||
|
||||
function setupListeners() {
|
||||
// 清理旧监听器
|
||||
cleanupFns.forEach((fn) => fn())
|
||||
cleanupFns = []
|
||||
|
||||
cleanupFns.push(
|
||||
window.api.onStatusChange((data) => {
|
||||
const srv = servers.value.find((s) => s.id === data.id)
|
||||
if (srv) {
|
||||
srv.status = data.status
|
||||
if (data.status === 'disconnected') {
|
||||
srv.topics.forEach((t) => {
|
||||
t.subscribed = false
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
cleanupFns.push(
|
||||
window.api.onMessage((data) => {
|
||||
if (data.serverId === activeServerId.value) {
|
||||
messages.value.unshift(data.message)
|
||||
if (messages.value.length > 500) messages.value.pop()
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
cleanupFns.push(
|
||||
window.api.onSubscriptionChange((data) => {
|
||||
if (data.serverId === activeServerId.value) {
|
||||
const srv = servers.value.find((s) => s.id === data.serverId)
|
||||
if (srv) {
|
||||
const t = srv.topics.find((t) => t.topic === data.topic || t.id === data.topicId)
|
||||
if (t) {
|
||||
t.subscribed = data.subscribed
|
||||
if (data.topic && data.topic !== t.topic) {
|
||||
t.topic = data.topic
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export function useMqttStore() {
|
||||
const activeServer = computed(
|
||||
() => servers.value.find((s) => s.id === activeServerId.value) || null
|
||||
)
|
||||
|
||||
const connectedServers = computed(() => servers.value.filter((s) => s.status === 'connected'))
|
||||
|
||||
const totalTopics = computed(() =>
|
||||
servers.value.reduce((sum, s) => sum + (s.topics?.length || 0), 0)
|
||||
)
|
||||
|
||||
const subscribedTopics = computed(() =>
|
||||
servers.value.reduce((sum, s) => sum + (s.topics?.filter((t) => t.subscribed)?.length || 0), 0)
|
||||
)
|
||||
|
||||
const totalMessages = computed(() => messages.value.length)
|
||||
|
||||
// ==================== 初始化 ====================
|
||||
async function init() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await window.api.getServers()
|
||||
servers.value = data
|
||||
if (data.length > 0 && !activeServerId.value) {
|
||||
activeServerId.value = data[0].id
|
||||
}
|
||||
setupListeners()
|
||||
} catch (e) {
|
||||
console.error('初始化失败:', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 服务器管理 ====================
|
||||
function setActiveServer(id) {
|
||||
activeServerId.value = id
|
||||
// 切换时刷新消息
|
||||
refreshMessages()
|
||||
refreshPublishHistory()
|
||||
}
|
||||
|
||||
async function addServer(config) {
|
||||
const server = await window.api.addServer(config)
|
||||
servers.value.push(server)
|
||||
if (!activeServerId.value) {
|
||||
activeServerId.value = server.id
|
||||
}
|
||||
return server
|
||||
}
|
||||
|
||||
async function updateServer(id, config) {
|
||||
const result = await window.api.updateServer(id, config)
|
||||
if (result) {
|
||||
const idx = servers.value.findIndex((s) => s.id === id)
|
||||
if (idx !== -1) {
|
||||
servers.value[idx] = { ...servers.value[idx], ...result }
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
async function deleteServer(id) {
|
||||
await window.api.deleteServer(id)
|
||||
servers.value = servers.value.filter((s) => s.id !== id)
|
||||
if (activeServerId.value === id) {
|
||||
activeServerId.value = servers.value.length > 0 ? servers.value[0].id : null
|
||||
}
|
||||
}
|
||||
|
||||
async function connectServer(id) {
|
||||
return await window.api.connect(id)
|
||||
}
|
||||
|
||||
async function disconnectServer(id) {
|
||||
await window.api.disconnect(id)
|
||||
}
|
||||
|
||||
// ==================== 主题管理 ====================
|
||||
async function addTopic(serverId, topic, qos) {
|
||||
const result = await window.api.addTopic(serverId, topic, qos)
|
||||
if (result) {
|
||||
const srv = servers.value.find((s) => s.id === serverId)
|
||||
if (srv) {
|
||||
srv.topics.push({ topic, qos, subscribed: false })
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
async function removeTopic(serverId, topic) {
|
||||
await window.api.removeTopic(serverId, topic)
|
||||
const srv = servers.value.find((s) => s.id === serverId)
|
||||
if (srv) {
|
||||
srv.topics = srv.topics.filter((t) => t.topic !== topic)
|
||||
}
|
||||
}
|
||||
|
||||
async function subscribeTopic(serverId, topic) {
|
||||
return await window.api.subscribe(serverId, topic)
|
||||
}
|
||||
|
||||
async function unsubscribeTopic(serverId, topic) {
|
||||
return await window.api.unsubscribe(serverId, topic)
|
||||
}
|
||||
|
||||
async function subscribeAllTopics(serverId) {
|
||||
return await window.api.subscribeAll(serverId)
|
||||
}
|
||||
|
||||
async function updateTopic(serverId, topicId, topic, qos) {
|
||||
return await window.api.updateTopic(serverId, topicId, topic, qos)
|
||||
}
|
||||
|
||||
// ==================== 消息发布 ====================
|
||||
async function publishMessage(serverId, topic, payload, opts) {
|
||||
const result = await window.api.publish(serverId, topic, payload, opts)
|
||||
if (result && serverId === activeServerId.value) {
|
||||
refreshPublishHistory()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ==================== 消息管理 ====================
|
||||
async function refreshMessages() {
|
||||
if (!activeServerId.value) return
|
||||
messages.value = await window.api.getMessages(activeServerId.value)
|
||||
}
|
||||
|
||||
async function refreshPublishHistory() {
|
||||
if (!activeServerId.value) return
|
||||
publishHistory.value = await window.api.getPublishHistory(activeServerId.value)
|
||||
}
|
||||
|
||||
async function clearMessages() {
|
||||
if (!activeServerId.value) return
|
||||
await window.api.clearMessages(activeServerId.value)
|
||||
messages.value = []
|
||||
}
|
||||
|
||||
async function clearPublishHistory() {
|
||||
if (!activeServerId.value) return
|
||||
await window.api.clearPublishHistory(activeServerId.value)
|
||||
publishHistory.value = []
|
||||
}
|
||||
|
||||
return {
|
||||
// 状态
|
||||
servers,
|
||||
activeServerId,
|
||||
activeServer,
|
||||
messages,
|
||||
publishHistory,
|
||||
loading,
|
||||
// 计算属性
|
||||
connectedServers,
|
||||
totalTopics,
|
||||
subscribedTopics,
|
||||
totalMessages,
|
||||
// 方法
|
||||
init,
|
||||
setActiveServer,
|
||||
addServer,
|
||||
updateServer,
|
||||
deleteServer,
|
||||
connectServer,
|
||||
disconnectServer,
|
||||
addTopic,
|
||||
removeTopic,
|
||||
subscribeTopic,
|
||||
unsubscribeTopic,
|
||||
subscribeAllTopics,
|
||||
updateTopic,
|
||||
publishMessage,
|
||||
refreshMessages,
|
||||
refreshPublishHistory,
|
||||
clearMessages,
|
||||
clearPublishHistory
|
||||
}
|
||||
}
|
||||
219
app/src/renderer/src/views/Dashboard.vue
Normal file
219
app/src/renderer/src/views/Dashboard.vue
Normal file
@@ -0,0 +1,219 @@
|
||||
<script setup>
|
||||
import { useMqttStore } from '../stores/mqtt.js'
|
||||
import {
|
||||
NGrid,
|
||||
NGridItem,
|
||||
NCard,
|
||||
NStatistic,
|
||||
NTag,
|
||||
NDescriptions,
|
||||
NDescriptionsItem,
|
||||
NSpace,
|
||||
NButton,
|
||||
NList,
|
||||
NListItem
|
||||
} from 'naive-ui'
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
const store = useMqttStore()
|
||||
const msg = useMessage()
|
||||
|
||||
function handleAction(srv, action) {
|
||||
if (srv.id === store.activeServerId.value) {
|
||||
emit('switch-tab', action)
|
||||
} else {
|
||||
store.setActiveServer(srv.id)
|
||||
emit('switch-tab', action)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleConnection(srv) {
|
||||
if (srv.status === 'connected') {
|
||||
await store.disconnectServer(srv.id)
|
||||
msg.info('已断开连接: ' + srv.name)
|
||||
} else if (srv.status === 'disconnected' || srv.status === 'error') {
|
||||
await store.connectServer(srv.id)
|
||||
}
|
||||
}
|
||||
|
||||
const emit = defineEmits(['switch-tab', 'edit-server'])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dashboard">
|
||||
<!-- 统计卡片 -->
|
||||
<n-grid :cols="4" :x-gap="12" responsive="screen">
|
||||
<n-grid-item>
|
||||
<n-card size="small" :bordered="true">
|
||||
<n-statistic label="服务器总数" :value="store.servers.value.length" />
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-card size="small" :bordered="true">
|
||||
<n-statistic label="已连接" :value="store.connectedServers.value.length" />
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-card size="small" :bordered="true">
|
||||
<n-statistic
|
||||
label="已订阅主题"
|
||||
:value="`${store.subscribedTopics.value}/${store.totalTopics.value}`"
|
||||
/>
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-card size="small" :bordered="true">
|
||||
<n-statistic label="消息总数" :value="store.messages.value.length" />
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
|
||||
<!-- 当前服务器 -->
|
||||
<n-card
|
||||
v-if="store.activeServer.value"
|
||||
title="当前服务器"
|
||||
size="small"
|
||||
style="margin-top: 12px"
|
||||
>
|
||||
<template #header-extra>
|
||||
<n-tag
|
||||
:type="
|
||||
store.activeServer.value.status === 'connected'
|
||||
? 'success'
|
||||
: store.activeServer.value.status === 'connecting'
|
||||
? 'warning'
|
||||
: 'error'
|
||||
"
|
||||
size="small"
|
||||
>
|
||||
{{
|
||||
store.activeServer.value.status === 'connected'
|
||||
? '已连接'
|
||||
: store.activeServer.value.status === 'connecting'
|
||||
? '连接中...'
|
||||
: '未连接'
|
||||
}}
|
||||
</n-tag>
|
||||
</template>
|
||||
<n-descriptions :column="2" size="small" bordered>
|
||||
<n-descriptions-item label="地址">
|
||||
{{ store.activeServer.value.protocol }}://{{ store.activeServer.value.host }}:{{
|
||||
store.activeServer.value.port
|
||||
}}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="客户端 ID">
|
||||
{{ store.activeServer.value.clientId }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="Keep Alive">
|
||||
{{ store.activeServer.value.keepAlive }}s
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="Clean Session">
|
||||
{{ store.activeServer.value.cleanSession ? '是' : '否' }}
|
||||
</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
<n-space style="margin-top: 12px">
|
||||
<n-button
|
||||
size="small"
|
||||
:type="store.activeServer.value.status === 'connected' ? 'error' : 'success'"
|
||||
@click="handleToggleConnection(store.activeServer.value)"
|
||||
>
|
||||
{{ store.activeServer.value.status === 'connected' ? '断开连接' : '连接服务器' }}
|
||||
</n-button>
|
||||
<n-button size="small" @click="$emit('edit-server', store.activeServer.value)"
|
||||
>编辑配置</n-button
|
||||
>
|
||||
<n-button size="small" @click="handleAction(store.activeServer.value, 'subscribe')"
|
||||
>管理订阅</n-button
|
||||
>
|
||||
<n-button size="small" @click="handleAction(store.activeServer.value, 'publish')"
|
||||
>发布消息</n-button
|
||||
>
|
||||
<n-button size="small" @click="handleAction(store.activeServer.value, 'messages')"
|
||||
>查看消息</n-button
|
||||
>
|
||||
</n-space>
|
||||
</n-card>
|
||||
|
||||
<!-- 所有服务器状态 -->
|
||||
<n-card title="所有服务器状态" size="small" style="margin-top: 12px">
|
||||
<n-list>
|
||||
<n-list-item v-for="srv in store.servers.value" :key="srv.id">
|
||||
<template #prefix>
|
||||
<span class="status-dot" :class="srv.status" />
|
||||
</template>
|
||||
<n-space align="center">
|
||||
<span style="font-weight: 500">{{ srv.name }}</span>
|
||||
<n-tag
|
||||
:bordered="false"
|
||||
size="small"
|
||||
:type="
|
||||
srv.status === 'connected'
|
||||
? 'success'
|
||||
: srv.status === 'connecting'
|
||||
? 'warning'
|
||||
: 'default'
|
||||
"
|
||||
>
|
||||
{{
|
||||
srv.status === 'connected'
|
||||
? '已连接'
|
||||
: srv.status === 'connecting'
|
||||
? '连接中...'
|
||||
: '未连接'
|
||||
}}
|
||||
</n-tag>
|
||||
</n-space>
|
||||
<template #suffix>
|
||||
<n-button
|
||||
size="tiny"
|
||||
:type="srv.status === 'connected' ? 'error' : 'success'"
|
||||
:disabled="srv.status === 'connecting'"
|
||||
@click="handleToggleConnection(srv)"
|
||||
>
|
||||
{{
|
||||
srv.status === 'connected' ? '断开' : srv.status === 'connecting' ? '...' : '连接'
|
||||
}}
|
||||
</n-button>
|
||||
</template>
|
||||
</n-list-item>
|
||||
</n-list>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dashboard {
|
||||
max-width: 900px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.status-dot.connected {
|
||||
background: #18a058;
|
||||
}
|
||||
.status-dot.connecting {
|
||||
background: #f0a020;
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
.status-dot.disconnected {
|
||||
background: #999;
|
||||
}
|
||||
.status-dot.error {
|
||||
background: #d03050;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
169
app/src/renderer/src/views/Messages.vue
Normal file
169
app/src/renderer/src/views/Messages.vue
Normal file
@@ -0,0 +1,169 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { useMqttStore } from '../stores/mqtt.js'
|
||||
import { NCard, NSpace, NInput, NButton, NEmpty } from 'naive-ui'
|
||||
|
||||
const store = useMqttStore()
|
||||
|
||||
const filter = ref('')
|
||||
|
||||
const filteredMessages = computed(() => {
|
||||
if (!filter.value.trim()) return store.messages.value
|
||||
const f = filter.value.trim().toLowerCase()
|
||||
return store.messages.value.filter((m) => m.topic.toLowerCase().includes(f))
|
||||
})
|
||||
|
||||
async function handleClear() {
|
||||
await store.clearMessages()
|
||||
}
|
||||
|
||||
function formatPayload(payload) {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(payload), null, 2)
|
||||
} catch {
|
||||
return payload
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="store.activeServer.value" class="messages-view">
|
||||
<n-card
|
||||
size="small"
|
||||
:content-style="{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: 0,
|
||||
overflow: 'hidden'
|
||||
}"
|
||||
>
|
||||
<template #header>
|
||||
<n-space align="center" justify="space-between" style="width: 100%">
|
||||
<div>
|
||||
<span style="font-weight: 700">消息监控</span>
|
||||
<span style="font-size: 12px; opacity: 0.55; margin-left: 8px"
|
||||
>实时查看接收和发送的 MQTT 消息</span
|
||||
>
|
||||
</div>
|
||||
<n-space>
|
||||
<n-input
|
||||
v-model:value="filter"
|
||||
size="small"
|
||||
placeholder="过滤主题..."
|
||||
style="width: 180px"
|
||||
clearable
|
||||
/>
|
||||
<n-button size="small" @click="handleClear">清空</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
</template>
|
||||
<n-empty v-if="filteredMessages.length === 0" description="等待消息...">
|
||||
<template #icon>
|
||||
<span style="font-size: 32px; opacity: 0.4">☰</span>
|
||||
</template>
|
||||
</n-empty>
|
||||
<div v-else ref="logRef" class="msg-log">
|
||||
<div v-for="m in filteredMessages" :key="m.id" class="msg-entry">
|
||||
<div class="msg-header">
|
||||
<span class="msg-time">{{ m.time }}</span>
|
||||
<span class="msg-dir" :class="m.direction">{{
|
||||
m.direction === 'pub' ? 'PUB' : 'SUB'
|
||||
}}</span>
|
||||
<span class="msg-topic" :title="m.topic">{{ m.topic }}</span>
|
||||
<span class="msg-meta">QoS{{ m.qos }}{{ m.retain ? ' R' : '' }}</span>
|
||||
</div>
|
||||
<pre class="msg-payload">{{ formatPayload(m.payload) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</n-card>
|
||||
</div>
|
||||
<n-empty v-else description="请先在左侧选择一个 MQTT 服务器" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.messages-view {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.messages-view :deep(.n-card) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.messages-view :deep(.n-card__content) {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.msg-log {
|
||||
background: #1e1e2e;
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
box-sizing: border-box;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
color: #cdd6f4;
|
||||
}
|
||||
|
||||
.msg-entry {
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.msg-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.msg-time {
|
||||
color: #6c7086;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.msg-dir {
|
||||
flex-shrink: 0;
|
||||
font-weight: 700;
|
||||
width: 28px;
|
||||
}
|
||||
.msg-dir.pub {
|
||||
color: #89b4fa;
|
||||
}
|
||||
.msg-dir.sub {
|
||||
color: #a6e3a1;
|
||||
}
|
||||
.msg-topic {
|
||||
color: #f9e2af;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.msg-payload {
|
||||
margin: 6px 0 0;
|
||||
padding: 8px 10px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #cdd6f4;
|
||||
font: inherit;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.msg-meta {
|
||||
color: #6c7086;
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
339
app/src/renderer/src/views/Publish.vue
Normal file
339
app/src/renderer/src/views/Publish.vue
Normal file
@@ -0,0 +1,339 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { useMqttStore } from '../stores/mqtt.js'
|
||||
import {
|
||||
NCard,
|
||||
NSpace,
|
||||
NFormItem,
|
||||
NInput,
|
||||
NSelect,
|
||||
NSwitch,
|
||||
NButton,
|
||||
NEmpty,
|
||||
NTabs,
|
||||
NTabPane,
|
||||
useMessage
|
||||
} from 'naive-ui'
|
||||
|
||||
const store = useMqttStore()
|
||||
const msg = useMessage()
|
||||
|
||||
const topic = ref('')
|
||||
const payload = ref('')
|
||||
const qos = ref(1)
|
||||
const retain = ref(false)
|
||||
const activePublishTab = ref('publish')
|
||||
const formStorageKey = 'mqtt-client.publish-form'
|
||||
const publishTabStorageKey = 'mqtt-client.publish-active-tab'
|
||||
|
||||
let formReady = false
|
||||
|
||||
try {
|
||||
const savedForm = JSON.parse(localStorage.getItem(formStorageKey) || '{}')
|
||||
if (typeof savedForm.topic === 'string') topic.value = savedForm.topic
|
||||
if (typeof savedForm.payload === 'string') payload.value = savedForm.payload
|
||||
if ([0, 1, 2].includes(savedForm.qos)) qos.value = savedForm.qos
|
||||
if (typeof savedForm.retain === 'boolean') retain.value = savedForm.retain
|
||||
} catch {
|
||||
localStorage.removeItem(formStorageKey)
|
||||
}
|
||||
|
||||
try {
|
||||
const savedTab = localStorage.getItem(publishTabStorageKey)
|
||||
if (savedTab === 'publish' || savedTab === 'history') activePublishTab.value = savedTab
|
||||
} catch {
|
||||
localStorage.removeItem(publishTabStorageKey)
|
||||
}
|
||||
|
||||
watch([topic, payload, qos, retain], () => {
|
||||
if (!formReady) return
|
||||
localStorage.setItem(
|
||||
formStorageKey,
|
||||
JSON.stringify({
|
||||
topic: topic.value,
|
||||
payload: payload.value,
|
||||
qos: qos.value,
|
||||
retain: retain.value
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
watch(activePublishTab, (tab) => {
|
||||
localStorage.setItem(publishTabStorageKey, tab)
|
||||
})
|
||||
|
||||
formReady = true
|
||||
|
||||
const qosOptions = [
|
||||
{ label: 'QoS 0 - 最多一次', value: 0 },
|
||||
{ label: 'QoS 1 - 至少一次', value: 1 },
|
||||
{ label: 'QoS 2 - 恰好一次', value: 2 }
|
||||
]
|
||||
|
||||
async function handlePublish() {
|
||||
const srv = store.activeServer.value
|
||||
if (!srv) return
|
||||
if (srv.status !== 'connected') {
|
||||
msg.warning('请先连接服务器')
|
||||
return
|
||||
}
|
||||
if (!topic.value.trim()) {
|
||||
msg.warning('请输入目标主题')
|
||||
return
|
||||
}
|
||||
const result = await store.publishMessage(srv.id, topic.value.trim(), payload.value, {
|
||||
qos: qos.value,
|
||||
retain: retain.value
|
||||
})
|
||||
if (result) {
|
||||
msg.success('消息已发布到: ' + topic.value)
|
||||
}
|
||||
}
|
||||
|
||||
function handleFormatJson() {
|
||||
try {
|
||||
const obj = JSON.parse(payload.value)
|
||||
payload.value = JSON.stringify(obj, null, 2)
|
||||
msg.info('JSON 已格式化')
|
||||
} catch {
|
||||
msg.warning('无效的 JSON 格式')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClearHistory() {
|
||||
await store.clearPublishHistory()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="store.activeServer.value" class="publish-view">
|
||||
<n-tabs v-model:value="activePublishTab" type="line" size="small">
|
||||
<n-tab-pane name="publish" tab="发布消息">
|
||||
<n-card
|
||||
size="small"
|
||||
:content-style="{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: 0,
|
||||
overflow: 'hidden'
|
||||
}"
|
||||
>
|
||||
<div class="publish-form">
|
||||
<n-form-item class="topic-field" label="目标主题 (Topic)">
|
||||
<n-input v-model:value="topic" placeholder="例如: sensor/device01/temperature" />
|
||||
</n-form-item>
|
||||
<n-space align="center">
|
||||
<n-form-item label="QoS">
|
||||
<n-select v-model:value="qos" :options="qosOptions" style="width: 160px" />
|
||||
</n-form-item>
|
||||
<n-form-item label="保留消息">
|
||||
<n-switch v-model:value="retain" />
|
||||
</n-form-item>
|
||||
</n-space>
|
||||
<n-form-item class="payload-field" label="消息内容 (Payload)">
|
||||
<n-input
|
||||
v-model:value="payload"
|
||||
type="textarea"
|
||||
:resizable="false"
|
||||
:rows="1"
|
||||
placeholder='{"temperature": 25.6, "humidity": 68.3}'
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-space>
|
||||
<n-button type="primary" @click="handlePublish">发布消息</n-button>
|
||||
<n-button @click="handleFormatJson">格式化 JSON</n-button>
|
||||
</n-space>
|
||||
</div>
|
||||
</n-card>
|
||||
</n-tab-pane>
|
||||
|
||||
<n-tab-pane name="history" tab="发布历史">
|
||||
<n-card
|
||||
size="small"
|
||||
:content-style="{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: 0,
|
||||
overflow: 'hidden'
|
||||
}"
|
||||
>
|
||||
<n-space style="margin-bottom: 12px">
|
||||
<n-button size="small" @click="handleClearHistory">清空</n-button>
|
||||
</n-space>
|
||||
<n-empty v-if="store.publishHistory.value.length === 0" description="暂无发布记录" />
|
||||
<div v-else class="msg-log">
|
||||
<div v-for="m in store.publishHistory.value" :key="m.id || m.time" class="msg-entry">
|
||||
<div class="msg-header">
|
||||
<span class="msg-time">{{ m.time }}</span>
|
||||
<span class="msg-dir pub">PUB</span>
|
||||
<span class="msg-topic" :title="m.topic">{{ m.topic }}</span>
|
||||
<span class="msg-meta">QoS{{ m.qos }}{{ m.retain ? ' R' : '' }}</span>
|
||||
</div>
|
||||
<pre class="msg-payload">{{ m.payload }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</n-card>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</div>
|
||||
<n-empty v-else description="请先在左侧选择一个 MQTT 服务器" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.publish-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.publish-view :deep(.n-tabs) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.publish-view :deep(.n-tabs-nav) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.publish-view :deep(.n-tabs-pane-wrapper) {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.publish-view :deep(.n-tabs-tab-panels),
|
||||
.publish-view :deep(.n-tabs-pane-wrapper) {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.publish-view :deep(.n-tab-pane) {
|
||||
box-sizing: border-box;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.publish-view :deep(.n-tab-pane > .n-card) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.publish-view :deep(.n-card__content) {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.publish-view :deep(.n-card__content > div) {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.topic-field {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.topic-field :deep(.n-form-item-blank) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.publish-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.publish-form .payload-field {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.publish-form .payload-field :deep(.n-form-item-blank) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.publish-form .payload-field :deep(.n-input) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.publish-form .payload-field :deep(.n-input__textarea-el),
|
||||
.publish-form .payload-field :deep(textarea) {
|
||||
resize: none !important;
|
||||
height: 100% !important;
|
||||
min-height: 0 !important;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.msg-log {
|
||||
background: #1e1e2e;
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
box-sizing: border-box;
|
||||
overflow-y: auto;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
color: #cdd6f4;
|
||||
}
|
||||
|
||||
.msg-entry {
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.msg-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.msg-time {
|
||||
color: #6c7086;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.msg-dir {
|
||||
flex-shrink: 0;
|
||||
font-weight: 700;
|
||||
width: 28px;
|
||||
}
|
||||
.msg-dir.pub {
|
||||
color: #89b4fa;
|
||||
}
|
||||
.msg-topic {
|
||||
color: #f9e2af;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.msg-payload {
|
||||
margin: 6px 0 0;
|
||||
padding: 8px 10px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #cdd6f4;
|
||||
font: inherit;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.msg-meta {
|
||||
color: #6c7086;
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
344
app/src/renderer/src/views/Subscribe.vue
Normal file
344
app/src/renderer/src/views/Subscribe.vue
Normal file
@@ -0,0 +1,344 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { useMqttStore } from '../stores/mqtt.js'
|
||||
import {
|
||||
NCard,
|
||||
NSpace,
|
||||
NFormItem,
|
||||
NInput,
|
||||
NSelect,
|
||||
NButton,
|
||||
NEmpty,
|
||||
NList,
|
||||
NListItem,
|
||||
NTag,
|
||||
NEllipsis,
|
||||
useMessage
|
||||
} from 'naive-ui'
|
||||
|
||||
const store = useMqttStore()
|
||||
const msg = useMessage()
|
||||
|
||||
const topicInput = ref('')
|
||||
const qos = ref(2)
|
||||
const editingTopicId = ref(null)
|
||||
const editingTopic = ref('')
|
||||
const editingQos = ref(2)
|
||||
const qosOptions = [
|
||||
{ label: 'QoS 0 - 最多一次', value: 0 },
|
||||
{ label: 'QoS 1 - 至少一次', value: 1 },
|
||||
{ label: 'QoS 2 - 恰好一次', value: 2 }
|
||||
]
|
||||
|
||||
const formStorageKey = 'mqtt-client.subscribe-form'
|
||||
let formReady = false
|
||||
|
||||
try {
|
||||
const savedForm = JSON.parse(localStorage.getItem(formStorageKey) || '{}')
|
||||
if (typeof savedForm.topicInput === 'string') topicInput.value = savedForm.topicInput
|
||||
if ([0, 1, 2].includes(savedForm.qos)) qos.value = savedForm.qos
|
||||
} catch {
|
||||
localStorage.removeItem(formStorageKey)
|
||||
}
|
||||
|
||||
watch([topicInput, qos], () => {
|
||||
if (!formReady) return
|
||||
localStorage.setItem(
|
||||
formStorageKey,
|
||||
JSON.stringify({ topicInput: topicInput.value, qos: qos.value })
|
||||
)
|
||||
})
|
||||
|
||||
formReady = true
|
||||
|
||||
async function handleAddTopic() {
|
||||
const topic = topicInput.value.trim()
|
||||
if (!topic) {
|
||||
msg.warning('请输入主题名称')
|
||||
return
|
||||
}
|
||||
const srv = store.activeServer.value
|
||||
if (!srv) return
|
||||
if (srv.topics && srv.topics.find((t) => t.topic === topic)) {
|
||||
msg.warning('该主题已存在')
|
||||
return
|
||||
}
|
||||
const result = await store.addTopic(srv.id, topic, qos.value)
|
||||
if (result) {
|
||||
msg.success('主题已添加' + (srv.status === 'connected' ? '并已订阅' : ''))
|
||||
topicInput.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemoveTopic(topic) {
|
||||
const srv = store.activeServer.value
|
||||
if (!srv) return
|
||||
await store.removeTopic(srv.id, topic)
|
||||
msg.info('主题已移除')
|
||||
}
|
||||
|
||||
async function handleToggleSub(topic) {
|
||||
const srv = store.activeServer.value
|
||||
if (!srv) return
|
||||
if (srv.status !== 'connected') {
|
||||
msg.warning('请先连接服务器')
|
||||
return
|
||||
}
|
||||
const t = srv.topics.find((t) => t.topic === topic)
|
||||
if (!t) return
|
||||
if (t.subscribed) {
|
||||
await store.unsubscribeTopic(srv.id, topic)
|
||||
msg.info('已取消订阅: ' + topic)
|
||||
} else {
|
||||
await store.subscribeTopic(srv.id, topic)
|
||||
msg.info('已订阅: ' + topic)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubscribeAll() {
|
||||
const srv = store.activeServer.value
|
||||
if (!srv) return
|
||||
if (srv.status !== 'connected') {
|
||||
msg.warning('请先连接服务器')
|
||||
return
|
||||
}
|
||||
await store.subscribeAllTopics(srv.id)
|
||||
msg.success('已订阅全部主题')
|
||||
}
|
||||
|
||||
function startEdit(t) {
|
||||
editingTopicId.value = t.id || t.topic
|
||||
editingTopic.value = t.topic
|
||||
editingQos.value = t.qos ?? 2
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editingTopicId.value = null
|
||||
editingTopic.value = ''
|
||||
editingQos.value = 2
|
||||
}
|
||||
|
||||
async function handleEditSave(t) {
|
||||
const srv = store.activeServer.value
|
||||
if (!srv) return
|
||||
const topicName = editingTopic.value.trim()
|
||||
if (!topicName) {
|
||||
msg.warning('请输入主题名称')
|
||||
return
|
||||
}
|
||||
if (
|
||||
srv.topics.find(
|
||||
(item) => item.topic === topicName && (item.id || item.topic) !== editingTopicId.value
|
||||
)
|
||||
) {
|
||||
msg.warning('该主题已存在')
|
||||
return
|
||||
}
|
||||
const topicId = t.id || t.topic
|
||||
const result = await store.updateTopic(srv.id, topicId, topicName, editingQos.value)
|
||||
if (result) {
|
||||
msg.success('主题已更新')
|
||||
cancelEdit()
|
||||
} else {
|
||||
msg.error('主题更新失败')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="store.activeServer.value" class="subscribe-view">
|
||||
<!-- 添加主题 -->
|
||||
<n-card title="添加订阅主题" size="small">
|
||||
<div class="topic-form">
|
||||
<n-form-item class="topic-field" label="主题 (Topic)">
|
||||
<n-input
|
||||
v-model:value="topicInput"
|
||||
placeholder="例如: sensor/+/temperature"
|
||||
@keydown.enter="handleAddTopic"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item class="qos-field" label="QoS">
|
||||
<n-select v-model:value="qos" :options="qosOptions" />
|
||||
</n-form-item>
|
||||
<n-button class="topic-submit" type="primary" @click="handleAddTopic">
|
||||
+ 添加订阅
|
||||
</n-button>
|
||||
</div>
|
||||
</n-card>
|
||||
|
||||
<!-- 主题列表 -->
|
||||
<n-card
|
||||
class="topic-list-card"
|
||||
title="已订阅主题列表"
|
||||
size="small"
|
||||
:content-style="{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: 0,
|
||||
overflow: 'hidden'
|
||||
}"
|
||||
>
|
||||
<template #header-extra>
|
||||
<n-button size="small" @click="handleSubscribeAll">全部订阅</n-button>
|
||||
</template>
|
||||
<n-empty
|
||||
v-if="!store.activeServer.value.topics || store.activeServer.value.topics.length === 0"
|
||||
description="暂无订阅主题,请在上方添加"
|
||||
/>
|
||||
<div v-else class="topic-list-scroll">
|
||||
<n-list>
|
||||
<n-list-item
|
||||
v-for="t in store.activeServer.value.topics"
|
||||
:key="t.id || t.topic"
|
||||
class="topic-item"
|
||||
>
|
||||
<template v-if="editingTopicId !== (t.id || t.topic)" #prefix>
|
||||
<n-tag :type="t.subscribed ? 'success' : 'default'" size="small">
|
||||
QoS {{ t.qos }}
|
||||
</n-tag>
|
||||
</template>
|
||||
<div v-if="editingTopicId === (t.id || t.topic)" class="topic-edit-row">
|
||||
<n-input
|
||||
v-model:value="editingTopic"
|
||||
size="small"
|
||||
placeholder="主题名称"
|
||||
@keydown.enter="handleEditSave(t)"
|
||||
/>
|
||||
<n-select
|
||||
v-model:value="editingQos"
|
||||
size="small"
|
||||
:options="qosOptions"
|
||||
style="width: 160px; flex-shrink: 0"
|
||||
/>
|
||||
<n-space :size="4" :wrap="false">
|
||||
<n-button size="tiny" type="primary" @click="handleEditSave(t)">保存</n-button>
|
||||
<n-button size="tiny" @click="cancelEdit">取消</n-button>
|
||||
</n-space>
|
||||
</div>
|
||||
<div v-else class="topic-name">
|
||||
<n-ellipsis>{{ t.topic }}</n-ellipsis>
|
||||
</div>
|
||||
<template #suffix>
|
||||
<n-space
|
||||
v-if="editingTopicId !== (t.id || t.topic)"
|
||||
class="topic-actions"
|
||||
:size="4"
|
||||
:wrap="false"
|
||||
>
|
||||
<n-button size="tiny" @click="startEdit(t)">编辑</n-button>
|
||||
<n-button
|
||||
size="tiny"
|
||||
:type="t.subscribed ? 'error' : 'success'"
|
||||
@click="handleToggleSub(t.topic)"
|
||||
>
|
||||
{{ t.subscribed ? '取消订阅' : '订阅' }}
|
||||
</n-button>
|
||||
<n-button size="tiny" type="error" text @click="handleRemoveTopic(t.topic)">
|
||||
✕
|
||||
</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</n-list-item>
|
||||
</n-list>
|
||||
</div>
|
||||
</n-card>
|
||||
</div>
|
||||
<n-empty v-else description="请先在左侧选择一个 MQTT 服务器" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.subscribe-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.topic-list-card {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.topic-list-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.topic-form {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.topic-field {
|
||||
flex: 1 1 auto;
|
||||
width: auto;
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.qos-field {
|
||||
flex: 0 0 160px;
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
.topic-submit {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.topic-field :deep(.n-form-item-blank),
|
||||
.qos-field :deep(.n-form-item-blank) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.topic-item :deep(.n-list-item__main) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.topic-name {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.topic-actions {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.topic-edit-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.topic-edit-row .n-input {
|
||||
flex: 1;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.topic-field {
|
||||
width: 100%;
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.qos-field {
|
||||
flex-basis: 160px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user