feat: initial MQTT v5 debugging tool

- Tauri v2 + Vue 3 + TypeScript + Element Plus
- Connection management (CRUD + enable/disable/test)
- Preset system with {{variable}} placeholders
- Dashboard with live message scrolling + topic tree
- Payload inspector (JSON/Hex/Base64/Plain)
- Protocol-level logging (file + UI dual channel)
- Global variable manager with localStorage persistence
- MQTT v5 property support (user properties, content type, etc.)
- SubAck result tracking with subscribe_tracked API
This commit is contained in:
Joe Allen
2026-08-18 14:32:57 +08:00
commit 154fb15130
63 changed files with 11626 additions and 0 deletions
+30
View File
@@ -0,0 +1,30 @@
# Logs
logs
src-tauri/logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Rust / Tauri build artifacts
src-tauri/target
src-tauri/target_d
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+7
View File
@@ -0,0 +1,7 @@
{
"recommendations": [
"Vue.volar",
"tauri-apps.tauri-vscode",
"rust-lang.rust-analyzer"
]
}
+20
View File
@@ -0,0 +1,20 @@
# Tauri + Vue + TypeScript
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
## Recommended IDE Setup
- [VS Code](https://code.visualstudio.com/) + [Vue - Official](https://marketplace.visualstudio.com/items?itemName=Vue.volar) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)
Template created! To get started run:
`cd mqttx`
`pnpm install`
<!-- pnpm tauri android init -->
For Desktop development, run:
`pnpm tauri dev`
For Android development, run:
<!-- pnpm tauri android dev -->
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MQTTX - MQTT v5 调试工具</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+26
View File
@@ -0,0 +1,26 @@
{
"name": "mqttx",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
"tauri": "tauri"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2",
"element-plus": "^2.14.4",
"vue": "^3.5.13"
},
"devDependencies": {
"@tauri-apps/cli": "^2",
"@vitejs/plugin-vue": "^6.0.7",
"typescript": "~6.0.3",
"vite": "^8.0.16",
"vue-tsc": "^3.3.5"
}
}
+991
View File
@@ -0,0 +1,991 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
'@element-plus/icons-vue':
specifier: ^2.3.2
version: 2.3.2(vue@3.5.41(typescript@6.0.3))
'@tauri-apps/api':
specifier: ^2
version: 2.11.1
'@tauri-apps/plugin-opener':
specifier: ^2
version: 2.5.4
element-plus:
specifier: ^2.14.4
version: 2.14.4(vue@3.5.41(typescript@6.0.3))
vue:
specifier: ^3.5.13
version: 3.5.41(typescript@6.0.3)
devDependencies:
'@tauri-apps/cli':
specifier: ^2
version: 2.11.4
'@vitejs/plugin-vue':
specifier: ^6.0.7
version: 6.0.8(vite@8.2.1)(vue@3.5.41(typescript@6.0.3))
typescript:
specifier: ~6.0.3
version: 6.0.3
vite:
specifier: ^8.0.16
version: 8.2.1
vue-tsc:
specifier: ^3.3.5
version: 3.3.10(typescript@6.0.3)
packages:
'@babel/helper-string-parser@7.29.7':
resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
engines: {node: '>=6.9.0'}
'@babel/helper-validator-identifier@7.29.7':
resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
engines: {node: '>=6.9.0'}
'@babel/parser@7.29.8':
resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==}
engines: {node: '>=6.0.0'}
hasBin: true
'@babel/types@7.29.8':
resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==}
engines: {node: '>=6.9.0'}
'@ctrl/tinycolor@4.2.0':
resolution: {integrity: sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==}
engines: {node: '>=14'}
'@element-plus/icons-vue@2.3.2':
resolution: {integrity: sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==}
peerDependencies:
vue: ^3.2.0
'@floating-ui/core@1.8.0':
resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==}
'@floating-ui/dom@1.8.0':
resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==}
'@floating-ui/utils@0.2.12':
resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==}
'@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
'@oxc-project/types@0.144.0':
resolution: {integrity: sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==}
'@rolldown/binding-android-arm64@1.2.4':
resolution: {integrity: sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [android]
'@rolldown/binding-darwin-arm64@1.2.4':
resolution: {integrity: sha512-Dc5mPD8F5F/FS8i01syd7FTF6yB2fVthH/TRkjwJkzUK6EpoxHtqvZQP5Zwq80/5z19TWYHIg1KOHboCgVx/aQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [darwin]
'@rolldown/binding-darwin-x64@1.2.4':
resolution: {integrity: sha512-fpDm4oBo6SqLvWUYCmFhdde3U9KH2fRNNMeAnAPAIwxRL345xutL0EtEUcuoxsoazdJGv/MuDBQHlCDrtbvqOg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [darwin]
'@rolldown/binding-freebsd-x64@1.2.4':
resolution: {integrity: sha512-rSJoreDE/HoIzoaib6MTp5jQtCTdMHKIvItAKT/ImS6Y6Ww76oUaeMyp4Vc/fAgd/ehji068IxetHXAnqUwN9A==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [freebsd]
'@rolldown/binding-linux-arm-gnueabihf@1.2.4':
resolution: {integrity: sha512-/jm8OGHgn7oGaJu3i/qZI9spUGcJ+y/lk43ttQ/iO1tOd9NissG6o97bighBCiL+BKRngmcDuR6ikfwYdJmVuQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@rolldown/binding-linux-arm64-gnu@1.2.4':
resolution: {integrity: sha512-tIP06BeD9EqvECBrPZ+sqdPlYrT+aYaAiu1wYziVx5elRK/ftm33JxVDy2bXGbr6J0CrtirCkR87/X5a2euEng==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-arm64-musl@1.2.4':
resolution: {integrity: sha512-Ql1Q0EQqVThvn9VAVlwNzsUvbSFtCMGjLpRRi4pk5i7NZZ4n5ISiLMjHYtus4VQ2PvkSw24zyaCVsiS+sXPj1w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rolldown/binding-linux-ppc64-gnu@1.2.4':
resolution: {integrity: sha512-GjbjXD4XXfN19D0LZNbmiCBUoDiRACsYHr0yaIbbn8aFsXjHZifcYqu/W5Er5X2X990WjHXFrxarn5chzItorQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-s390x-gnu@1.2.4':
resolution: {integrity: sha512-p5WR0NOwaRmJ/B1b6IjEFLLivwEsf3PrdBIhRbhTCQisbo2SvHHpG4ELB/+FgQNnB88LTOF86upmJmbvZdQ2lw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-gnu@1.2.4':
resolution: {integrity: sha512-4/GyVjmhR+Tc6HLJvwc1sOhPqAZtySiSMesOZyX6JQ5XBxoTDEMKQzvo07NIK6nTon/SivlZqvhzvuVBNQhObQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-musl@1.2.4':
resolution: {integrity: sha512-l9eeLsCNvPpmSXUej0etw/J1eqV0Jj1D5G/xG6YTijmE6dkv6E2QezgWbTfQk63v952DPqrjOCoiqxq7Bw0YUQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@rolldown/binding-openharmony-arm64@1.2.4':
resolution: {integrity: sha512-e0F355MSTMm3+UOqtV3L24gFUp2N5m1f8L/7d56deik6va+AXdrt9F8LbzGpeWGWRbZEDq4m8NVnJDeBtf9DZg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [openharmony]
'@rolldown/binding-win32-arm64-msvc@1.2.4':
resolution: {integrity: sha512-AWLi0uBRYh6QlE7OKhiz+phZC0qwtij2QZmhmOdsLdFn64m7oMpooE9ICE3lhm9xMb4SpDo2WbHcxX1iFLFtqw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [win32]
'@rolldown/binding-win32-x64-msvc@1.2.4':
resolution: {integrity: sha512-UwSDJOg3dqCAejWdxclJjCsh3Qq4vLYMDxmyHqo1btz3stK2VqgwNd3mm5tuIwzSlGIQ/1H9Hr+Zn09mrezNqQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [win32]
'@rolldown/pluginutils@1.0.1':
resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
'@sxzz/popperjs-es@2.11.8':
resolution: {integrity: sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==}
'@tauri-apps/api@2.11.1':
resolution: {integrity: sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==}
'@tauri-apps/cli-darwin-arm64@2.11.4':
resolution: {integrity: sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@tauri-apps/cli-darwin-x64@2.11.4':
resolution: {integrity: sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@tauri-apps/cli-linux-arm-gnueabihf@2.11.4':
resolution: {integrity: sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==}
engines: {node: '>= 10'}
cpu: [arm]
os: [linux]
'@tauri-apps/cli-linux-arm64-gnu@2.11.4':
resolution: {integrity: sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-arm64-musl@2.11.4':
resolution: {integrity: sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tauri-apps/cli-linux-riscv64-gnu@2.11.4':
resolution: {integrity: sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==}
engines: {node: '>= 10'}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-x64-gnu@2.11.4':
resolution: {integrity: sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-x64-musl@2.11.4':
resolution: {integrity: sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tauri-apps/cli-win32-arm64-msvc@2.11.4':
resolution: {integrity: sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@tauri-apps/cli-win32-ia32-msvc@2.11.4':
resolution: {integrity: sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==}
engines: {node: '>= 10'}
cpu: [ia32]
os: [win32]
'@tauri-apps/cli-win32-x64-msvc@2.11.4':
resolution: {integrity: sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@tauri-apps/cli@2.11.4':
resolution: {integrity: sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==}
engines: {node: '>= 10'}
hasBin: true
'@tauri-apps/plugin-opener@2.5.4':
resolution: {integrity: sha512-1HnPkb+AmgO29HBazm4uPLKB+r7zzcTBW1d0fyYp1uP+jwtpoiNDGKMMzz58SFp49nOIrxdE3aUJtT57lfO9CQ==}
'@types/lodash-es@4.17.12':
resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==}
'@types/lodash@4.17.25':
resolution: {integrity: sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==}
'@types/web-bluetooth@0.0.21':
resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
'@vitejs/plugin-vue@6.0.8':
resolution: {integrity: sha512-0ZjgOg7oO6farnNGup7yvoM/YXZV84OZxHAwtflItNa/6zzQyVb5LNxyea3FEKEX2XlagIKzrlH7wwxkKgtiew==}
engines: {node: ^20.19.0 || >=22.12.0}
peerDependencies:
vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
vue: ^3.2.25
'@volar/language-core@2.4.28':
resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==}
'@volar/source-map@2.4.28':
resolution: {integrity: sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==}
'@volar/typescript@2.4.28':
resolution: {integrity: sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==}
'@vue/compiler-core@3.5.41':
resolution: {integrity: sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg==}
'@vue/compiler-dom@3.5.41':
resolution: {integrity: sha512-oKacVfNglLvGjnS6BXOlGL7EyG2h8X03pqXCjzotRZUaXGjbrTJUnVAQjrCqUnS+lyu31nwQjZY/d817GmCnfw==}
'@vue/compiler-sfc@3.5.41':
resolution: {integrity: sha512-XJhip7R2wy6vX3knCxdZN4KracFaZUef58s1KYewqluedHIJaPIVfXoYT7MF1F8nCvv6k8bWWxDC8opMkg1VTQ==}
'@vue/compiler-ssr@3.5.41':
resolution: {integrity: sha512-U3v5OejKEGqOI0Wy0+Sz7hGuIFZHA4LSXzrNM3IMIeDyJEBBfTpX26n3SDgToRpP2bLc9FfI2j/kSgcJ8Emq5A==}
'@vue/language-core@3.3.10':
resolution: {integrity: sha512-CR7ByBbgPHqhxrioKPOcZBqttaozzLNwtkCzXQ+uF8gLPHnUe03srPnGpdtHD3zp+bq5iyVkZ1WNx7W564RPwg==}
'@vue/reactivity@3.5.41':
resolution: {integrity: sha512-rznsqKM0np0x18EjzF8x88MpEhdNsffbvFbckLL5+oUKz1BxAImEmO7J1ArRYSyo6aQaVoBDp7jEkT91OOxydA==}
'@vue/runtime-core@3.5.41':
resolution: {integrity: sha512-Vcry58hiAKwGen9Z1jUZE0feFsNArPCMOImYI8el48A9Idf6DuQYD0U05zZIF2Iad1hGhPSvcbBbAOhNr55fhg==}
'@vue/runtime-dom@3.5.41':
resolution: {integrity: sha512-3vVBahVBS9+U6cmXBLyb8nE6/yYo4J/CGI9eVFs3KiMc0YHuudwKyShTD65jtJy/L9PUUxNAFu4cj4LiJ0UFbw==}
'@vue/server-renderer@3.5.41':
resolution: {integrity: sha512-n6hx/pNFfbD6SuyeuMVkvqox8bwf/ET9JlA/kAz/imw8sw++wkqKe2mHX5KutjPpbKE4Z56yTHszoOjGMI9igQ==}
'@vue/shared@3.5.41':
resolution: {integrity: sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA==}
'@vueuse/core@14.3.0':
resolution: {integrity: sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==}
peerDependencies:
vue: ^3.5.0
'@vueuse/metadata@14.3.0':
resolution: {integrity: sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==}
'@vueuse/shared@14.3.0':
resolution: {integrity: sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==}
peerDependencies:
vue: ^3.5.0
alien-signals@3.2.1:
resolution: {integrity: sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==}
async-validator@4.2.5:
resolution: {integrity: sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==}
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
dayjs@1.11.22:
resolution: {integrity: sha512-1YRnxzt/AabP3GHxnaB9/b+ZScCKu5TeF+co+BWG+lnWVIwEcTFc1FVE0WLNmNO3sA6GGXL40i5qkHfbLzpwrg==}
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
element-plus@2.14.4:
resolution: {integrity: sha512-vMKR9tFcLeNrJgFXA3zhUn6YuRKUQW9d0btakBR8U1Iq8MfzkjMOGcgs5de2VgiLldHt69brmuBHpxc3bK4ZgQ==}
peerDependencies:
vue: ^3.3.7
entities@7.0.1:
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
engines: {node: '>=0.12'}
estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
peerDependencies:
picomatch: ^3 || ^4
peerDependenciesMeta:
picomatch:
optional: true
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
lightningcss-android-arm64@1.33.0:
resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [android]
lightningcss-darwin-arm64@1.33.0:
resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [darwin]
lightningcss-darwin-x64@1.33.0:
resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [darwin]
lightningcss-freebsd-x64@1.33.0:
resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [freebsd]
lightningcss-linux-arm-gnueabihf@1.33.0:
resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm]
os: [linux]
lightningcss-linux-arm64-gnu@1.33.0:
resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.33.0:
resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.33.0:
resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.33.0:
resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.33.0:
resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [win32]
lightningcss-win32-x64-msvc@1.33.0:
resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [win32]
lightningcss@1.33.0:
resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==}
engines: {node: '>= 12.0.0'}
lodash-es@4.18.1:
resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==}
lodash-unified@1.0.3:
resolution: {integrity: sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==}
peerDependencies:
'@types/lodash-es': '*'
lodash: '*'
lodash-es: '*'
lodash@4.18.1:
resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
memoize-one@6.0.0:
resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==}
muggle-string@0.4.1:
resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==}
nanoid@3.3.18:
resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
normalize-wheel-es@1.2.0:
resolution: {integrity: sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==}
path-browserify@1.0.1:
resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
picomatch@4.0.5:
resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==}
engines: {node: '>=12'}
postcss@8.5.26:
resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==}
engines: {node: ^10 || ^12 || >=14}
rolldown@1.2.4:
resolution: {integrity: sha512-rSr7irW0K7QRWzjdJXqZowkcRdDtjRduh43rBltnVKd0VFq839l1lJoDvGJb6gl7+4rTTCrPWu+YfujUL8Ug7w==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
tinyglobby@0.2.17:
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
engines: {node: '>=12.0.0'}
typescript@6.0.3:
resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==}
engines: {node: '>=14.17'}
hasBin: true
vite@8.2.1:
resolution: {integrity: sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
'@types/node': ^20.19.0 || >=22.12.0
'@vitejs/devtools': ^0.4.0
esbuild: ^0.27.0 || ^0.28.0
jiti: '>=1.21.0'
less: ^4.0.0
sass: ^1.70.0
sass-embedded: ^1.70.0
stylus: '>=0.54.8'
sugarss: ^5.0.0
terser: ^5.16.0
tsx: ^4.8.1
yaml: ^2.4.2
peerDependenciesMeta:
'@types/node':
optional: true
'@vitejs/devtools':
optional: true
esbuild:
optional: true
jiti:
optional: true
less:
optional: true
sass:
optional: true
sass-embedded:
optional: true
stylus:
optional: true
sugarss:
optional: true
terser:
optional: true
tsx:
optional: true
yaml:
optional: true
vscode-uri@3.1.0:
resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
vue-component-type-helpers@3.3.10:
resolution: {integrity: sha512-t7IQivQ3oD4D01b7s7a9AWzHAcr3DGBIa/1jZREsFQJcFgSL92gqUkqNiHTYgzTt7QDuJa0I9wCeDwEtZICoBQ==}
vue-tsc@3.3.10:
resolution: {integrity: sha512-YaDVxcW+CGtaOt3pZahMG5jYPx0hsUTxyEoPOTSMebcGUXP9lIBabQ14vfKMORb2CqqK5CxsNo/d1d+4IQwiKg==}
hasBin: true
peerDependencies:
typescript: '>=5.0.0'
vue@3.5.41:
resolution: {integrity: sha512-2laE0p+aK+/AOPG/XL/WepOs/GlK755LJ1XECi9kDUrz1FKNw8rb2Xzlw9JS1rqEV55nb0ttsKxVlTCcd+R5cg==}
peerDependencies:
typescript: '*'
peerDependenciesMeta:
typescript:
optional: true
snapshots:
'@babel/helper-string-parser@7.29.7': {}
'@babel/helper-validator-identifier@7.29.7': {}
'@babel/parser@7.29.8':
dependencies:
'@babel/types': 7.29.8
'@babel/types@7.29.8':
dependencies:
'@babel/helper-string-parser': 7.29.7
'@babel/helper-validator-identifier': 7.29.7
'@ctrl/tinycolor@4.2.0': {}
'@element-plus/icons-vue@2.3.2(vue@3.5.41(typescript@6.0.3))':
dependencies:
vue: 3.5.41(typescript@6.0.3)
'@floating-ui/core@1.8.0':
dependencies:
'@floating-ui/utils': 0.2.12
'@floating-ui/dom@1.8.0':
dependencies:
'@floating-ui/core': 1.8.0
'@floating-ui/utils': 0.2.12
'@floating-ui/utils@0.2.12': {}
'@jridgewell/sourcemap-codec@1.5.5': {}
'@oxc-project/types@0.144.0': {}
'@rolldown/binding-android-arm64@1.2.4':
optional: true
'@rolldown/binding-darwin-arm64@1.2.4':
optional: true
'@rolldown/binding-darwin-x64@1.2.4':
optional: true
'@rolldown/binding-freebsd-x64@1.2.4':
optional: true
'@rolldown/binding-linux-arm-gnueabihf@1.2.4':
optional: true
'@rolldown/binding-linux-arm64-gnu@1.2.4':
optional: true
'@rolldown/binding-linux-arm64-musl@1.2.4':
optional: true
'@rolldown/binding-linux-ppc64-gnu@1.2.4':
optional: true
'@rolldown/binding-linux-s390x-gnu@1.2.4':
optional: true
'@rolldown/binding-linux-x64-gnu@1.2.4':
optional: true
'@rolldown/binding-linux-x64-musl@1.2.4':
optional: true
'@rolldown/binding-openharmony-arm64@1.2.4':
optional: true
'@rolldown/binding-win32-arm64-msvc@1.2.4':
optional: true
'@rolldown/binding-win32-x64-msvc@1.2.4':
optional: true
'@rolldown/pluginutils@1.0.1': {}
'@sxzz/popperjs-es@2.11.8': {}
'@tauri-apps/api@2.11.1': {}
'@tauri-apps/cli-darwin-arm64@2.11.4':
optional: true
'@tauri-apps/cli-darwin-x64@2.11.4':
optional: true
'@tauri-apps/cli-linux-arm-gnueabihf@2.11.4':
optional: true
'@tauri-apps/cli-linux-arm64-gnu@2.11.4':
optional: true
'@tauri-apps/cli-linux-arm64-musl@2.11.4':
optional: true
'@tauri-apps/cli-linux-riscv64-gnu@2.11.4':
optional: true
'@tauri-apps/cli-linux-x64-gnu@2.11.4':
optional: true
'@tauri-apps/cli-linux-x64-musl@2.11.4':
optional: true
'@tauri-apps/cli-win32-arm64-msvc@2.11.4':
optional: true
'@tauri-apps/cli-win32-ia32-msvc@2.11.4':
optional: true
'@tauri-apps/cli-win32-x64-msvc@2.11.4':
optional: true
'@tauri-apps/cli@2.11.4':
optionalDependencies:
'@tauri-apps/cli-darwin-arm64': 2.11.4
'@tauri-apps/cli-darwin-x64': 2.11.4
'@tauri-apps/cli-linux-arm-gnueabihf': 2.11.4
'@tauri-apps/cli-linux-arm64-gnu': 2.11.4
'@tauri-apps/cli-linux-arm64-musl': 2.11.4
'@tauri-apps/cli-linux-riscv64-gnu': 2.11.4
'@tauri-apps/cli-linux-x64-gnu': 2.11.4
'@tauri-apps/cli-linux-x64-musl': 2.11.4
'@tauri-apps/cli-win32-arm64-msvc': 2.11.4
'@tauri-apps/cli-win32-ia32-msvc': 2.11.4
'@tauri-apps/cli-win32-x64-msvc': 2.11.4
'@tauri-apps/plugin-opener@2.5.4':
dependencies:
'@tauri-apps/api': 2.11.1
'@types/lodash-es@4.17.12':
dependencies:
'@types/lodash': 4.17.25
'@types/lodash@4.17.25': {}
'@types/web-bluetooth@0.0.21': {}
'@vitejs/plugin-vue@6.0.8(vite@8.2.1)(vue@3.5.41(typescript@6.0.3))':
dependencies:
'@rolldown/pluginutils': 1.0.1
vite: 8.2.1
vue: 3.5.41(typescript@6.0.3)
'@volar/language-core@2.4.28':
dependencies:
'@volar/source-map': 2.4.28
'@volar/source-map@2.4.28': {}
'@volar/typescript@2.4.28':
dependencies:
'@volar/language-core': 2.4.28
path-browserify: 1.0.1
vscode-uri: 3.1.0
'@vue/compiler-core@3.5.41':
dependencies:
'@babel/parser': 7.29.8
'@vue/shared': 3.5.41
entities: 7.0.1
estree-walker: 2.0.2
source-map-js: 1.2.1
'@vue/compiler-dom@3.5.41':
dependencies:
'@vue/compiler-core': 3.5.41
'@vue/shared': 3.5.41
'@vue/compiler-sfc@3.5.41':
dependencies:
'@babel/parser': 7.29.8
'@vue/compiler-core': 3.5.41
'@vue/compiler-dom': 3.5.41
'@vue/compiler-ssr': 3.5.41
'@vue/shared': 3.5.41
estree-walker: 2.0.2
magic-string: 0.30.21
postcss: 8.5.26
source-map-js: 1.2.1
'@vue/compiler-ssr@3.5.41':
dependencies:
'@vue/compiler-dom': 3.5.41
'@vue/shared': 3.5.41
'@vue/language-core@3.3.10':
dependencies:
'@volar/language-core': 2.4.28
'@vue/compiler-dom': 3.5.41
'@vue/shared': 3.5.41
alien-signals: 3.2.1
muggle-string: 0.4.1
path-browserify: 1.0.1
picomatch: 4.0.5
'@vue/reactivity@3.5.41':
dependencies:
'@vue/shared': 3.5.41
'@vue/runtime-core@3.5.41':
dependencies:
'@vue/reactivity': 3.5.41
'@vue/shared': 3.5.41
'@vue/runtime-dom@3.5.41':
dependencies:
'@vue/reactivity': 3.5.41
'@vue/runtime-core': 3.5.41
'@vue/shared': 3.5.41
csstype: 3.2.3
'@vue/server-renderer@3.5.41':
dependencies:
'@vue/compiler-ssr': 3.5.41
'@vue/runtime-dom': 3.5.41
'@vue/shared': 3.5.41
'@vue/shared@3.5.41': {}
'@vueuse/core@14.3.0(vue@3.5.41(typescript@6.0.3))':
dependencies:
'@types/web-bluetooth': 0.0.21
'@vueuse/metadata': 14.3.0
'@vueuse/shared': 14.3.0(vue@3.5.41(typescript@6.0.3))
vue: 3.5.41(typescript@6.0.3)
'@vueuse/metadata@14.3.0': {}
'@vueuse/shared@14.3.0(vue@3.5.41(typescript@6.0.3))':
dependencies:
vue: 3.5.41(typescript@6.0.3)
alien-signals@3.2.1: {}
async-validator@4.2.5: {}
csstype@3.2.3: {}
dayjs@1.11.22: {}
detect-libc@2.1.2: {}
element-plus@2.14.4(vue@3.5.41(typescript@6.0.3)):
dependencies:
'@ctrl/tinycolor': 4.2.0
'@element-plus/icons-vue': 2.3.2(vue@3.5.41(typescript@6.0.3))
'@floating-ui/dom': 1.8.0
'@popperjs/core': '@sxzz/popperjs-es@2.11.8'
'@types/lodash': 4.17.25
'@types/lodash-es': 4.17.12
'@vueuse/core': 14.3.0(vue@3.5.41(typescript@6.0.3))
async-validator: 4.2.5
dayjs: 1.11.22
lodash: 4.18.1
lodash-es: 4.18.1
lodash-unified: 1.0.3(@types/lodash-es@4.17.12)(lodash-es@4.18.1)(lodash@4.18.1)
memoize-one: 6.0.0
normalize-wheel-es: 1.2.0
vue: 3.5.41(typescript@6.0.3)
vue-component-type-helpers: 3.3.10
entities@7.0.1: {}
estree-walker@2.0.2: {}
fdir@6.5.0(picomatch@4.0.5):
optionalDependencies:
picomatch: 4.0.5
fsevents@2.3.3:
optional: true
lightningcss-android-arm64@1.33.0:
optional: true
lightningcss-darwin-arm64@1.33.0:
optional: true
lightningcss-darwin-x64@1.33.0:
optional: true
lightningcss-freebsd-x64@1.33.0:
optional: true
lightningcss-linux-arm-gnueabihf@1.33.0:
optional: true
lightningcss-linux-arm64-gnu@1.33.0:
optional: true
lightningcss-linux-arm64-musl@1.33.0:
optional: true
lightningcss-linux-x64-gnu@1.33.0:
optional: true
lightningcss-linux-x64-musl@1.33.0:
optional: true
lightningcss-win32-arm64-msvc@1.33.0:
optional: true
lightningcss-win32-x64-msvc@1.33.0:
optional: true
lightningcss@1.33.0:
dependencies:
detect-libc: 2.1.2
optionalDependencies:
lightningcss-android-arm64: 1.33.0
lightningcss-darwin-arm64: 1.33.0
lightningcss-darwin-x64: 1.33.0
lightningcss-freebsd-x64: 1.33.0
lightningcss-linux-arm-gnueabihf: 1.33.0
lightningcss-linux-arm64-gnu: 1.33.0
lightningcss-linux-arm64-musl: 1.33.0
lightningcss-linux-x64-gnu: 1.33.0
lightningcss-linux-x64-musl: 1.33.0
lightningcss-win32-arm64-msvc: 1.33.0
lightningcss-win32-x64-msvc: 1.33.0
lodash-es@4.18.1: {}
lodash-unified@1.0.3(@types/lodash-es@4.17.12)(lodash-es@4.18.1)(lodash@4.18.1):
dependencies:
'@types/lodash-es': 4.17.12
lodash: 4.18.1
lodash-es: 4.18.1
lodash@4.18.1: {}
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
memoize-one@6.0.0: {}
muggle-string@0.4.1: {}
nanoid@3.3.18: {}
normalize-wheel-es@1.2.0: {}
path-browserify@1.0.1: {}
picocolors@1.1.1: {}
picomatch@4.0.5: {}
postcss@8.5.26:
dependencies:
nanoid: 3.3.18
picocolors: 1.1.1
source-map-js: 1.2.1
rolldown@1.2.4:
dependencies:
'@oxc-project/types': 0.144.0
'@rolldown/pluginutils': 1.0.1
optionalDependencies:
'@rolldown/binding-android-arm64': 1.2.4
'@rolldown/binding-darwin-arm64': 1.2.4
'@rolldown/binding-darwin-x64': 1.2.4
'@rolldown/binding-freebsd-x64': 1.2.4
'@rolldown/binding-linux-arm-gnueabihf': 1.2.4
'@rolldown/binding-linux-arm64-gnu': 1.2.4
'@rolldown/binding-linux-arm64-musl': 1.2.4
'@rolldown/binding-linux-ppc64-gnu': 1.2.4
'@rolldown/binding-linux-s390x-gnu': 1.2.4
'@rolldown/binding-linux-x64-gnu': 1.2.4
'@rolldown/binding-linux-x64-musl': 1.2.4
'@rolldown/binding-openharmony-arm64': 1.2.4
'@rolldown/binding-win32-arm64-msvc': 1.2.4
'@rolldown/binding-win32-x64-msvc': 1.2.4
source-map-js@1.2.1: {}
tinyglobby@0.2.17:
dependencies:
fdir: 6.5.0(picomatch@4.0.5)
picomatch: 4.0.5
typescript@6.0.3: {}
vite@8.2.1:
dependencies:
lightningcss: 1.33.0
picomatch: 4.0.5
postcss: 8.5.26
rolldown: 1.2.4
tinyglobby: 0.2.17
optionalDependencies:
fsevents: 2.3.3
vscode-uri@3.1.0: {}
vue-component-type-helpers@3.3.10: {}
vue-tsc@3.3.10(typescript@6.0.3):
dependencies:
'@volar/typescript': 2.4.28
'@vue/language-core': 3.3.10
typescript: 6.0.3
vue@3.5.41(typescript@6.0.3):
dependencies:
'@vue/compiler-dom': 3.5.41
'@vue/compiler-sfc': 3.5.41
'@vue/runtime-dom': 3.5.41
'@vue/server-renderer': 3.5.41
'@vue/shared': 3.5.41
optionalDependencies:
typescript: 6.0.3
+6
View File
@@ -0,0 +1,6 @@
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+7
View File
@@ -0,0 +1,7 @@
# Generated by Cargo
# will have compiled files and executables
/target/
# Generated by Tauri
# will have schema files for capabilities auto-completion
/gen/schemas
+5399
View File
File diff suppressed because it is too large Load Diff
+39
View File
@@ -0,0 +1,39 @@
[package]
name = "mqttx"
version = "0.1.0"
description = "A Tauri App"
authors = ["you"]
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[package.metadata]
# Tauri configuration metadata
[lib]
# The `_lib` suffix may seem redundant but it is necessary
# to make the lib name unique and wouldn't conflict with the bin name.
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
name = "mqttx_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rumqttc-v5-next = "0.34"
tokio = { version = "1", features = ["sync"] }
uuid = { version = "1", features = ["v4"] }
# Read the optimization guideline for more details: https://tauri.app/concept/size/#cargo-configuration
[profile.release]
codegen-units = 1
lto = true
opt-level = 3
panic = "abort"
strip = true
+14
View File
@@ -0,0 +1,14 @@
fn main() {
// Workaround for tauri-build issue with cargo's OUT_DIR format change.
// The newer cargo version uses `{package}/{hash}/out` instead of `{package}-{hash}/out`,
// which breaks the target_dir calculation in tauri-build 2.6.3.
// We create the expected `build/build` directory to prevent the fs::read_dir error.
let out_dir = std::env::var("OUT_DIR").unwrap();
let out_path = std::path::Path::new(&out_dir);
if let Some(parent) = out_path.parent().and_then(|p| p.parent()).and_then(|p| p.parent()) {
let build_build = parent.join("build");
let _ = std::fs::create_dir_all(&build_build);
}
tauri_build::build()
}
+10
View File
@@ -0,0 +1,10 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"opener:default"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 567 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 536 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 777 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1020 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 848 B

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

+964
View File
@@ -0,0 +1,964 @@
use rumqttc::mqttbytes::v5::{
ConnectReturnCode, Packet, Publish, PublishProperties,
};
use rumqttc::{
AsyncClient, Broker, ConnectionError, Event, EventLoop, MqttOptions, Outgoing,
PublishOptions,
};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use tauri::{AppHandle, Emitter, Manager};
use tokio::sync::{RwLock, watch};
// ─── DTO structs ───────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MqttConnectionDto {
pub id: String,
pub name: String,
pub host: String,
pub port: u16,
pub username: String,
pub password: String,
pub client_id: String,
pub clean_start: bool,
pub session_expiry_interval: u32,
pub keep_alive: u64,
pub ssl: bool,
pub receive_maximum: Option<u16>,
pub maximum_packet_size: Option<u32>,
pub topic_alias_maximum: Option<u16>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PublishDto {
pub connection_id: String,
pub topic: String,
pub payload: String,
pub qos: u8,
pub retain: bool,
pub user_properties: Vec<(String, String)>,
pub content_type: Option<String>,
pub message_expiry_interval: Option<u32>,
pub response_topic: Option<String>,
pub correlation_data: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SubscribeDto {
pub connection_id: String,
pub topic: String,
pub qos: u8,
}
#[derive(Debug, Clone, Serialize)]
pub struct MqttMessageEvent {
pub connection_id: String,
pub topic: String,
pub payload: String,
pub qos: u8,
pub retain: bool,
pub timestamp: i64,
pub reason_code: Option<u8>,
pub user_properties: Vec<(String, String)>,
pub content_type: Option<String>,
pub content_encoding: Option<String>,
pub response_topic: Option<String>,
pub correlation_data: Option<String>,
pub message_expiry_interval: Option<u32>,
pub subscription_identifier: Option<u32>,
}
#[derive(Debug, Clone, Serialize)]
pub struct MqttStatusEvent {
pub connection_id: String,
pub status: String,
pub error: Option<String>,
pub session_present: Option<bool>,
pub reason_code: Option<u8>,
}
#[derive(Debug, Clone, Serialize)]
pub struct MqttLogEvent {
pub timestamp: i64,
pub connection_id: String,
pub level: String,
pub event: String,
pub message: String,
pub details: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ConnectionStatus {
pub id: String,
pub connected: bool,
pub error: Option<String>,
}
// ─── Log file writer ────────────────────────────────────────────────────────────
struct LogWriter {
dir: PathBuf,
}
impl LogWriter {
fn new(dir: PathBuf) -> Self {
let _ = std::fs::create_dir_all(&dir);
Self { dir }
}
fn date_from_ts(ts: i64) -> (i32, u32, u32, u32, u32, u32, u32) {
// Unix time to UTC date
let mut s = ts / 1000;
let ms = (ts % 1000).unsigned_abs() as u32;
// Days since epoch
let mut d = (s / 86400) as i32;
s %= 86400;
let h = (s / 3600) as u32;
s %= 3600;
let m = (s / 60) as u32;
let sec = (s % 60) as u32;
// Date from days since 1970-01-01
let mut y = 1970i32;
loop {
let days_in_year = if Self::is_leap(y) { 366 } else { 365 };
if d < days_in_year { break; }
d -= days_in_year;
y += 1;
}
let (mo, day) = Self::month_day(y, d as i32);
(y, mo, day, h, m, sec, ms as u32)
}
fn is_leap(y: i32) -> bool {
(y % 4 == 0 && y % 100 != 0) || y % 400 == 0
}
fn month_day(y: i32, d: i32) -> (u32, u32) {
let days = [31, if Self::is_leap(y) { 29 } else { 28 }, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
let mut remaining = d;
for (i, &dim) in days.iter().enumerate() {
if remaining < dim {
return ((i + 1) as u32, (remaining + 1) as u32);
}
remaining -= dim;
}
(12, 31)
}
fn write(&self, timestamp: i64, level: &str, event: &str, connection_id: &str, message: &str, details: Option<&str>) {
let (y, mo, d, h, mi, s, ms) = Self::date_from_ts(timestamp);
let time_str = format!("{:02}:{:02}:{:02}.{:03}", h, mi, s, ms);
let file_date = format!("{:04}-{:02}-{:02}", y, mo, d);
let log_file = self.dir.join(format!("mqttx-{}.log", file_date));
let line = format!(
"[{}] [{}] [{}] [{}] {}{}\n",
time_str,
level,
event,
connection_id,
message,
details.map(|d| format!(" | {}", d)).unwrap_or_default()
);
let _ = std::fs::OpenOptions::new()
.create(true)
.append(true)
.write(true)
.open(&log_file)
.and_then(|f| {
use std::io::Write;
let mut f = f;
f.write_all(line.as_bytes())
});
}
}
// ─── State management ──────────────────────────────────────────────────────────
struct ClientHandle {
client: AsyncClient,
cancel_tx: watch::Sender<bool>,
connected: Arc<AtomicBool>,
}
struct AppState {
clients: RwLock<HashMap<String, ClientHandle>>,
log_writer: Option<LogWriter>,
}
fn qos_to_u8(qos: rumqttc::QoS) -> u8 {
match qos {
rumqttc::QoS::AtMostOnce => 0,
rumqttc::QoS::AtLeastOnce => 1,
rumqttc::QoS::ExactlyOnce => 2,
}
}
fn qos_from_u8(qos: u8) -> rumqttc::QoS {
match qos {
0 => rumqttc::QoS::AtMostOnce,
1 => rumqttc::QoS::AtLeastOnce,
2 => rumqttc::QoS::ExactlyOnce,
_ => rumqttc::QoS::AtMostOnce,
}
}
fn now_timestamp() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as i64
}
fn emit_log(app_handle: &AppHandle, connection_id: &str, level: &str, event: &str, message: String, details: Option<String>) {
let ts = now_timestamp();
let log = MqttLogEvent {
timestamp: ts,
connection_id: connection_id.to_string(),
level: level.to_string(),
event: event.to_string(),
message: message.clone(),
details: details.clone(),
};
let _ = app_handle.emit("mqtt:log", &log);
// Also write to log file
if let Some(state) = app_handle.try_state::<AppState>() {
if let Some(writer) = &state.log_writer {
writer.write(ts, level, event, connection_id, &message, details.as_deref());
}
}
}
fn extract_publish_info(publish: &Publish) -> MqttMessageEvent {
let payload_str = String::from_utf8_lossy(&publish.payload).to_string();
let props = publish.properties.as_ref();
MqttMessageEvent {
connection_id: String::new(), // will be filled by caller
topic: String::from_utf8_lossy(&publish.topic).to_string(),
payload: payload_str,
qos: qos_to_u8(publish.qos),
retain: publish.retain,
timestamp: now_timestamp(),
reason_code: None,
user_properties: props
.map(|p| {
p.user_properties
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect()
})
.unwrap_or_default(),
content_type: props.and_then(|p| p.content_type.clone()),
content_encoding: None,
response_topic: props.and_then(|p| p.response_topic.clone()),
correlation_data: props
.and_then(|p| p.correlation_data.as_ref().map(|d| {
d.iter().map(|b| format!("{:02x}", b)).collect::<String>()
})),
message_expiry_interval: props.and_then(|p| p.message_expiry_interval),
subscription_identifier: props
.and_then(|p| p.subscription_identifiers.first().copied().map(|v| v as u32)),
}
}
fn describe_publish_properties(props: &Option<PublishProperties>) -> String {
props.as_ref().map(|p| {
let mut parts = Vec::new();
if let Some(ct) = &p.content_type {
parts.push(format!("content_type: {ct}"));
}
if let Some(rt) = &p.response_topic {
parts.push(format!("response_topic: {rt}"));
}
if let Some(cd) = &p.correlation_data {
parts.push(format!("correlation_data: {} bytes", cd.len()));
}
if let Some(mei) = p.message_expiry_interval {
parts.push(format!("expiry: {mei}s"));
}
if let Some(ta) = p.topic_alias {
parts.push(format!("topic_alias: {ta}"));
}
if !p.subscription_identifiers.is_empty() {
parts.push(format!("sub_ids: {:?}", p.subscription_identifiers));
}
if let Some(ps) = p.payload_format_indicator {
parts.push(format!("payload_format: {ps}"));
}
if !p.user_properties.is_empty() {
let ups: Vec<String> = p.user_properties.iter()
.take(5)
.map(|(k, v)| format!("{k}={v}"))
.collect();
parts.push(format!("user_properties: [{}]", ups.join(", ")));
}
parts.join(", ")
}).unwrap_or_default()
}
// ─── Background event loop task ────────────────────────────────────────────────
fn spawn_event_loop(
connection_id: String,
mut eventloop: EventLoop,
mut cancel_rx: watch::Receiver<bool>,
connected: Arc<AtomicBool>,
app_handle: AppHandle,
) {
let conn_id = connection_id.clone();
let ah = app_handle.clone();
emit_log(&ah, &conn_id, "info", "event_loop_started", "MQTT 事件循环已启动".to_string(), None);
tokio::spawn(async move {
loop {
tokio::select! {
event = eventloop.poll() => {
match event {
Ok(Event::Incoming(Packet::Publish(publish))) => {
let topic = String::from_utf8_lossy(&publish.topic).to_string();
let payload_len = publish.payload.len();
let payload_preview = String::from_utf8_lossy(&publish.payload).to_string().chars().take(200).collect::<String>();
let props_desc = describe_publish_properties(&publish.properties);
emit_log(&app_handle, &connection_id, "info", "publish_received",
format!("收到消息: {topic} ({} 字节, QoS {})", payload_len, qos_to_u8(publish.qos)),
Some(format!("payload: {}{}\n{}",
payload_preview,
if payload_len > 200 { format!("... (共 {} 字节)", payload_len) } else { String::new() },
props_desc)),
);
let mut msg = extract_publish_info(&publish);
msg.connection_id = connection_id.clone();
let _ = app_handle.emit("mqtt:message", &msg);
}
Ok(Event::Incoming(Packet::ConnAck(connack))) => {
connected.store(true, Ordering::SeqCst);
let reason_code = connack.code as u8;
let is_success = connack.code == ConnectReturnCode::Success;
if is_success {
let props_desc = connack.properties.as_ref().map(|p| {
format!("assigned_client_id: {:?}, topic_alias_max: {:?}, server_keepalive: {:?}, receive_max: {:?}",
p.assigned_client_identifier,
p.topic_alias_max,
p.server_keep_alive,
p.receive_max)
}).unwrap_or_default();
emit_log(&app_handle, &connection_id, "info", "connack",
format!("连接成功 (session_present: {})", connack.session_present),
Some(props_desc).filter(|d| !d.is_empty()),
);
} else {
emit_log(&app_handle, &connection_id, "error", "connack",
format!("连接被拒绝: {:?} (code: {})", connack.code, reason_code), None);
}
let status_event = MqttStatusEvent {
connection_id: connection_id.clone(),
status: "connected".to_string(),
error: None,
session_present: Some(connack.session_present),
reason_code: Some(reason_code),
};
let _ = app_handle.emit("mqtt:status", &status_event);
}
Ok(Event::Incoming(Packet::Disconnect(disconnect))) => {
connected.store(false, Ordering::SeqCst);
let reason_code = disconnect.reason_code as u8;
let reason_desc = format!("{:?}", disconnect.reason_code);
let props_info = disconnect.properties.as_ref().map(|p| {
let mut parts = Vec::new();
if let Some(ref s) = p.session_expiry_interval { parts.push(format!("session_expiry: {s}")); }
if let Some(ref s) = p.reason_string { parts.push(format!("reason: {s}")); }
if let Some(ref s) = p.server_reference { parts.push(format!("server_ref: {s}")); }
for (k, v) in p.user_properties.iter().take(3) {
parts.push(format!("{k}: {v}"));
}
parts.join(", ")
}).unwrap_or_default();
emit_log(&app_handle, &connection_id, "warn", "disconnect_received",
format!("收到断开连接: {reason_desc} (code: {reason_code})"),
Some(props_info).filter(|d| !d.is_empty()),
);
let status_event = MqttStatusEvent {
connection_id: connection_id.clone(),
status: "disconnected".to_string(),
error: Some(format!("Disconnect reason: {reason_desc}")),
session_present: None,
reason_code: Some(reason_code),
};
let _ = app_handle.emit("mqtt:status", &status_event);
break;
}
Ok(Event::Incoming(Packet::SubAck(suback))) => {
let codes: Vec<String> = suback.return_codes.iter().map(|rc| {
match rc {
rumqttc::mqttbytes::v5::SubscribeReasonCode::Success(qos) => format!("成功(QoS {})", qos_to_u8(*qos)),
rumqttc::mqttbytes::v5::SubscribeReasonCode::Unspecified => "未指定错误(0x80)".to_string(),
rumqttc::mqttbytes::v5::SubscribeReasonCode::ImplementationSpecific => "实现特定错误(0x81)".to_string(),
rumqttc::mqttbytes::v5::SubscribeReasonCode::NotAuthorized => "未授权(0x83)".to_string(),
rumqttc::mqttbytes::v5::SubscribeReasonCode::TopicFilterInvalid => "主题过滤无效(0x82)".to_string(),
rumqttc::mqttbytes::v5::SubscribeReasonCode::QuotaExceeded => "配额超限(0x97)".to_string(),
rumqttc::mqttbytes::v5::SubscribeReasonCode::SharedSubscriptionsNotSupported => "不支持共享订阅(0x9E)".to_string(),
rumqttc::mqttbytes::v5::SubscribeReasonCode::SubscriptionIdNotSupported => "不支持订阅标识符(0xA1)".to_string(),
rumqttc::mqttbytes::v5::SubscribeReasonCode::WildcardSubscriptionsNotSupported => "不支持通配符订阅(0xA2)".to_string(),
_ => format!("未知({:?})", rc),
}
}).collect();
let any_failure = suback.return_codes.iter().any(|rc| !matches!(rc, rumqttc::mqttbytes::v5::SubscribeReasonCode::Success(_)));
let level = if any_failure { "warn" } else { "info" };
emit_log(&app_handle, &connection_id, level, "suback",
format!("订阅确认 (packet_id: {}): {}", suback.pkid, codes.join(", ")),
None,
);
}
Ok(Event::Incoming(Packet::UnsubAck(unsuback))) => {
let reasons: Vec<String> = unsuback.reasons.iter().map(|r| {
match r {
rumqttc::mqttbytes::v5::UnsubAckReason::Success => "成功".to_string(),
rumqttc::mqttbytes::v5::UnsubAckReason::NoSubscriptionExisted => "无此订阅".to_string(),
rumqttc::mqttbytes::v5::UnsubAckReason::UnspecifiedError => "未指定错误".to_string(),
rumqttc::mqttbytes::v5::UnsubAckReason::ImplementationSpecificError => "实现特定错误".to_string(),
rumqttc::mqttbytes::v5::UnsubAckReason::NotAuthorized => "未授权".to_string(),
rumqttc::mqttbytes::v5::UnsubAckReason::TopicFilterInvalid => "主题过滤无效".to_string(),
rumqttc::mqttbytes::v5::UnsubAckReason::PacketIdentifierInUse => "包标识符使用中".to_string(),
}
}).collect();
emit_log(&app_handle, &connection_id, "info", "unsuback",
format!("取消订阅确认: {}", reasons.join(", ")), None);
}
Ok(Event::Incoming(Packet::PubAck(puback))) => {
let reason = format!("{:?}", puback.reason);
let details = puback.properties.as_ref().map(|p| {
let mut parts = Vec::new();
if let Some(ref s) = p.reason_string { parts.push(format!("reason: {s}")); }
for (k, v) in p.user_properties.iter().take(3) {
parts.push(format!("{k}: {v}"));
}
parts.join(", ")
}).unwrap_or_default();
emit_log(&app_handle, &connection_id, "info", "puback",
format!("发布确认 (packet_id: {}, reason: {})", puback.pkid, reason),
Some(details).filter(|d| !d.is_empty()),
);
}
Ok(Event::Incoming(Packet::PubRec(pubrec))) => {
let reason = format!("{:?}", pubrec.reason);
emit_log(&app_handle, &connection_id, "info", "pubrec",
format!("QoS2 发布收到 (packet_id: {}, reason: {})", pubrec.pkid, reason), None);
}
Ok(Event::Incoming(Packet::PubRel(pubrel))) => {
emit_log(&app_handle, &connection_id, "info", "pubrel",
format!("QoS2 发布释放 (packet_id: {})", pubrel.pkid), None);
}
Ok(Event::Incoming(Packet::PubComp(pubcomp))) => {
let reason = format!("{:?}", pubcomp.reason);
emit_log(&app_handle, &connection_id, "info", "pubcomp",
format!("QoS2 发布完成 (packet_id: {}, reason: {})", pubcomp.pkid, reason), None);
}
Ok(Event::Incoming(Packet::PingResp)) => {
// Ping responses are too noisy to log every time
}
Ok(Event::Incoming(Packet::Auth(auth))) => {
let method = auth.properties.as_ref()
.and_then(|p| p.method.as_ref())
.cloned()
.unwrap_or_default();
emit_log(&app_handle, &connection_id, "info", "auth",
format!("认证交换 (reason: {:?}, method: {method})", auth.code), None);
}
// ─── Outgoing events (packet IDs only) ──────────────────────
Ok(Event::Outgoing(Outgoing::PingReq)) => {
// Ping requests are too noisy
}
Ok(Event::Outgoing(Outgoing::Publish(pkid))) => {
emit_log(&app_handle, &connection_id, "info", "publish_sent",
format!("发送消息 (packet_id: {pkid})"), None);
}
Ok(Event::Outgoing(Outgoing::Subscribe(pkid))) => {
emit_log(&app_handle, &connection_id, "info", "subscribe_sent",
format!("发送订阅请求 (packet_id: {pkid})"), None);
}
Ok(Event::Outgoing(Outgoing::Unsubscribe(pkid))) => {
emit_log(&app_handle, &connection_id, "info", "unsubscribe_sent",
format!("发送取消订阅请求 (packet_id: {pkid})"), None);
}
Ok(Event::Outgoing(Outgoing::PubAck(pkid))) => {
emit_log(&app_handle, &connection_id, "info", "puback_sent",
format!("发送发布确认 (packet_id: {pkid})"), None);
}
Ok(Event::Outgoing(Outgoing::PubRec(pkid))) => {
emit_log(&app_handle, &connection_id, "info", "pubrec_sent",
format!("发送QoS2发布收到 (packet_id: {pkid})"), None);
}
Ok(Event::Outgoing(Outgoing::PubRel(pkid))) => {
emit_log(&app_handle, &connection_id, "info", "pubrel_sent",
format!("发送QoS2发布释放 (packet_id: {pkid})"), None);
}
Ok(Event::Outgoing(Outgoing::PubComp(pkid))) => {
emit_log(&app_handle, &connection_id, "info", "pubcomp_sent",
format!("发送QoS2发布完成 (packet_id: {pkid})"), None);
}
Ok(Event::Outgoing(Outgoing::PingResp)) => {
// Ping responses are too noisy
}
Ok(Event::Outgoing(Outgoing::AwaitAck(pkid))) => {
// AwaitAck is internal, log it
emit_log(&app_handle, &connection_id, "info", "await_ack",
format!("等待应答 (packet_id: {pkid})"), None);
}
Ok(Event::Outgoing(Outgoing::Auth)) => {
emit_log(&app_handle, &connection_id, "info", "auth_sent",
"发送认证请求".to_string(), None);
}
Ok(Event::Outgoing(Outgoing::Disconnect)) => {
connected.store(false, Ordering::SeqCst);
emit_log(&app_handle, &connection_id, "info", "disconnect_sent",
"已发送断开连接请求".to_string(), None);
let status_event = MqttStatusEvent {
connection_id: connection_id.clone(),
status: "disconnected".to_string(),
error: None,
session_present: None,
reason_code: None,
};
let _ = app_handle.emit("mqtt:status", &status_event);
break;
}
Err(ConnectionError::RequestsDone) => {
connected.store(false, Ordering::SeqCst);
emit_log(&app_handle, &connection_id, "info", "requests_done",
"所有请求已完成,连接关闭".to_string(), None);
let status_event = MqttStatusEvent {
connection_id: connection_id.clone(),
status: "disconnected".to_string(),
error: None,
session_present: None,
reason_code: None,
};
let _ = app_handle.emit("mqtt:status", &status_event);
break;
}
Err(e) => {
connected.store(false, Ordering::SeqCst);
emit_log(&app_handle, &connection_id, "error", "connection_error",
format!("连接错误: {e}"), None);
let status_event = MqttStatusEvent {
connection_id: connection_id.clone(),
status: "error".to_string(),
error: Some(e.to_string()),
session_present: None,
reason_code: None,
};
let _ = app_handle.emit("mqtt:status", &status_event);
break;
}
Ok(Event::Incoming(other)) => {
emit_log(&app_handle, &connection_id, "info", "incoming_other",
format!("未处理入站事件: {:?}", std::mem::discriminant(&other)), None);
}
Ok(Event::Auth(auth_event)) => {
match auth_event {
rumqttc::AuthEvent::Started { method, .. } => {
emit_log(&app_handle, &connection_id, "info", "auth_started",
format!("认证开始 (method: {method})"), None);
}
rumqttc::AuthEvent::Continue { method, .. } => {
emit_log(&app_handle, &connection_id, "info", "auth_continue",
format!("认证继续 (method: {method})"), None);
}
rumqttc::AuthEvent::Succeeded { method, .. } => {
emit_log(&app_handle, &connection_id, "info", "auth_succeeded",
format!("认证成功 (method: {method})"), None);
}
rumqttc::AuthEvent::Failed { method, reason, .. } => {
emit_log(&app_handle, &connection_id, "warn", "auth_failed",
format!("认证失败 (method: {method}, reason: {reason:?})"), None);
}
}
}
}
}
_ = cancel_rx.changed() => {
connected.store(false, Ordering::SeqCst);
emit_log(&app_handle, &connection_id, "info", "disconnect_cancelled",
"用户取消连接".to_string(), None);
let status_event = MqttStatusEvent {
connection_id: connection_id.clone(),
status: "disconnected".to_string(),
error: None,
session_present: None,
reason_code: None,
};
let _ = app_handle.emit("mqtt:status", &status_event);
break;
}
}
}
});
}
// ─── Tauri Commands ────────────────────────────────────────────────────────────
#[tauri::command]
async fn mqtt_connect(
app_handle: AppHandle,
state: tauri::State<'_, AppState>,
conn: MqttConnectionDto,
) -> Result<(), String> {
let broker = Broker::tcp(&conn.host, conn.port);
let mut mqttopts = MqttOptions::new(&conn.client_id, broker);
mqttopts.set_keep_alive(conn.keep_alive as u16);
mqttopts.set_clean_start(conn.clean_start);
if conn.session_expiry_interval > 0 {
mqttopts.set_session_expiry_interval(Some(conn.session_expiry_interval));
}
if !conn.username.is_empty() {
mqttopts.set_credentials(conn.username.clone(), conn.password.clone());
}
if let Some(rm) = conn.receive_maximum {
mqttopts.set_receive_maximum(Some(rm));
}
if let Some(mps) = conn.maximum_packet_size {
mqttopts.set_max_packet_size(Some(mps));
}
if let Some(tam) = conn.topic_alias_maximum {
mqttopts.set_topic_alias_max(Some(tam));
}
// Cancel existing connection if any
{
let clients = state.clients.read().await;
if let Some(handle) = clients.get(&conn.id) {
let _ = handle.cancel_tx.send(true);
}
}
let (client, eventloop) = AsyncClient::builder(mqttopts).build();
let connected = Arc::new(AtomicBool::new(false));
let (cancel_tx, cancel_rx) = watch::channel(false);
let handle = ClientHandle {
client: client.clone(),
cancel_tx,
connected: connected.clone(),
};
{
let mut clients = state.clients.write().await;
clients.insert(conn.id.clone(), handle);
}
// Emit connecting status
let status_event = MqttStatusEvent {
connection_id: conn.id.clone(),
status: "connecting".to_string(),
error: None,
session_present: None,
reason_code: None,
};
let _ = app_handle.emit("mqtt:status", &status_event);
emit_log(&app_handle, &conn.id, "info", "connect",
format!("正在连接 {0}:{1}", conn.host, conn.port), None);
// Spawn background task
spawn_event_loop(conn.id, eventloop, cancel_rx, connected, app_handle);
Ok(())
}
#[tauri::command]
async fn mqtt_test_connection(conn: MqttConnectionDto) -> Result<String, String> {
use tokio::time::timeout;
let test_id = format!("{}_test_{}", conn.client_id, std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis());
let broker = Broker::tcp(&conn.host, conn.port);
let mut mqttopts = MqttOptions::new(&test_id, broker);
mqttopts.set_keep_alive(10);
mqttopts.set_clean_start(true);
if !conn.username.is_empty() {
mqttopts.set_credentials(conn.username.clone(), conn.password.clone());
}
let (client, mut eventloop) = AsyncClient::builder(mqttopts).build();
let result = timeout(std::time::Duration::from_secs(5), async {
loop {
match eventloop.poll().await {
Ok(Event::Incoming(Packet::ConnAck(connack))) => {
if connack.code == ConnectReturnCode::Success {
return Ok("连接测试成功!".to_string());
} else {
return Err(format!("连接被拒绝: {:?}", connack.code));
}
}
Ok(Event::Incoming(Packet::Disconnect(disconnect))) => {
return Err(format!("连接被拒绝: {:?}", disconnect.reason_code));
}
Err(e) => {
return Err(format!("连接失败: {}", e));
}
_ => {}
}
}
}).await;
// Drop client to close the connection
drop(client);
match result {
Ok(r) => r,
Err(_) => Err("连接测试超时(5秒),请检查主机地址和端口是否正确".to_string()),
}
}
#[tauri::command]
async fn mqtt_disconnect(
app_handle: AppHandle,
state: tauri::State<'_, AppState>,
connection_id: String,
) -> Result<(), String> {
let mut clients = state.clients.write().await;
if let Some(handle) = clients.remove(&connection_id) {
emit_log(&app_handle, &connection_id, "info", "disconnect",
"用户请求断开连接".to_string(), None);
let _ = handle.cancel_tx.send(true);
Ok(())
} else {
emit_log(&app_handle, &connection_id, "warn", "disconnect",
"断开连接失败: 连接不存在".to_string(), None);
Err("Connection not found".to_string())
}
}
#[tauri::command]
async fn mqtt_publish(
app_handle: AppHandle,
state: tauri::State<'_, AppState>,
publish: PublishDto,
) -> Result<(), String> {
let clients = state.clients.read().await;
let handle = clients
.get(&publish.connection_id)
.ok_or_else(|| "Connection not found".to_string())?;
let qos = qos_from_u8(publish.qos);
let payload = publish.payload.into_bytes();
let mut props = PublishProperties::default();
props.user_properties = publish.user_properties;
props.content_type = publish.content_type.clone();
props.message_expiry_interval = publish.message_expiry_interval;
props.response_topic = publish.response_topic.clone();
props.correlation_data = publish
.correlation_data
.map(|s| s.into_bytes().into());
emit_log(&app_handle, &publish.connection_id, "info", "publish_sent",
format!("发布消息到 {topic}: {size} 字节 (QoS {qos}, retain={retain})",
topic = publish.topic,
size = payload.len(),
qos = publish.qos,
retain = publish.retain,
),
Some(format!("payload: {}", String::from_utf8_lossy(&payload).to_string().chars().take(200).collect::<String>())),
);
let options = PublishOptions::new(qos)
.retain(publish.retain)
.properties(props);
handle
.client
.publish(publish.topic, payload, options)
.await
.map_err(|e| {
emit_log(&app_handle, &publish.connection_id, "error", "publish_error",
format!("发布失败: {e}"), None);
e.to_string()
})?;
Ok(())
}
#[tauri::command]
async fn mqtt_subscribe(
app_handle: AppHandle,
state: tauri::State<'_, AppState>,
subscribe: SubscribeDto,
) -> Result<(), String> {
let clients = state.clients.read().await;
let handle = clients
.get(&subscribe.connection_id)
.ok_or_else(|| "Connection not found".to_string())?;
let qos = qos_from_u8(subscribe.qos);
emit_log(&app_handle, &subscribe.connection_id, "info", "subscribe",
format!("订阅主题: {topic} (QoS {qos})", topic = subscribe.topic, qos = subscribe.qos), None);
// Use tracked subscribe to wait for SubAck result
let notice = handle
.client
.subscribe_tracked(subscribe.topic, qos)
.await
.map_err(|e| {
emit_log(&app_handle, &subscribe.connection_id, "error", "subscribe_error",
format!("订阅请求失败: {e}"), None);
e.to_string()
})?;
// Wait for SubAck and check return codes
notice.wait_completion_async().await.map_err(|e| {
let err_msg = format!("订阅被拒绝: {e}");
emit_log(&app_handle, &subscribe.connection_id, "error", "subscribe_rejected",
err_msg.clone(), None);
err_msg
})?;
Ok(())
}
#[tauri::command]
async fn mqtt_unsubscribe(
app_handle: AppHandle,
state: tauri::State<'_, AppState>,
connection_id: String,
topic: String,
) -> Result<(), String> {
let clients = state.clients.read().await;
let handle = clients
.get(&connection_id)
.ok_or_else(|| "Connection not found".to_string())?;
emit_log(&app_handle, &connection_id, "info", "unsubscribe",
format!("取消订阅主题: {topic}"), None);
handle
.client
.unsubscribe(topic)
.await
.map_err(|e| {
emit_log(&app_handle, &connection_id, "error", "unsubscribe_error",
format!("取消订阅失败: {e}"), None);
e.to_string()
})?;
Ok(())
}
#[tauri::command]
async fn mqtt_get_connections(state: tauri::State<'_, AppState>) -> Result<Vec<ConnectionStatus>, String> {
let clients = state.clients.read().await;
Ok(clients
.iter()
.map(|(id, handle)| ConnectionStatus {
id: id.clone(),
connected: handle.connected.load(Ordering::SeqCst),
error: None,
})
.collect())
}
#[tauri::command]
async fn mqtt_get_connection_status(
state: tauri::State<'_, AppState>,
connection_id: String,
) -> Result<ConnectionStatus, String> {
let clients = state.clients.read().await;
clients
.get(&connection_id)
.map(|handle| ConnectionStatus {
id: connection_id,
connected: handle.connected.load(Ordering::SeqCst),
error: None,
})
.ok_or_else(|| "Connection not found".to_string())
}
// ─── App entry ─────────────────────────────────────────────────────────────────
#[tauri::command]
fn mqtt_get_log_dir(app_handle: AppHandle) -> Result<String, String> {
let dir = app_handle.path().app_data_dir()
.map_err(|e| format!("获取日志目录失败: {e}"))?
.join("logs");
let _ = std::fs::create_dir_all(&dir);
Ok(dir.to_string_lossy().to_string())
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let log_writer = std::env::current_dir()
.ok()
.map(|p| p.join("logs"))
.or_else(|| {
// Fallback: use a temp directory
std::env::temp_dir().join("mqttx-logs").into()
})
.map(|dir| LogWriter::new(dir));
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.manage(AppState {
clients: RwLock::new(HashMap::new()),
log_writer,
})
.invoke_handler(tauri::generate_handler![
mqtt_connect,
mqtt_disconnect,
mqtt_publish,
mqtt_subscribe,
mqtt_unsubscribe,
mqtt_get_connections,
mqtt_get_connection_status,
mqtt_test_connection,
mqtt_get_log_dir,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
+6
View File
@@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
mqttx_lib::run()
}
+1
View File
@@ -0,0 +1 @@
{"rustc_fingerprint":16876742413920271527,"outputs":{"3784865838296543696":{"success":true,"status":"","code":0,"stdout":"rustc 1.99.0-nightly (8ab9fdff5 2026-07-30)\nbinary: rustc\ncommit-hash: 8ab9fdff5a91b9f2b5ed57fb0275452d9a0d0280\ncommit-date: 2026-07-30\nhost: x86_64-pc-windows-gnu\nrelease: 1.99.0-nightly\nLLVM version: 22.1.8\n","stderr":""}},"successes":{}}
+37
View File
@@ -0,0 +1,37 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "MQTTX",
"version": "0.1.0",
"identifier": "com.mqtt.x",
"build": {
"beforeDevCommand": "pnpm dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "pnpm build",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"title": "MQTTX - MQTT v5 调试工具",
"width": 1200,
"height": 800,
"minWidth": 900,
"minHeight": 600
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}
+26
View File
@@ -0,0 +1,26 @@
<script setup lang="ts">
import AppLayout from "./components/AppLayout.vue";
</script>
<template>
<AppLayout />
</template>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body, #app {
height: 100%;
overflow: hidden;
}
body {
font-family: "Inter", "Helvetica Neue", "PingFang SC", "Microsoft YaHei", sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
</style>
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

+155
View File
@@ -0,0 +1,155 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useMqttBridge } from '../stores/useMqttBridge'
import ConnectionList from './connections/ConnectionList.vue'
import PresetList from './presets/PresetList.vue'
import DashboardView from './dashboard/DashboardView.vue'
const { init } = useMqttBridge()
const activeTab = ref<'connections' | 'presets' | 'dashboard'>('dashboard')
onMounted(() => {
init()
})
</script>
<template>
<div class="app-layout">
<header class="app-header">
<div class="header-left">
<div class="app-brand">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="brand-icon">
<circle cx="12" cy="12" r="10"/>
<path d="M12 6v6l4 2"/>
</svg>
<span class="brand-name">MQTTX</span>
<span class="brand-version">v0.1</span>
</div>
</div>
<div class="header-tabs">
<button
:class="['tab-btn', { active: activeTab === 'dashboard' }]"
@click="activeTab = 'dashboard'"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
看板
</button>
<button
:class="['tab-btn', { active: activeTab === 'connections' }]"
@click="activeTab = 'connections'"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4h16v16H4z"/><path d="M9 9h6v6H9z"/><path d="M9 12h6"/></svg>
连接管理
</button>
<button
:class="['tab-btn', { active: activeTab === 'presets' }]"
@click="activeTab = 'presets'"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
预设
</button>
</div>
<div class="header-right" />
</header>
<main class="app-main">
<ConnectionList v-if="activeTab === 'connections'" />
<PresetList v-if="activeTab === 'presets'" />
<DashboardView v-if="activeTab === 'dashboard'" />
</main>
</div>
</template>
<style scoped>
.app-layout {
display: flex;
flex-direction: column;
height: 100vh;
background: var(--el-bg-color-page);
}
/* ─── Header ─────────────────────────────────────────────────── */
.app-header {
display: flex;
align-items: center;
padding: 0 16px;
height: 48px;
background: var(--el-bg-color);
border-bottom: 1px solid var(--el-border-color-light);
flex-shrink: 0;
gap: 24px;
}
.header-left {
display: flex;
align-items: center;
}
.app-brand {
display: flex;
align-items: center;
gap: 8px;
}
.brand-icon {
color: var(--el-color-primary);
}
.brand-name {
font-size: 18px;
font-weight: 700;
color: var(--el-color-primary);
letter-spacing: 0.5px;
}
.brand-version {
font-size: 11px;
color: var(--el-text-color-placeholder);
background: var(--el-fill-color);
padding: 0 5px;
border-radius: 4px;
line-height: 18px;
}
/* ─── Tabs ───────────────────────────────────────────────────── */
.header-tabs {
display: flex;
gap: 2px;
background: var(--el-fill-color);
padding: 2px;
border-radius: 8px;
}
.tab-btn {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 0 12px;
height: 28px;
border: none;
background: transparent;
border-radius: 6px;
font-size: 13px;
color: var(--el-text-color-secondary);
cursor: pointer;
transition: all 0.15s;
font-family: inherit;
white-space: nowrap;
}
.tab-btn:hover {
color: var(--el-text-color-primary);
}
.tab-btn.active {
background: var(--el-bg-color);
color: var(--el-color-primary);
font-weight: 500;
box-shadow: 0 1px 2px rgba(0,0,0,0.06);
}
.tab-btn svg {
flex-shrink: 0;
}
.header-right {
flex: 1;
}
/* ─── Main ───────────────────────────────────────────────────── */
.app-main {
flex: 1;
padding: 14px;
overflow: auto;
}
</style>
@@ -0,0 +1,232 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import type { MqttConnection } from '../../types/mqtt'
import { defaultConnection } from '../../types/mqtt'
import { mqttTestConnection } from '../../stores/useMqttBridge'
import { toConnectionDto } from '../../types/mqtt'
const props = defineProps<{
modelValue: boolean
connection: MqttConnection | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
save: [data: MqttConnection]
}>()
const form = ref<MqttConnection>(defaultConnection())
const testing = ref(false)
watch(
() => props.connection,
(conn) => {
if (conn) {
form.value = { ...conn }
} else {
form.value = defaultConnection()
}
},
{ immediate: true },
)
function generateClientId() {
form.value.clientId = `mqttx_${Math.random().toString(36).slice(2, 10)}`
}
function handleSave() {
if (!form.value.name.trim()) {
ElMessage.warning('请输入连接名称')
return
}
if (!form.value.host.trim()) {
ElMessage.warning('请输入主机地址')
return
}
if (!form.value.clientId.trim()) {
ElMessage.warning('请输入 Client ID')
return
}
emit('save', { ...form.value })
emit('update:modelValue', false)
}
function handleCancel() {
emit('update:modelValue', false)
}
async function handleTestConnection() {
if (!form.value.host.trim()) {
ElMessage.warning('请输入主机地址')
return
}
testing.value = true
try {
const msg = await mqttTestConnection(toConnectionDto(form.value))
ElMessage.success(msg)
} catch (e: unknown) {
const err = e instanceof Error ? e.message : String(e)
ElMessage.error(err)
} finally {
testing.value = false
}
}
</script>
<template>
<el-dialog
:model-value="modelValue"
:title="connection ? '编辑连接' : '新增连接'"
width="600px"
@update:model-value="(val: boolean) => emit('update:modelValue', val)"
>
<el-form
ref="formRef"
:model="form"
label-width="110px"
label-position="top"
size="small"
>
<el-form-item label="名称">
<el-input v-model="form.name" placeholder="给连接起个名字" />
</el-form-item>
<el-row :gutter="12">
<el-col :span="6">
<el-form-item label="协议">
<el-select v-model="form.ssl" :placeholder="false">
<el-option :value="false" label="mqtt://" />
<el-option :value="true" label="mqtts://" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="主机">
<el-input v-model="form.host" placeholder="localhost" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="端口">
<el-input-number
v-model="form.port"
:min="1"
:max="65535"
style="width: 100%"
/>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="用户名">
<el-input v-model="form.username" placeholder="(可选)" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="密码">
<el-input
v-model="form.password"
type="password"
show-password
placeholder="(可选)"
/>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="Client ID">
<el-input v-model="form.clientId" placeholder="自动生成即可">
<template #append>
<el-button @click="generateClientId">重新生成</el-button>
</template>
</el-input>
</el-form-item>
<el-divider border-style="dashed" />
<div class="section-title">MQTT v5 选项</div>
<el-row :gutter="12">
<el-col :span="8">
<el-form-item label="Clean Start">
<el-switch v-model="form.cleanStart" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="Session Expiry">
<el-input-number
v-model="form.sessionExpiryInterval"
:min="0"
style="width: 100%"
/>
<span class="unit">秒</span>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="Keep Alive">
<el-input-number
v-model="form.keepAlive"
:min="10"
:max="3600"
style="width: 100%"
/>
<span class="unit">秒</span>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="12">
<el-col :span="8">
<el-form-item label="Receive Max">
<el-input-number
v-model="form.receiveMaximum"
:min="0"
:value-on-clear="null"
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="Max Packet Size">
<el-input-number
v-model="form.maximumPacketSize"
:min="0"
:value-on-clear="null"
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="Topic Alias Max">
<el-input-number
v-model="form.topicAliasMaximum"
:min="0"
:value-on-clear="null"
style="width: 100%"
/>
</el-form-item>
</el-col>
</el-row>
</el-form>
<template #footer>
<el-button @click="handleCancel">取消</el-button>
<el-button
:loading="testing"
:disabled="testing"
@click="handleTestConnection"
>
测试连接
</el-button>
<el-button type="primary" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.section-title {
font-size: 13px;
font-weight: 600;
color: var(--el-text-color-primary);
margin-bottom: 8px;
}
.unit {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-left: 4px;
}
</style>
@@ -0,0 +1,230 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { MqttConnection } from '../../types/mqtt'
import { useConnections } from '../../stores/useConnections'
import { toConnectionDto } from '../../types/mqtt'
import { mqttConnect, mqttDisconnect } from '../../stores/useMqttBridge'
import ConnectionForm from './ConnectionForm.vue'
const { connections, add, update, remove, updateStatus } = useConnections()
const showForm = ref(false)
const editingConnection = ref<MqttConnection | null>(null)
// ─── Connection actions ────────────────────────────────────────────────────────
async function handleConnect(conn: MqttConnection) {
try {
updateStatus(conn.id, 'connecting')
await mqttConnect(toConnectionDto(conn))
// Status will be updated by the event listener
} catch (e: any) {
updateStatus(conn.id, 'error', String(e))
ElMessage.error(`连接失败: ${e}`)
}
}
async function handleDisconnect(conn: MqttConnection) {
try {
await mqttDisconnect(conn.id)
updateStatus(conn.id, 'disconnected')
ElMessage.success('已断开')
} catch (e: any) {
ElMessage.error(`断开失败: ${e}`)
}
}
// ─── CRUD ──────────────────────────────────────────────────────────────────────
function handleAdd() {
editingConnection.value = null
showForm.value = true
}
function handleEdit(conn: MqttConnection) {
editingConnection.value = conn
showForm.value = true
}
function handleSave(data: MqttConnection) {
if (editingConnection.value) {
// Update existing
update(editingConnection.value.id, data)
ElMessage.success('连接已更新')
} else {
// Add new
const newConn = { ...data, id: crypto.randomUUID() }
add()
// Replace the default with our data
const idx = connections.value.length - 1
connections.value[idx] = { ...connections.value[idx], ...newConn }
ElMessage.success('连接已创建')
}
showForm.value = false
}
async function handleDelete(conn: MqttConnection) {
try {
await ElMessageBox.confirm(`确定删除连接 "${conn.name}"?`, '确认', {
type: 'warning',
})
if (conn.status === 'connected') {
await handleDisconnect(conn)
}
remove(conn.id)
ElMessage.success('已删除')
} catch {
// cancelled
}
}
// ─── Status helpers ────────────────────────────────────────────────────────────
function statusText(status: string): string {
switch (status) {
case 'connected':
return '已连接'
case 'connecting':
return '连接中...'
case 'error':
return '错误'
default:
return '已断开'
}
}
function statusType(status: string): string {
switch (status) {
case 'connected':
return 'success'
case 'connecting':
return 'warning'
case 'error':
return 'danger'
default:
return 'info'
}
}
</script>
<template>
<div class="connection-list">
<div class="list-header">
<h3>连接管理</h3>
<el-button type="primary" size="small" @click="handleAdd">
+ 新增连接
</el-button>
</div>
<el-table :data="connections" style="width: 100%" stripe>
<el-table-column prop="name" label="名称" min-width="120" />
<el-table-column label="地址" min-width="180">
<template #default="scope">
<span>{{ scope.row.ssl ? 'mqtts' : 'mqtt' }}://{{ scope.row.host }}:{{ scope.row.port }}</span>
</template>
</el-table-column>
<el-table-column label="协议" width="60">
<template #default>
<el-tag size="small">v5</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="120">
<template #default="scope">
<el-tag :type="statusType(scope.row.status)" size="small" effect="plain">
<span :class="['status-indicator', scope.row.status]" />
{{ statusText(scope.row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="240" fixed="right">
<template #default="scope">
<el-button
v-if="scope.row.status === 'disconnected' || scope.row.status === 'error'"
size="small"
type="success"
:loading="scope.row.status === 'connecting'"
@click="handleConnect(scope.row)"
>
连接
</el-button>
<el-button
v-else
size="small"
@click="handleDisconnect(scope.row)"
>
断开
</el-button>
<el-button
size="small"
@click="handleEdit(scope.row)"
>
编辑
</el-button>
<el-button
size="small"
type="danger"
@click="handleDelete(scope.row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="connections.length === 0" class="empty-hint">
暂无连接,点击上方按钮创建
</div>
<ConnectionForm
v-model="showForm"
:connection="editingConnection"
@save="handleSave"
/>
</div>
</template>
<style scoped>
.connection-list {
height: 100%;
}
.list-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.list-header h3 {
margin: 0;
font-size: 16px;
}
.empty-hint {
text-align: center;
padding: 40px;
color: var(--el-text-color-placeholder);
}
.status-indicator {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
margin-right: 4px;
}
.status-indicator.connected {
background: var(--el-color-success);
}
.status-indicator.connecting {
background: var(--el-color-warning);
animation: pulse 1s infinite;
}
.status-indicator.error {
background: var(--el-color-danger);
}
.status-indicator.disconnected {
background: var(--el-text-color-disabled);
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
</style>
+422
View File
@@ -0,0 +1,422 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useConnections } from '../../stores/useConnections'
import { usePresets } from '../../stores/usePresets'
import { useMessages } from '../../stores/useMessages'
import { mqttConnect, mqttDisconnect } from '../../stores/useMqttBridge'
import { toConnectionDto } from '../../types/mqtt'
import { ElMessage } from 'element-plus'
import FilterBar from './FilterBar.vue'
import TopicTree from './TopicTree.vue'
import MessageStream from './MessageStream.vue'
import SendPanel from './SendPanel.vue'
import LogPanel from './LogPanel.vue'
import type { PublishDto } from '../../types/mqtt'
const { connections, updateStatus, getConnected } = useConnections()
const { presets } = usePresets()
const { messages, clear, filterByTopic, filterByConnection, messageCount } = useMessages()
const topicFilter = ref('')
const selectedConnectionId = ref('')
const selectedTopic = ref('')
const paused = ref(false)
const showOutgoing = ref(true)
const showTopicTree = ref(true)
const showLogPanel = ref(false)
const connecting = ref(false)
const filteredMessages = computed(() => {
let result = messages.value
if (topicFilter.value.trim()) {
result = filterByTopic(topicFilter.value)
}
if (selectedConnectionId.value) {
result = filterByConnection(selectedConnectionId.value)
}
if (selectedTopic.value) {
result = result.filter((m) => m.topic === selectedTopic.value || m.topic.startsWith(selectedTopic.value + '/'))
}
if (!showOutgoing.value) {
result = result.filter((m) => m.direction === 'in')
}
return result
})
// Selected connection object
const selectedConn = computed(() =>
connections.value.find((c) => c.id === selectedConnectionId.value),
)
const isConnected = computed(() => selectedConn.value?.status === 'connected')
async function handleToggleConnect() {
const conn = selectedConn.value
if (!conn) return
if (isConnected.value) {
try {
await mqttDisconnect(conn.id)
updateStatus(conn.id, 'disconnected')
ElMessage.success('已断开')
} catch (e: any) {
ElMessage.error(`断开失败: ${e}`)
}
} else {
connecting.value = true
try {
updateStatus(conn.id, 'connecting')
await mqttConnect(toConnectionDto(conn))
// Status will be updated by the event listener
} catch (e: any) {
updateStatus(conn.id, 'error', String(e))
ElMessage.error(`连接失败: ${e}`)
} finally {
connecting.value = false
}
}
}
function handleClear() {
clear()
ElMessage.success('消息已清空')
}
function handleSent(_publish: PublishDto) {
// Outgoing message is already handled by the backend echo
}
function handleTopicSelect(topic: string) {
selectedTopic.value = topic
}
</script>
<template>
<div class="dashboard">
<!-- Toolbar -->
<div class="toolbar-card">
<div class="toolbar-left">
<!-- Connection Selector -->
<div class="toolbar-group">
<div class="conn-select-wrap">
<el-select
v-model="selectedConnectionId"
placeholder="选择连接"
size="small"
style="width: 180px"
clearable
>
<el-option
v-for="c in connections"
:key="c.id"
:label="c.name"
:value="c.id"
>
<div class="conn-option">
<span>{{ c.name }}</span>
<span :class="['status-badge', c.status]" />
</div>
</el-option>
</el-select>
<button
v-if="selectedConn"
:class="['conn-toggle-btn', { connected: isConnected, connecting: selectedConn.status === 'connecting' }]"
:disabled="selectedConn.status === 'connecting' || connecting"
@click="handleToggleConnect"
:title="isConnected ? '断开连接' : '连接'"
>
<svg v-if="isConnected" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
<svg v-else width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="1" y1="1" x2="23" y2="23"/><path d="M16.72 3.7A9 9 0 0 1 21 6.5"/><path d="M3.5 6.5a9 9 0 0 1 5.22-3.2"/><path d="M9 12a3 3 0 0 1 3-3 3 3 0 0 1 1.24.26"/><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/></svg>
</button>
</div>
<FilterBar v-model="topicFilter" />
</div>
<!-- View Toggles -->
<div class="toolbar-group">
<el-button
size="small"
:class="['view-btn', { active: showTopicTree }]"
@click="showTopicTree = !showTopicTree"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 3h14v4H5z"/><path d="M3 10h7v11H3z"/><path d="M14 10h7v11h-7z"/></svg>
主题树
</el-button>
<el-button
size="small"
:class="['view-btn', { active: showLogPanel }]"
@click="showLogPanel = !showLogPanel"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
日志
</el-button>
</div>
</div>
<div class="toolbar-right">
<el-checkbox v-model="showOutgoing" size="small">
<span class="checkbox-label">显示发送</span>
</el-checkbox>
<div class="msg-stat">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
<span>{{ messageCount }}</span>
</div>
<div class="toolbar-divider" />
<el-button
size="small"
:class="['action-btn', { 'is-paused': paused }]"
@click="paused = !paused"
>
<svg v-if="paused" width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>
<svg v-else width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>
{{ paused ? '继续' : '暂停' }}
</el-button>
<el-button
size="small"
class="action-btn danger"
@click="handleClear"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
清空
</el-button>
</div>
</div>
<!-- Connection not connected hint -->
<div
v-if="selectedConn && !isConnected && selectedConn.status !== 'connecting'"
class="connect-hint"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
连接「{{ selectedConn.name }}」未连接,请先
<button class="connect-hint-btn" @click="handleToggleConnect">点击连接</button>
</div>
<!-- Main Content -->
<div class="content-area">
<TopicTree
v-if="showTopicTree"
:messages="messages"
:selected-topic="selectedTopic"
:connection-id="selectedConnectionId"
:connected="isConnected"
@select="handleTopicSelect"
/>
<MessageStream
:messages="filteredMessages"
:paused="paused"
@update:paused="paused = $event"
/>
</div>
<!-- Log Panel -->
<LogPanel v-if="showLogPanel" v-model:show="showLogPanel" />
<!-- Send Panel -->
<SendPanel
:connections="connections"
:presets="presets"
@sent="handleSent"
/>
</div>
</template>
<style scoped>
.dashboard {
display: flex;
flex-direction: column;
height: 100%;
gap: 10px;
}
/* ─── Toolbar ─────────────────────────────────────────────────── */
.toolbar-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 14px;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color);
border-radius: 8px;
flex-shrink: 0;
}
.toolbar-left {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.toolbar-right {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.toolbar-group {
display: flex;
align-items: center;
gap: 6px;
}
.toolbar-divider {
width: 1px;
height: 20px;
background: var(--el-border-color);
}
/* Connection selector with toggle button */
.conn-select-wrap {
display: flex;
align-items: center;
gap: 4px;
}
.conn-option {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.status-badge {
display: inline-block;
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
}
.status-badge.connected { background: var(--el-color-success); }
.status-badge.disconnected { background: var(--el-text-color-disabled); }
.status-badge.connecting { background: var(--el-color-warning); }
.status-badge.error { background: var(--el-color-danger); }
.conn-toggle-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: 1px solid var(--el-border-color);
border-radius: 6px;
background: var(--el-bg-color);
cursor: pointer;
color: var(--el-text-color-secondary);
transition: all 0.15s;
flex-shrink: 0;
}
.conn-toggle-btn:hover {
border-color: var(--el-color-primary);
color: var(--el-color-primary);
}
.conn-toggle-btn.connected {
color: var(--el-color-success);
border-color: var(--el-color-success);
}
.conn-toggle-btn.connecting {
opacity: 0.5;
cursor: not-allowed;
}
/* View toggle buttons */
.view-btn {
--el-button-bg-color: transparent;
--el-button-border-color: var(--el-border-color);
--el-button-hover-bg-color: var(--el-fill-color);
--el-button-hover-border-color: var(--el-border-color);
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--el-text-color-secondary);
padding: 0 10px !important;
height: 28px;
border-radius: 6px;
}
.view-btn.active {
--el-button-bg-color: var(--el-color-primary-light-9);
--el-button-border-color: var(--el-color-primary-light-5);
color: var(--el-color-primary);
}
.view-btn svg {
flex-shrink: 0;
}
/* Action buttons */
.action-btn {
--el-button-bg-color: transparent;
--el-button-border-color: var(--el-border-color);
--el-button-hover-bg-color: var(--el-fill-color);
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
padding: 0 10px !important;
height: 28px;
border-radius: 6px;
}
.action-btn.is-paused {
--el-button-bg-color: var(--el-color-warning-light-9);
--el-button-border-color: var(--el-color-warning-light-5);
color: var(--el-color-warning);
}
.action-btn.danger {
color: var(--el-color-danger);
}
.action-btn svg {
flex-shrink: 0;
}
/* Message stat */
.msg-stat {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--el-text-color-secondary);
white-space: nowrap;
}
.msg-stat svg {
flex-shrink: 0;
}
.checkbox-label {
font-size: 12px;
}
/* ─── Connection hint ──────────────────────────────────────────── */
.connect-hint {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: var(--el-color-warning-light-9);
border: 1px solid var(--el-color-warning-light-7);
border-radius: 6px;
font-size: 12px;
color: var(--el-color-warning);
flex-shrink: 0;
}
.connect-hint svg {
flex-shrink: 0;
}
.connect-hint-btn {
background: none;
border: none;
color: var(--el-color-primary);
cursor: pointer;
font-size: 12px;
font-weight: 600;
text-decoration: underline;
font-family: inherit;
padding: 0;
}
.connect-hint-btn:hover {
color: var(--el-color-primary-dark-2);
}
/* ─── Content Area ────────────────────────────────────────────── */
.content-area {
display: flex;
flex: 1;
gap: 10px;
min-height: 0;
}
</style>
+72
View File
@@ -0,0 +1,72 @@
<script setup lang="ts">
defineProps<{
modelValue: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
</script>
<template>
<div class="filter-bar">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="search-icon"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input
:value="modelValue"
@input="(e: any) => emit('update:modelValue', e.target.value)"
placeholder="搜索主题..."
class="filter-input"
/>
<button
v-if="modelValue"
class="filter-clear"
@click="emit('update:modelValue', '')"
>✕</button>
</div>
</template>
<style scoped>
.filter-bar {
display: flex;
align-items: center;
gap: 4px;
padding: 0 8px;
border: 1px solid var(--el-border-color);
border-radius: 6px;
background: var(--el-bg-color);
height: 28px;
width: 180px;
transition: border-color 0.15s;
}
.filter-bar:focus-within {
border-color: var(--el-color-primary);
}
.search-icon {
color: var(--el-text-color-placeholder);
flex-shrink: 0;
}
.filter-input {
flex: 1;
border: none;
outline: none;
font-size: 12px;
color: var(--el-text-color-regular);
background: transparent;
font-family: inherit;
}
.filter-input::placeholder {
color: var(--el-text-color-placeholder);
}
.filter-clear {
border: none;
background: none;
font-size: 12px;
color: var(--el-text-color-secondary);
cursor: pointer;
padding: 0 2px;
line-height: 1;
}
.filter-clear:hover {
color: var(--el-text-color-primary);
}
</style>
@@ -0,0 +1,190 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useGlobalVariables } from '../../stores/useGlobalVariables'
const { variables, setVariable, deleteVariable, clear } = useGlobalVariables()
const showPanel = defineModel<boolean>('show', { default: false })
const newVarName = ref('')
const newVarValue = ref('')
function addVariable() {
const name = newVarName.value.trim()
if (!name) {
ElMessage.warning('请输入变量名')
return
}
if (!/^\w+$/.test(name)) {
ElMessage.warning('变量名只能包含字母、数字和下划线')
return
}
setVariable(name, newVarValue.value.trim())
newVarName.value = ''
newVarValue.value = ''
ElMessage.success(`已添加变量: {{${name}}}`)
}
function handleDelete(name: string) {
deleteVariable(name)
ElMessage.success(`已删除变量: {{${name}}}`)
}
function handleClear() {
clear()
ElMessage.success('已清空所有全局变量')
}
const entries = ref<{ name: string; value: string }[]>([])
// Refresh entries when panel opens
function refreshEntries() {
const vars = useGlobalVariables().getAllVariables()
entries.value = Object.entries(vars).map(([name, value]) => ({ name, value }))
}
</script>
<template>
<el-dialog
:model-value="showPanel"
title="全局变量管理"
width="500px"
@update:model-value="(val) => showPanel = val"
@open="refreshEntries"
@closed="newVarName = ''; newVarValue = ''"
>
<div class="global-var-mgr">
<!-- Add new variable -->
<div class="add-row">
<el-input
v-model="newVarName"
size="small"
placeholder="变量名"
style="width: 140px"
@keyup.enter="addVariable"
/>
<span class="equals-sign">=</span>
<el-input
v-model="newVarValue"
size="small"
placeholder="值"
style="flex: 1"
@keyup.enter="addVariable"
/>
<el-button size="small" type="primary" @click="addVariable">添加</el-button>
</div>
<div class="hint-text">
变量名只能包含字母、数字和下划线。在预设模板中使用 <code v-pre>{{变量名}}</code> 引用。
</div>
<!-- Variable list -->
<div v-if="entries.length === 0" class="empty-state">
暂无全局变量,在上方添加
</div>
<div v-else class="var-list">
<div
v-for="(entry, idx) in entries"
:key="entry.name"
class="var-row"
>
<span class="var-name">{{ entry.name }}</span>
<span class="var-eq">=</span>
<el-input
:model-value="entry.value"
size="small"
style="flex: 1"
@input="(val: string) => { setVariable(entry.name, val); entries[idx] = { ...entries[idx], value: val } }"
/>
<el-button
size="small"
text
type="danger"
@click="handleDelete(entry.name)"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
</el-button>
</div>
</div>
</div>
<template #footer>
<el-button @click="showPanel = false">关闭</el-button>
<el-button
v-if="entries.length > 0"
type="danger"
plain
@click="handleClear"
>
清空全部
</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.global-var-mgr {
display: flex;
flex-direction: column;
gap: 10px;
}
.add-row {
display: flex;
align-items: center;
gap: 6px;
}
.equals-sign {
font-family: monospace;
color: var(--el-text-color-secondary);
font-size: 14px;
}
.hint-text {
font-size: 12px;
color: var(--el-text-color-secondary);
line-height: 1.4;
}
.hint-text code {
background: var(--el-fill-color-light);
padding: 0 4px;
border-radius: 2px;
font-family: monospace;
}
.empty-state {
text-align: center;
padding: 24px;
color: var(--el-text-color-placeholder);
font-size: 13px;
}
.var-list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 300px;
overflow-y: auto;
}
.var-row {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 6px;
background: var(--el-fill-color-lighter);
border-radius: 6px;
}
.var-name {
font-family: monospace;
font-size: 13px;
font-weight: 600;
color: var(--el-color-primary);
min-width: 80px;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.var-eq {
font-family: monospace;
color: var(--el-text-color-secondary);
}
</style>
+227
View File
@@ -0,0 +1,227 @@
<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue'
import { useLogs } from '../../stores/useLogs'
import { mqttGetLogDir } from '../../stores/useMqttBridge'
import { ElMessage } from 'element-plus'
const { logs, clear } = useLogs()
const filterLevel = ref('')
const filterConnId = ref('')
const showPanel = defineModel<boolean>('show', { default: false })
const expandedDetails = ref(new Set<string>())
const logDirPath = ref('')
function toggleDetails(id: string) {
const s = new Set(expandedDetails.value)
if (s.has(id)) s.delete(id)
else s.add(id)
expandedDetails.value = s
}
const filteredLogs = computed(() => {
let result = logs.value
if (filterLevel.value) result = result.filter((l) => l.level === filterLevel.value)
if (filterConnId.value) result = result.filter((l) => l.connectionId === filterConnId.value)
return result
})
const connectionIds = computed(() => [...new Set(logs.value.map((l) => l.connectionId))])
const logContainer = ref<HTMLDivElement | null>(null)
const autoScroll = ref(true)
function onScroll() {
if (!logContainer.value) return
const el = logContainer.value
autoScroll.value = el.scrollHeight - el.scrollTop - el.clientHeight < 30
}
watch(filteredLogs, async () => {
if (autoScroll.value) {
await nextTick()
if (logContainer.value) logContainer.value.scrollTop = logContainer.value.scrollHeight
}
})
function formatTime(ts: number): string {
const d = new Date(ts)
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}.${String(d.getMilliseconds()).padStart(3, '0')}`
}
async function handleOpenLogDir() {
try {
if (!logDirPath.value) {
logDirPath.value = await mqttGetLogDir()
}
// Try to open the directory using the opener plugin
const { open } = await import('@tauri-apps/plugin-opener')
await open(logDirPath.value)
} catch (e: any) {
ElMessage.warning(`日志目录: ${logDirPath.value || '未知'}`)
}
}
function eventIcon(event: string): string {
switch (event) {
case 'connect': return '🔌'
case 'connack': return '✅'
case 'disconnect': case 'disconnect_sent': case 'disconnect_received': case 'disconnect_cancelled': return '🔌'
case 'event_loop_started': return '▶️'
case 'publish_sent': return '📤'
case 'publish_received': return '📥'
case 'publish_error': case 'subscribe_error': case 'unsubscribe_error': case 'connection_error': return '❌'
case 'subscribe': return '📋'
case 'suback': case 'unsuback': return '✅'
case 'unsubscribe': return '🗑️'
case 'puback': case 'pubrec': case 'pubcomp': return '📨'
case 'requests_done': return '🏁'
default: return '📝'
}
}
</script>
<template>
<div class="log-panel">
<div class="log-header">
<div class="log-title-row">
<span class="log-title">事件日志</span>
<span class="log-count">{{ filteredLogs.length }}</span>
</div>
<div class="log-tools">
<el-select v-model="filterLevel" size="small" placeholder="级别" style="width: 80px" clearable>
<el-option value="" label="全部" />
<el-option value="info" label="信息" />
<el-option value="warn" label="警告" />
<el-option value="error" label="错误" />
</el-select>
<el-select v-model="filterConnId" size="small" placeholder="连接" style="width: 100px" clearable>
<el-option value="" label="全部" />
<el-option v-for="cid in connectionIds" :key="cid" :value="cid" :label="cid.substring(0, 6) + '…'" />
</el-select>
<el-button size="small" class="log-action-btn" @click="handleOpenLogDir" title="打开日志文件夹">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
文件夹
</el-button>
<el-button size="small" class="log-action-btn" @click="clear()">清空</el-button>
<el-button size="small" class="log-action-btn" @click="showPanel = false">✕</el-button>
</div>
</div>
<div class="log-list" ref="logContainer" @scroll="onScroll">
<div v-if="filteredLogs.length === 0" class="log-empty">暂无日志</div>
<div
v-for="log in filteredLogs"
:key="log.id"
>
<div :class="['log-entry', `level-${log.level}`]">
<span class="log-time">{{ formatTime(log.timestamp) }}</span>
<span :class="['log-lvl', `lvl-${log.level}`]">{{ { info: 'INF', warn: 'WRN', error: 'ERR' }[log.level] }}</span>
<span class="log-ico">{{ eventIcon(log.event) }}</span>
<span class="log-msg">{{ log.message }}</span>
<span v-if="log.details" class="log-dtl-btn" @click="toggleDetails(log.id)">{{ expandedDetails.has(log.id) ? '收起' : '详情' }}</span>
</div>
<div v-if="log.details && expandedDetails.has(log.id)" class="log-dtl-content">
{{ log.details }}
</div>
</div>
</div>
</div>
</template>
<style scoped>
.log-panel {
border: 1px solid var(--el-border-color);
border-radius: 8px;
background: var(--el-bg-color);
display: flex;
flex-direction: column;
max-height: 220px;
font-size: 12px;
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
flex-shrink: 0;
}
.log-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 10px;
border-bottom: 1px solid var(--el-border-color-light);
flex-shrink: 0;
gap: 8px;
}
.log-title-row {
display: flex;
align-items: center;
gap: 6px;
}
.log-title {
font-size: 13px;
font-weight: 600;
white-space: nowrap;
}
.log-count {
font-size: 10px;
color: var(--el-text-color-secondary);
background: var(--el-fill-color);
padding: 0 5px;
border-radius: 6px;
line-height: 16px;
}
.log-tools {
display: flex;
align-items: center;
gap: 4px;
}
.log-action-btn {
--el-button-bg-color: transparent;
--el-button-border-color: var(--el-border-color);
--el-button-hover-bg-color: var(--el-fill-color);
font-size: 11px;
height: 24px;
padding: 0 6px !important;
border-radius: 4px;
display: inline-flex;
align-items: center;
gap: 3px;
}
.log-action-btn svg {
flex-shrink: 0;
}
.log-list {
flex: 1;
overflow-y: auto;
padding: 2px 0;
}
.log-empty {
padding: 24px;
text-align: center;
color: var(--el-text-color-placeholder);
}
.log-entry {
display: flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
transition: background 0.1s;
}
.log-entry:hover { background: var(--el-fill-color-light); }
.log-time { font-size: 11px; color: var(--el-text-color-secondary); white-space: nowrap; flex-shrink: 0; }
.log-lvl { font-size: 10px; font-weight: 700; padding: 0 3px; border-radius: 2px; flex-shrink: 0; }
.lvl-info { color: var(--el-color-primary); background: var(--el-color-primary-light-9); }
.lvl-warn { color: var(--el-color-warning); background: var(--el-color-warning-light-9); }
.lvl-err { color: var(--el-color-danger); background: var(--el-color-danger-light-9); }
.log-ico { font-size: 11px; flex-shrink: 0; }
.log-msg { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--el-text-color-regular); }
.log-dtl-btn { font-size: 11px; color: var(--el-color-primary); cursor: pointer; flex-shrink: 0; opacity: 0; }
.log-entry:hover .log-dtl-btn { opacity: 1; }
.log-dtl-content {
padding: 2px 8px 4px 20px;
font-size: 11px;
color: var(--el-text-color-secondary);
background: var(--el-fill-color-lighter);
word-break: break-all;
white-space: pre-wrap;
max-height: 80px;
overflow-y: auto;
}
</style>
+393
View File
@@ -0,0 +1,393 @@
<script setup lang="ts">
import { ref, watch, nextTick } from 'vue'
import type { MqttMessage } from '../../types/mqtt'
const props = defineProps<{
messages: MqttMessage[]
paused: boolean
}>()
const emit = defineEmits<{
'update:paused': [value: boolean]
}>()
const scrollRef = ref<HTMLDivElement | null>(null)
const autoScroll = ref(true)
const expandedPayloads = ref(new Set<string>())
const messageFormats = ref<Record<string, 'auto' | 'json' | 'hex' | 'base64' | 'plain'>>({})
const formatOptions: { value: string; label: string }[] = [
{ value: 'auto', label: '自动' },
{ value: 'json', label: 'JSON' },
{ value: 'hex', label: 'Hex' },
{ value: 'base64', label: 'Base64' },
{ value: 'plain', label: '文本' },
]
watch(
() => props.messages.length,
async () => {
if (autoScroll.value && !props.paused) {
await nextTick()
if (scrollRef.value) {
scrollRef.value.scrollTop = scrollRef.value.scrollHeight
}
}
},
)
function onScroll() {
if (!scrollRef.value) return
const el = scrollRef.value
const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 50
autoScroll.value = isAtBottom
if (!isAtBottom && !props.paused) {
emit('update:paused', true)
} else if (isAtBottom && props.paused) {
emit('update:paused', false)
}
}
// ─── Payload Format Helpers ────────────────────────────────────
function isJson(str: string): boolean {
try { JSON.parse(str); return true }
catch { return false }
}
function getEffectiveFormat(msg: MqttMessage): string {
const fmt = messageFormats.value[msg.id] || 'auto'
if (fmt === 'auto') return isJson(msg.payload) ? 'json' : 'plain'
return fmt
}
function formatPayload(msg: MqttMessage): string {
const fmt = getEffectiveFormat(msg)
switch (fmt) {
case 'json':
try {
const parsed = JSON.parse(msg.payload)
return JSON.stringify(parsed, null, 2)
} catch { return msg.payload }
case 'hex':
return bytesToHex(new TextEncoder().encode(msg.payload))
case 'base64':
return btoa(msg.payload)
case 'plain':
default:
return msg.payload
}
}
function bytesToHex(bytes: Uint8Array): string {
const parts: string[] = []
for (let i = 0; i < bytes.length; i++) {
if (i > 0 && i % 16 === 0) parts.push('\n')
else if (i > 0) parts.push(' ')
parts.push(bytes[i].toString(16).padStart(2, '0'))
}
return parts.join('')
}
function setFormat(msgId: string, fmt: 'auto' | 'json' | 'hex' | 'base64' | 'plain') {
messageFormats.value = { ...messageFormats.value, [msgId]: fmt }
}
function togglePayload(msgId: string) {
const s = new Set(expandedPayloads.value)
if (s.has(msgId)) s.delete(msgId)
else s.add(msgId)
expandedPayloads.value = s
}
// ─── Display helpers ───────────────────────────────────────────
function formatTime(ts: number): string {
const d = new Date(ts)
const pad = (n: number) => String(n).padStart(2, '0')
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${String(d.getMilliseconds()).padStart(3, '0')}`
}
</script>
<template>
<div class="message-stream" ref="scrollRef" @scroll="onScroll">
<div v-if="messages.length === 0" class="empty-hint">
<div class="empty-icon">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" opacity="0.3">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
</div>
<div class="empty-text">暂无消息</div>
<div class="empty-sub">连接后消息将自动出现在这里</div>
</div>
<div
v-for="msg in messages"
:key="msg.id"
:class="['msg-card', msg.direction === 'in' ? 'msg-in' : 'msg-out']"
>
<!-- Header -->
<div class="msg-header">
<span :class="['dir-badge', msg.direction]">
<svg v-if="msg.direction === 'in'" width="10" height="10" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2zm-1 14.5v-9l5 4.5z"/></svg>
<svg v-else width="10" height="10" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2zm1 14.5v-9l5 4.5z"/></svg>
{{ msg.direction === 'in' ? '收' : '发' }}
</span>
<span class="msg-time">{{ formatTime(msg.timestamp) }}</span>
<span class="msg-topic">{{ msg.topic }}</span>
<span class="qos-badge">Q{{ msg.qos }}</span>
<span v-if="msg.retain" class="retain-badge">R</span>
</div>
<!-- v5 Properties -->
<div v-if="msg.contentType || msg.reasonCode !== undefined || msg.messageExpiryInterval" class="v5-bar">
<span v-if="msg.contentType" class="v5-chip">CT: {{ msg.contentType }}</span>
<span v-if="msg.reasonCode !== undefined" class="v5-chip">RC: {{ msg.reasonCode }}</span>
<span v-if="msg.messageExpiryInterval" class="v5-chip">Exp: {{ msg.messageExpiryInterval }}s</span>
</div>
<div v-if="msg.userProperties && msg.userProperties.length > 0" class="v5-bar">
<span v-for="(up, i) in msg.userProperties.slice(0, 3)" :key="i" class="v5-chip">
{{ up.key }}: {{ up.value }}
</span>
<span v-if="msg.userProperties.length > 3" class="v5-chip more">+{{ msg.userProperties.length - 3 }}</span>
</div>
<!-- Format Tabs + Expand -->
<div class="payload-bar">
<div class="format-tabs">
<span
v-for="opt in formatOptions"
:key="opt.value"
:class="['fmt-tab', { active: (messageFormats[msg.id] || 'auto') === opt.value }]"
@click="setFormat(msg.id, opt.value as any)"
>{{ opt.label }}</span>
</div>
<span
class="expand-btn"
@click="togglePayload(msg.id)"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline v-if="expandedPayloads.has(msg.id)" points="18 15 12 9 6 15"/>
<polyline v-else points="6 9 12 15 18 9"/>
</svg>
{{ expandedPayloads.has(msg.id) ? '收起' : '展开' }}
</span>
</div>
<!-- Payload Content -->
<div class="msg-payload" :class="{ expanded: expandedPayloads.has(msg.id) }">
<pre
v-if="expandedPayloads.has(msg.id)"
:class="['payload-full', { 'is-json': getEffectiveFormat(msg) === 'json' }]"
>{{ formatPayload(msg) }}</pre>
<pre v-else class="payload-truncated">{{ msg.payload.substring(0, 200) }}{{ msg.payload.length > 200 ? '...' : '' }}</pre>
</div>
</div>
</div>
</template>
<style scoped>
.message-stream {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 6px;
padding: 4px;
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
font-size: 13px;
}
/* ─── Empty State ────────────────────────────────────────────── */
.empty-hint {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
color: var(--el-text-color-placeholder);
gap: 6px;
}
.empty-text {
font-size: 14px;
font-weight: 500;
}
.empty-sub {
font-size: 12px;
opacity: 0.7;
}
/* ─── Message Card ───────────────────────────────────────────── */
.msg-card {
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-light);
border-radius: 8px;
padding: 8px 10px;
transition: box-shadow 0.15s;
}
.msg-card:hover {
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
}
.msg-card.msg-in {
border-left: 3px solid var(--el-color-primary);
}
.msg-card.msg-out {
border-left: 3px solid var(--el-color-success);
}
/* ─── Header ─────────────────────────────────────────────────── */
.msg-header {
display: flex;
align-items: center;
gap: 6px;
}
.dir-badge {
display: inline-flex;
align-items: center;
gap: 2px;
font-size: 10px;
font-weight: 600;
padding: 1px 5px;
border-radius: 4px;
flex-shrink: 0;
}
.dir-badge.in {
background: var(--el-color-primary-light-9);
color: var(--el-color-primary);
}
.dir-badge.out {
background: var(--el-color-success-light-9);
color: var(--el-color-success);
}
.dir-badge svg {
flex-shrink: 0;
}
.msg-time {
font-size: 11px;
color: var(--el-text-color-secondary);
white-space: nowrap;
flex-shrink: 0;
}
.msg-topic {
font-weight: 500;
color: var(--el-text-color-primary);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
}
.qos-badge {
font-size: 10px;
color: var(--el-text-color-secondary);
background: var(--el-fill-color);
padding: 0 4px;
border-radius: 3px;
flex-shrink: 0;
}
.retain-badge {
font-size: 10px;
font-weight: 700;
color: var(--el-color-warning);
background: var(--el-color-warning-light-9);
padding: 0 4px;
border-radius: 3px;
flex-shrink: 0;
}
/* ─── v5 Properties ──────────────────────────────────────────── */
.v5-bar {
display: flex;
gap: 4px;
flex-wrap: wrap;
margin-top: 4px;
}
.v5-chip {
font-size: 10px;
color: var(--el-color-info);
background: var(--el-color-info-light-9);
padding: 0 5px;
border-radius: 3px;
line-height: 18px;
}
.v5-chip.more {
color: var(--el-text-color-secondary);
background: var(--el-fill-color);
}
/* ─── Payload Bar ────────────────────────────────────────────── */
.payload-bar {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 4px;
}
.format-tabs {
display: flex;
gap: 1px;
background: var(--el-fill-color);
border-radius: 4px;
padding: 1px;
}
.fmt-tab {
font-size: 10px;
padding: 1px 6px;
cursor: pointer;
border-radius: 3px;
color: var(--el-text-color-secondary);
transition: all 0.12s;
line-height: 18px;
}
.fmt-tab:hover {
color: var(--el-color-primary);
}
.fmt-tab.active {
color: var(--el-color-primary);
background: var(--el-bg-color);
font-weight: 600;
box-shadow: 0 1px 2px rgba(0,0,0,0.06);
}
.expand-btn {
display: inline-flex;
align-items: center;
gap: 2px;
font-size: 11px;
color: var(--el-color-primary);
cursor: pointer;
opacity: 0.6;
transition: opacity 0.12s;
}
.expand-btn:hover {
opacity: 1;
}
/* ─── Payload ────────────────────────────────────────────────── */
.msg-payload {
margin-top: 2px;
}
.msg-payload pre {
margin: 0;
white-space: pre-wrap;
word-break: break-all;
color: var(--el-text-color-regular);
}
.payload-full {
max-height: 300px;
overflow: auto;
background: var(--el-fill-color-lighter);
padding: 6px 8px;
border-radius: 6px;
font-size: 12px;
line-height: 1.5;
border: 1px solid var(--el-border-color-extra-light);
}
.payload-full.is-json {
color: var(--el-color-primary-dark-2);
}
.payload-truncated {
max-height: 1.4em;
overflow: hidden;
font-size: 12px;
color: var(--el-text-color-secondary);
}
</style>
+491
View File
@@ -0,0 +1,491 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import type { MqttConnection, Preset, PublishDto } from '../../types/mqtt'
import { extractVariables, resolveTemplate, resolveUserProperties } from '../../utils/envVarResolver'
import { mqttPublish } from '../../stores/useMqttBridge'
import { useGlobalVariables } from '../../stores/useGlobalVariables'
import { ElMessage } from 'element-plus'
import VariableEditor from './VariableEditor.vue'
import UserPropertiesEditor from './UserPropertiesEditor.vue'
import GlobalVariableManager from './GlobalVariableManager.vue'
const props = defineProps<{
connections: MqttConnection[]
presets: Preset[]
}>()
const emit = defineEmits<{
sent: [message: PublishDto]
}>()
const { variables: globalVars, setVariable: setGlobalVar, importFromBindings } = useGlobalVariables()
// ─── State ─────────────────────────────────────────────────────
const selectedConnectionId = ref('')
const selectedPresetId = ref('')
const topic = ref('')
const payload = ref('')
const qos = ref<0 | 1 | 2>(0)
const retain = ref(false)
const showVariables = ref(false)
const showV5Props = ref(false)
const variableBindings = ref<Record<string, string>>({})
const userProperties = ref<{ key: string; value: string }[]>([])
const contentType = ref('')
const messageExpiryInterval = ref<number | null>(null)
const responseTopic = ref('')
const correlationData = ref('')
const sending = ref(false)
const showGlobalVarMgr = ref(false)
// ─── Computed ──────────────────────────────────────────────────
const connectedConnections = computed(() =>
props.connections.filter((c) => c.status === 'connected'),
)
const selectedPreset = computed(() =>
props.presets.find((p) => p.id === selectedPresetId.value),
)
const presetVariables = computed(() => {
if (!selectedPreset.value) return []
const vars = extractVariables(selectedPreset.value.payloadTemplate)
for (const v of extractVariables(selectedPreset.value.topic)) {
if (!vars.includes(v)) vars.push(v)
}
for (const up of selectedPreset.value.userProperties) {
for (const v of extractVariables(up.key)) if (!vars.includes(v)) vars.push(v)
for (const v of extractVariables(up.value)) if (!vars.includes(v)) vars.push(v)
}
return vars
})
// ─── Watch preset ──────────────────────────────────────────────
watch(selectedPreset, (preset) => {
if (preset) {
topic.value = preset.topic
payload.value = preset.payloadTemplate
qos.value = preset.qos
retain.value = preset.retain
userProperties.value = JSON.parse(JSON.stringify(preset.userProperties))
const newBindings: Record<string, string> = {}
const presetVars = extractVariables(preset.payloadTemplate)
const topicVars = extractVariables(preset.topic)
const allVars = [...new Set([...presetVars, ...topicVars])]
for (const v of allVars) {
// Auto-fill from global variables first, then keep existing
newBindings[v] = globalVars.value[v] ?? variableBindings.value[v] ?? ''
}
variableBindings.value = newBindings
showVariables.value = allVars.length > 0
}
})
// ─── Send ──────────────────────────────────────────────────────
async function send() {
if (!selectedConnectionId.value) {
ElMessage.warning('请先选择一个连接')
return
}
if (!topic.value.trim()) {
ElMessage.warning('请输入主题')
return
}
if (!payload.value.trim()) {
ElMessage.warning('请输入消息内容')
return
}
const resolvedTopic = resolveTemplate(topic.value, variableBindings.value)
const resolvedPayload = resolveTemplate(payload.value, variableBindings.value)
const resolvedProps = resolveUserProperties(userProperties.value, variableBindings.value)
// Save current bindings to global variables
importFromBindings(variableBindings.value)
const publishDto: PublishDto = {
connection_id: selectedConnectionId.value,
topic: resolvedTopic,
payload: resolvedPayload,
qos: qos.value,
retain: retain.value,
user_properties: resolvedProps,
content_type: contentType.value || null,
message_expiry_interval: messageExpiryInterval.value,
response_topic: responseTopic.value || null,
correlation_data: correlationData.value || null,
}
sending.value = true
try {
await mqttPublish(publishDto)
ElMessage.success('消息已发送')
emit('sent', publishDto)
} catch (e: any) {
ElMessage.error(`发送失败: ${e}`)
} finally {
sending.value = false
}
}
// ─── Save as global variable when a binding value changes ──────
function onBindingChange(name: string, value: string) {
variableBindings.value = { ...variableBindings.value, [name]: value }
// Auto-save to global if value is non-empty
if (value.trim()) {
setGlobalVar(name, value)
}
}
</script>
<template>
<div class="send-panel">
<div class="panel-header">
<span class="panel-title">发送消息</span>
<div class="panel-actions">
<el-select
v-model="selectedPresetId"
placeholder="选择预设..."
size="small"
style="width: 160px"
clearable
>
<el-option
v-for="p in presets"
:key="p.id"
:label="p.name"
:value="p.id"
/>
</el-select>
</div>
</div>
<div class="panel-body">
<!-- Row 1: Connection + Topic -->
<div class="form-row">
<div class="field" style="flex: 0 0 160px">
<label>连接</label>
<el-select
v-model="selectedConnectionId"
placeholder="选择连接"
size="small"
style="width: 100%"
>
<el-option
v-for="c in connectedConnections"
:key="c.id"
:label="c.name"
:value="c.id"
/>
</el-select>
</div>
<div class="field" style="flex: 1">
<label>主题</label>
<el-input
v-model="topic"
size="small"
placeholder="sensor/temp"
/>
</div>
<div class="field" style="flex: 0 0 90px">
<label>QoS</label>
<el-select v-model="qos" size="small">
<el-option :value="0" label="QoS 0" />
<el-option :value="1" label="QoS 1" />
<el-option :value="2" label="QoS 2" />
</el-select>
</div>
<div class="field" style="flex: 0 0 70px; align-items: center; justify-content: flex-end; padding-bottom: 0;">
<el-checkbox v-model="retain" size="small" label="Retain" />
</div>
</div>
<!-- Row 2: Payload -->
<div class="form-row">
<div class="field" style="flex: 1">
<label>Payload</label>
<el-input
v-model="payload"
type="textarea"
:rows="2"
placeholder='{"temp":{{temp}},"device":"{{deviceId}}"}'
/>
</div>
</div>
<!-- Toggle buttons -->
<div class="toggle-row">
<el-button
size="small"
:class="['toggle-btn', { active: showVariables }]"
@click="showVariables = !showVariables"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 7V4h16v3"/><path d="M9 20h6"/><path d="M12 4v16"/></svg>
变量
<span v-if="presetVariables.length" class="toggle-badge">{{ presetVariables.length }}</span>
</el-button>
<el-button
size="small"
:class="['toggle-btn', { active: showV5Props }]"
@click="showV5Props = !showV5Props"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
v5 属性
</el-button>
<el-button
size="small"
class="toggle-btn"
@click="showGlobalVarMgr = true"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>
全局变量
</el-button>
<div class="toggle-spacer" />
<el-button
type="primary"
size="small"
:loading="sending"
:disabled="!selectedConnectionId || !topic.trim()"
@click="send"
class="send-btn"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
发送
</el-button>
</div>
<!-- Variable editor (now uses onBindingChange to auto-save) -->
<transition name="slide">
<div v-if="showVariables" class="expand-section">
<div class="var-editor-header">
<span class="var-editor-title">变量值</span>
<span class="var-editor-hint">输入后自动保存为全局变量</span>
</div>
<div class="var-grid">
<div
v-for="v in presetVariables"
:key="v"
class="var-item"
>
<span class="var-label">{{ v }}</span>
<el-input
:model-value="variableBindings[v] ?? ''"
size="small"
placeholder="输入值..."
@input="(val: string) => onBindingChange(v, val)"
/>
</div>
</div>
</div>
</transition>
<!-- v5 Props -->
<transition name="slide">
<div v-if="showV5Props" class="expand-section">
<div class="v5-grid">
<div class="field">
<label>Content Type</label>
<el-input v-model="contentType" size="small" placeholder="json" />
</div>
<div class="field">
<label>消息过期(秒)</label>
<el-input-number
v-model="messageExpiryInterval"
:min="0"
size="small"
style="width: 100%"
/>
</div>
<div class="field">
<label>Response Topic</label>
<el-input v-model="responseTopic" size="small" placeholder="sensor/response" />
</div>
<div class="field">
<label>Correlation Data</label>
<el-input v-model="correlationData" size="small" placeholder="hex string" />
</div>
</div>
<UserPropertiesEditor v-model="userProperties" />
</div>
</transition>
</div>
<!-- Global Variable Manager Dialog -->
<GlobalVariableManager v-model:show="showGlobalVarMgr" />
</div>
</template>
<style scoped>
.send-panel {
border: 1px solid var(--el-border-color);
border-radius: 8px;
background: var(--el-bg-color);
flex-shrink: 0;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-bottom: 1px solid var(--el-border-color-light);
}
.panel-title {
font-size: 13px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.panel-actions {
display: flex;
align-items: center;
gap: 6px;
}
.panel-body {
padding: 8px 12px 10px;
display: flex;
flex-direction: column;
gap: 6px;
}
/* ─── Form Row ───────────────────────────────────────────────── */
.form-row {
display: flex;
gap: 8px;
align-items: flex-start;
}
.field {
display: flex;
flex-direction: column;
gap: 2px;
}
.field label {
font-size: 11px;
color: var(--el-text-color-secondary);
font-weight: 500;
}
/* ─── Toggle Row ─────────────────────────────────────────────── */
.toggle-row {
display: flex;
align-items: center;
gap: 4px;
}
.toggle-btn {
--el-button-bg-color: transparent;
--el-button-border-color: var(--el-border-color);
--el-button-hover-bg-color: var(--el-fill-color);
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--el-text-color-secondary);
padding: 0 8px !important;
height: 26px;
border-radius: 6px;
}
.toggle-btn.active {
--el-button-bg-color: var(--el-color-primary-light-9);
--el-button-border-color: var(--el-color-primary-light-5);
color: var(--el-color-primary);
}
.toggle-btn svg {
flex-shrink: 0;
}
.toggle-badge {
font-size: 10px;
background: var(--el-color-primary);
color: #fff;
padding: 0 4px;
border-radius: 6px;
line-height: 14px;
min-width: 14px;
text-align: center;
}
.toggle-spacer {
flex: 1;
}
.send-btn {
display: inline-flex;
align-items: center;
gap: 4px;
height: 28px;
padding: 0 14px !important;
border-radius: 6px;
}
.send-btn svg {
flex-shrink: 0;
}
/* ─── Variable Editor (inline) ───────────────────────────────── */
.expand-section {
padding: 8px;
background: var(--el-fill-color-light);
border-radius: 6px;
border: 1px solid var(--el-border-color-extra-light);
}
.var-editor-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.var-editor-title {
font-size: 12px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.var-editor-hint {
font-size: 11px;
color: var(--el-text-color-placeholder);
}
.var-grid {
display: flex;
flex-direction: column;
gap: 4px;
}
.var-item {
display: flex;
align-items: center;
gap: 8px;
}
.var-label {
min-width: 80px;
font-family: monospace;
font-size: 13px;
font-weight: 600;
color: var(--el-color-primary);
}
/* ─── v5 Props ───────────────────────────────────────────────── */
.v5-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
margin-bottom: 6px;
}
/* ─── Transition ─────────────────────────────────────────────── */
.slide-enter-active,
.slide-leave-active {
transition: all 0.2s ease;
overflow: hidden;
}
.slide-enter-from,
.slide-leave-to {
opacity: 0;
max-height: 0;
padding-top: 0;
padding-bottom: 0;
margin-bottom: 0;
border-width: 0;
}
.slide-enter-to,
.slide-leave-from {
opacity: 1;
max-height: 500px;
}
</style>
+312
View File
@@ -0,0 +1,312 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { MqttMessage } from '../../types/mqtt'
import type { Subscription } from '../../types/mqtt'
import { useSubscriptions } from '../../stores/useSubscriptions'
import { mqttSubscribe, mqttUnsubscribe } from '../../stores/useMqttBridge'
interface TopicNode {
id: string
label: string
fullPath: string
count: number
isSubscribed: boolean
subscriptionQos: number
children: TopicNode[]
}
const props = defineProps<{
messages: MqttMessage[]
selectedTopic: string
connectionId: string
connected: boolean
}>()
const emit = defineEmits<{
'update:selectedTopic': [value: string]
'select': [topic: string]
}>()
const { addSubscription, removeSubscription, getSubscriptionsByConnection } = useSubscriptions()
const subscribeInput = ref('')
const subscribeQos = ref<0 | 1 | 2>(0)
const subscribing = ref(false)
// Build tree from both subscriptions and messages
const treeData = computed(() => {
const root: TopicNode[] = []
const nodeMap = new Map<string, TopicNode>()
function getOrCreateNode(path: string, label: string): TopicNode {
let node = nodeMap.get(path)
if (!node) {
node = {
id: path,
label,
fullPath: path,
count: 0,
isSubscribed: false,
subscriptionQos: 0,
children: [],
}
nodeMap.set(path, node)
}
return node
}
function addToTree(fullPath: string) {
const segments = fullPath.split('/')
let currentPath = ''
let parentList = root
for (let i = 0; i < segments.length; i++) {
const seg = segments[i]
currentPath = currentPath ? `${currentPath}/${seg}` : seg
let node = parentList.find((n) => n.id === currentPath)
if (!node) {
node = getOrCreateNode(currentPath, seg)
parentList.push(node)
}
parentList = node.children
}
}
const subs = getSubscriptionsByConnection(props.connectionId)
for (const sub of subs) {
addToTree(sub.topic)
const node = nodeMap.get(sub.topic)
if (node) { node.isSubscribed = true; node.subscriptionQos = sub.qos }
}
for (const msg of props.messages) {
addToTree(msg.topic)
}
for (const msg of props.messages) {
const node = nodeMap.get(msg.topic)
if (node) node.count++
}
function sortTree(nodes: TopicNode[]) {
nodes.sort((a, b) => {
const aIsFolder = a.children.length > 0
const bIsFolder = b.children.length > 0
if (aIsFolder !== bIsFolder) return aIsFolder ? -1 : 1
return a.label.localeCompare(b.label)
})
for (const n of nodes) sortTree(n.children)
}
sortTree(root)
return root
})
function handleNodeClick(node: TopicNode) {
if (props.selectedTopic === node.fullPath) {
emit('update:selectedTopic', '')
emit('select', '')
} else {
emit('update:selectedTopic', node.fullPath)
emit('select', node.fullPath)
}
}
async function handleSubscribe() {
const topic = subscribeInput.value.trim()
if (!topic) { ElMessage.warning('请输入订阅主题'); return }
if (!props.connectionId) { ElMessage.warning('请先选择一个连接'); return }
subscribing.value = true
try {
await mqttSubscribe({ connection_id: props.connectionId, topic, qos: subscribeQos.value })
addSubscription(props.connectionId, topic, subscribeQos.value)
ElMessage.success(`已订阅: ${topic}`)
subscribeInput.value = ''
} catch (e: unknown) {
ElMessage.error(`订阅失败: ${e instanceof Error ? e.message : String(e)}`)
} finally {
subscribing.value = false
}
}
async function handleUnsubscribe(topic: string) {
if (!props.connectionId) return
try {
await ElMessageBox.confirm(`确定取消订阅 "${topic}"?`, '取消订阅', {
type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消',
})
await mqttUnsubscribe(props.connectionId, topic)
removeSubscription(props.connectionId, topic)
ElMessage.success(`已取消订阅: ${topic}`)
} catch { /* cancelled */ }
}
function getNodeIcon(node: TopicNode): string {
if (node.isSubscribed) return '✅'
if (node.children.length > 0) return '📁'
return '📄'
}
function getNodeClass(node: TopicNode): string {
if (node.fullPath === props.selectedTopic) return 'selected'
if (node.isSubscribed) return 'subscribed'
return ''
}
</script>
<template>
<div class="topic-tree">
<div class="tree-header">
<span class="tree-title">主题树</span>
<span class="tree-count">{{ treeData.length }}</span>
</div>
<div class="subscribe-row">
<el-input
v-model="subscribeInput"
size="small"
:placeholder="connectionId ? (connected ? '输入主题...' : '连接未就绪') : '先选择连接'"
:disabled="!connectionId || !connected"
@keyup.enter="handleSubscribe"
>
<template #prefix>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="color: var(--el-text-color-placeholder)"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
</template>
<template #append>
<el-select v-model="subscribeQos" size="small" style="width: 62px" :disabled="!connectionId || !connected">
<el-option :value="0" label="Q0" />
<el-option :value="1" label="Q1" />
<el-option :value="2" label="Q2" />
</el-select>
</template>
</el-input>
<el-button
size="small"
type="primary"
:loading="subscribing"
:disabled="!connectionId || !connected || !subscribeInput.trim()"
@click="handleSubscribe"
class="sub-btn"
>+</el-button>
</div>
<div v-if="treeData.length === 0" class="empty-state">
<div class="empty-text">暂无主题</div>
<div class="empty-sub">输入上方搜索框订阅</div>
</div>
<div class="tree-list">
<template v-for="node in treeData" :key="node.id">
<div :class="['tree-node', getNodeClass(node)]" @click="handleNodeClick(node)">
<span class="node-icon">{{ getNodeIcon(node) }}</span>
<span class="node-label">{{ node.label }}</span>
<span class="node-count">{{ node.count }}</span>
<span v-if="node.isSubscribed" class="node-unsub" title="取消订阅" @click.stop="handleUnsubscribe(node.fullPath)">✕</span>
</div>
<div v-if="node.children.length > 0" class="children-group">
<template v-for="child in node.children" :key="child.id">
<div :class="['tree-node child', getNodeClass(child)]" @click="handleNodeClick(child)">
<span class="node-icon">{{ getNodeIcon(child) }}</span>
<span class="node-label">{{ child.label }}</span>
<span class="node-count">{{ child.count }}</span>
<span v-if="child.isSubscribed" class="node-unsub" @click.stop="handleUnsubscribe(child.fullPath)">✕</span>
</div>
<div v-if="child.children.length > 0" class="children-group">
<div
v-for="gc in child.children"
:key="gc.id"
:class="['tree-node child', { selected: gc.fullPath === selectedTopic, subscribed: gc.isSubscribed }]"
@click="handleNodeClick(gc)"
>
<span class="node-icon">{{ getNodeIcon(gc) }}</span>
<span class="node-label">{{ gc.label }}</span>
<span class="node-count">{{ gc.count }}</span>
<span v-if="gc.isSubscribed" class="node-unsub" @click.stop="handleUnsubscribe(gc.fullPath)">✕</span>
</div>
</div>
</template>
</div>
</template>
</div>
</div>
</template>
<style scoped>
.topic-tree {
width: 240px;
min-width: 200px;
border: 1px solid var(--el-border-color);
border-radius: 8px;
background: var(--el-bg-color);
display: flex;
flex-direction: column;
font-size: 13px;
overflow: hidden;
}
.tree-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 10px;
border-bottom: 1px solid var(--el-border-color-light);
flex-shrink: 0;
}
.tree-title {
font-size: 13px;
font-weight: 600;
}
.tree-count {
font-size: 10px;
color: var(--el-text-color-secondary);
background: var(--el-fill-color);
padding: 0 6px;
border-radius: 6px;
line-height: 18px;
}
.subscribe-row {
display: flex;
gap: 4px;
padding: 6px 8px;
border-bottom: 1px solid var(--el-border-color-light);
flex-shrink: 0;
}
.sub-btn {
flex-shrink: 0;
width: 28px;
padding: 0 !important;
font-size: 16px;
font-weight: 700;
border-radius: 6px;
}
.empty-state {
padding: 24px 16px;
text-align: center;
color: var(--el-text-color-placeholder);
}
.empty-text { font-size: 13px; font-weight: 500; }
.empty-sub { font-size: 11px; margin-top: 2px; opacity: 0.7; }
.tree-list {
flex: 1;
overflow-y: auto;
padding: 2px 0;
}
.tree-node {
display: flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
cursor: pointer;
border-radius: 0;
transition: background 0.1s;
user-select: none;
margin: 0 2px;
border-radius: 4px;
}
.tree-node:hover { background: var(--el-fill-color-light); }
.tree-node.selected { background: var(--el-color-primary-light-9) !important; color: var(--el-color-primary); font-weight: 600; }
.tree-node.subscribed { color: var(--el-color-success); }
.tree-node.child { padding-left: 20px; }
.node-icon { font-size: 12px; flex-shrink: 0; }
.node-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.node-count { font-size: 11px; color: var(--el-text-color-secondary); background: var(--el-fill-color); padding: 0 5px; border-radius: 6px; min-width: 16px; text-align: center; flex-shrink: 0; line-height: 16px; }
.node-unsub { font-size: 10px; color: var(--el-color-danger); cursor: pointer; padding: 0 2px; opacity: 0; transition: opacity 0.12s; flex-shrink: 0; }
.tree-node:hover .node-unsub { opacity: 1; }
.children-group { margin-left: 8px; border-left: 1px solid var(--el-border-color-extra-light); }
</style>
@@ -0,0 +1,86 @@
<script setup lang="ts">
const props = defineProps<{
modelValue: { key: string; value: string }[]
}>()
const emit = defineEmits<{
'update:modelValue': [value: { key: string; value: string }[]]
}>()
function addRow() {
emit('update:modelValue', [...props.modelValue, { key: '', value: '' }])
}
function removeRow(index: number) {
const arr = [...props.modelValue]
arr.splice(index, 1)
emit('update:modelValue', arr)
}
function updateRow(index: number, field: 'key' | 'value', val: string) {
const arr = [...props.modelValue]
arr[index] = { ...arr[index], [field]: val }
emit('update:modelValue', arr)
}
</script>
<template>
<div class="user-props-editor">
<div class="editor-header">
<span class="editor-title">User Properties</span>
<el-button size="small" type="primary" link @click="addRow">
+ 添加
</el-button>
</div>
<div
v-for="(prop, index) in modelValue"
:key="index"
class="prop-row"
>
<el-input
:model-value="prop.key"
size="small"
placeholder="Key"
style="flex: 1"
@input="(val: string) => updateRow(index, 'key', val)"
/>
<el-input
:model-value="prop.value"
size="small"
placeholder="Value"
style="flex: 1"
@input="(val: string) => updateRow(index, 'value', val)"
/>
<el-button
size="small"
type="danger"
link
@click="removeRow(index)"
>
删除
</el-button>
</div>
</div>
</template>
<style scoped>
.user-props-editor {
margin-top: 8px;
}
.editor-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 4px;
}
.editor-title {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.prop-row {
display: flex;
gap: 4px;
margin-bottom: 4px;
align-items: center;
}
</style>
@@ -0,0 +1,75 @@
<script setup lang="ts">
import { computed } from 'vue'
import { extractVariables } from '../../utils/envVarResolver'
const props = defineProps<{
payloadTemplate: string
modelValue: Record<string, string>
}>()
const emit = defineEmits<{
'update:modelValue': [value: Record<string, string>]
}>()
const variables = computed(() => {
return extractVariables(props.payloadTemplate)
})
function updateValue(name: string, value: string) {
emit('update:modelValue', { ...props.modelValue, [name]: value })
}
function formatVariableName(name: string): string {
return '\u007B\u007B' + name + '\u007D\u007D'
}
</script>
<template>
<div v-if="variables.length > 0" class="variable-editor">
<div class="variable-editor-title">变量</div>
<div class="variable-grid">
<div
v-for="v in variables"
:key="v"
class="variable-item"
>
<span class="variable-label">{{ formatVariableName(v) }}</span>
<el-input
:model-value="modelValue[v] ?? ''"
size="small"
placeholder="输入值..."
@input="(val: string) => updateValue(v, val)"
/>
</div>
</div>
</div>
</template>
<style scoped>
.variable-editor {
margin-top: 8px;
padding: 8px;
background: var(--el-fill-color-light);
border-radius: 4px;
}
.variable-editor-title {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-bottom: 6px;
}
.variable-grid {
display: flex;
flex-direction: column;
gap: 4px;
}
.variable-item {
display: flex;
align-items: center;
gap: 8px;
}
.variable-label {
min-width: 80px;
font-family: monospace;
font-size: 13px;
color: var(--el-color-primary);
}
</style>
+127
View File
@@ -0,0 +1,127 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import type { Preset } from '../../types/mqtt'
import { defaultPreset } from '../../types/mqtt'
import UserPropertiesEditor from '../dashboard/UserPropertiesEditor.vue'
const props = defineProps<{
modelValue: boolean
preset: Preset | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
save: [data: Preset]
}>()
const form = ref<Preset>(defaultPreset())
watch(
() => props.preset,
(preset) => {
if (preset) {
form.value = { ...preset, userProperties: JSON.parse(JSON.stringify(preset.userProperties)) }
} else {
form.value = defaultPreset()
}
},
{ immediate: true },
)
function handleSave() {
if (!form.value.name.trim()) {
ElMessage.warning('请输入预设名称')
return
}
if (!form.value.topic.trim()) {
ElMessage.warning('请输入主题')
return
}
emit('save', { ...form.value })
emit('update:modelValue', false)
}
function handleCancel() {
emit('update:modelValue', false)
}
</script>
<template>
<el-dialog
:model-value="modelValue"
:title="preset ? '编辑预设' : '新增预设'"
width="550px"
@update:model-value="(val: boolean) => emit('update:modelValue', val)"
>
<el-form
:model="form"
label-position="top"
size="small"
>
<el-form-item label="名称">
<el-input v-model="form.name" placeholder="温度上报" />
</el-form-item>
<el-form-item label="主题">
<el-input v-model="form.topic" placeholder="sensor/{{deviceId}}/temp" />
<div class="form-hint">
支持 <code v-pre>{{变量名}}</code> 占位符,发送时自动替换
</div>
</el-form-item>
<el-form-item label="Payload 模板">
<el-input
v-model="form.payloadTemplate"
type="textarea"
:rows="4"
placeholder='{"temp":{{temp}},"device":"{{deviceId}}"}'
/>
<div class="form-hint">
使用 <code v-pre>{{变量名}}</code> 作为占位符,发送时自动替换
</div>
</el-form-item>
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="QoS">
<el-select v-model="form.qos">
<el-option :value="0" label="QoS 0 (至多一次)" />
<el-option :value="1" label="QoS 1 (至少一次)" />
<el-option :value="2" label="QoS 2 (正好一次)" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12" style="padding-top: 24px">
<el-form-item>
<el-checkbox v-model="form.retain">Retain</el-checkbox>
</el-form-item>
</el-col>
</el-row>
<el-divider border-style="dashed" />
<div class="section-title">MQTT v5 User Properties (模板)</div>
<UserPropertiesEditor v-model="form.userProperties" />
</el-form>
<template #footer>
<el-button @click="handleCancel">取消</el-button>
<el-button type="primary" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.form-hint {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-top: 4px;
}
.form-hint code {
background: var(--el-fill-color-light);
padding: 0 4px;
border-radius: 2px;
font-family: monospace;
}
.section-title {
font-size: 13px;
font-weight: 600;
color: var(--el-text-color-primary);
margin-bottom: 4px;
}
</style>
+126
View File
@@ -0,0 +1,126 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { Preset } from '../../types/mqtt'
import { usePresets } from '../../stores/usePresets'
import PresetForm from './PresetForm.vue'
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _PresetForm = PresetForm
const { presets, add, update, remove } = usePresets()
const showForm = ref(false)
const editingPreset = ref<Preset | null>(null)
function handleAdd() {
editingPreset.value = null
showForm.value = true
}
function handleEdit(preset: Preset) {
editingPreset.value = preset
showForm.value = true
}
function handleSave(data: Preset) {
if (editingPreset.value) {
update(editingPreset.value.id, data)
ElMessage.success('预设已更新')
} else {
const newPreset = { ...data, id: crypto.randomUUID() }
add()
const idx = presets.value.length - 1
presets.value[idx] = { ...presets.value[idx], ...newPreset }
ElMessage.success('预设已创建')
}
showForm.value = false
}
async function handleDelete(preset: Preset) {
try {
await ElMessageBox.confirm(`确定删除预设 "${preset.name}"?`, '确认', {
type: 'warning',
})
remove(preset.id)
ElMessage.success('已删除')
} catch {
// cancelled
}
}
const qosLabel = (qos: number) => `QoS ${qos}`
</script>
<template>
<div class="preset-list">
<div class="list-header">
<h3>预设管理</h3>
<el-button type="primary" size="small" @click="handleAdd">
+ 新增预设
</el-button>
</div>
<el-table :data="presets" style="width: 100%" stripe>
<el-table-column prop="name" label="名称" min-width="120" />
<el-table-column prop="topic" label="主题" min-width="200" />
<el-table-column label="QoS" width="80">
<template #default="{ row }">
<el-tag size="small">{{ qosLabel(row.qos) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="Retain" width="70">
<template #default="{ row }">
<el-tag v-if="row.retain" size="small" type="warning">R</el-tag>
</template>
</el-table-column>
<el-table-column label="Payload 预览" min-width="200">
<template #default="{ row }">
<span class="payload-preview">{{ row.payloadTemplate.substring(0, 50) }}{{ row.payloadTemplate.length > 50 ? '...' : '' }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="handleEdit(row)">编辑</el-button>
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="presets.length === 0" class="empty-hint">
暂无预设,点击上方按钮创建
</div>
<PresetForm
v-model="showForm"
:preset="editingPreset"
@save="handleSave"
/>
</div>
</template>
<style scoped>
.preset-list {
height: 100%;
}
.list-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.list-header h3 {
margin: 0;
font-size: 16px;
}
.empty-hint {
text-align: center;
padding: 40px;
color: var(--el-text-color-placeholder);
}
.payload-preview {
font-family: monospace;
font-size: 12px;
color: var(--el-text-color-secondary);
}
</style>
+8
View File
@@ -0,0 +1,8 @@
import { createApp } from "vue";
import ElementPlus from "element-plus";
import "element-plus/dist/index.css";
import App from "./App.vue";
const app = createApp(App);
app.use(ElementPlus);
app.mount("#app");
+94
View File
@@ -0,0 +1,94 @@
import { ref, watch } from 'vue'
import type { MqttConnection } from '../types/mqtt'
import { defaultConnection } from '../types/mqtt'
const STORAGE_KEY = 'mqttx_connections'
function loadConnections(): MqttConnection[] {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return []
const parsed = JSON.parse(raw) as MqttConnection[]
// Ensure each connection has runtime status fields
return parsed.map((c) => ({
...c,
status: 'disconnected' as const,
lastError: '',
}))
} catch {
return []
}
}
function saveConnections(connections: MqttConnection[]) {
try {
// Strip runtime fields before persisting
const toSave = connections.map(({ status: _, lastError: __, ...rest }) => rest)
localStorage.setItem(STORAGE_KEY, JSON.stringify(toSave))
} catch {
// localStorage full or unavailable — silently ignore
}
}
const connections = ref<MqttConnection[]>(loadConnections())
// Auto-persist on changes
watch(
connections,
(val) => {
saveConnections(val)
},
{ deep: true },
)
export function useConnections() {
function add() {
const conn = defaultConnection()
connections.value.push(conn)
return conn
}
function update(id: string, data: Partial<MqttConnection>) {
const idx = connections.value.findIndex((c) => c.id === id)
if (idx !== -1) {
connections.value[idx] = { ...connections.value[idx], ...data }
}
}
function remove(id: string) {
const idx = connections.value.findIndex((c) => c.id === id)
if (idx !== -1) {
connections.value.splice(idx, 1)
}
}
function getById(id: string): MqttConnection | undefined {
return connections.value.find((c) => c.id === id)
}
function updateStatus(
id: string,
status: MqttConnection['status'],
lastError = '',
) {
const conn = getById(id)
if (conn) {
conn.status = status
conn.lastError = lastError
}
}
function getConnected(): MqttConnection[] {
return connections.value.filter((c) => c.status === 'connected')
}
return {
connections,
add,
update,
remove,
getById,
updateStatus,
getConnected,
}
}
+79
View File
@@ -0,0 +1,79 @@
import { ref, watch } from 'vue'
const STORAGE_KEY = 'mqttx_global_variables'
function loadVariables(): Record<string, string> {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return {}
return JSON.parse(raw) as Record<string, string>
} catch {
return {}
}
}
function saveVariables(vars: Record<string, string>) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(vars))
} catch {
// silently ignore
}
}
const variables = ref<Record<string, string>>(loadVariables())
watch(
variables,
(val) => {
saveVariables(val)
},
{ deep: true },
)
export function useGlobalVariables() {
function setVariable(name: string, value: string) {
if (value.trim() === '') {
delete variables.value[name]
} else {
variables.value = { ...variables.value, [name]: value }
}
}
function deleteVariable(name: string) {
const newVars = { ...variables.value }
delete newVars[name]
variables.value = newVars
}
function getVariable(name: string): string {
return variables.value[name] ?? ''
}
function clear() {
variables.value = {}
}
function importFromBindings(bindings: Record<string, string>) {
const newVars = { ...variables.value }
for (const [key, value] of Object.entries(bindings)) {
if (value.trim()) {
newVars[key] = value
}
}
variables.value = newVars
}
function getAllVariables(): Record<string, string> {
return { ...variables.value }
}
return {
variables,
setVariable,
deleteVariable,
getVariable,
clear,
importFromBindings,
getAllVariables,
}
}
+46
View File
@@ -0,0 +1,46 @@
import { ref } from 'vue'
export interface LogEntry {
id: string
timestamp: number
connectionId: string
level: 'info' | 'warn' | 'error'
event: string
message: string
details?: string
}
const MAX_LOGS = 1000
const logs = ref<LogEntry[]>([])
export function useLogs() {
function push(entry: LogEntry) {
logs.value.push(entry)
if (logs.value.length > MAX_LOGS) {
logs.value.splice(0, logs.value.length - MAX_LOGS)
}
}
function clear() {
logs.value = []
}
function filterByConnection(connectionId: string): LogEntry[] {
if (!connectionId) return logs.value
return logs.value.filter((l) => l.connectionId === connectionId)
}
function filterByLevel(level: string): LogEntry[] {
if (!level) return logs.value
return logs.value.filter((l) => l.level === level)
}
return {
logs,
push,
clear,
filterByConnection,
filterByLevel,
}
}
+41
View File
@@ -0,0 +1,41 @@
import { ref, computed } from 'vue'
import type { MqttMessage } from '../types/mqtt'
const MAX_MESSAGES = 500
const messages = ref<MqttMessage[]>([])
export function useMessages() {
function push(msg: MqttMessage) {
messages.value.push(msg)
if (messages.value.length > MAX_MESSAGES) {
messages.value.splice(0, messages.value.length - MAX_MESSAGES)
}
}
function clear() {
messages.value = []
}
function filterByTopic(topicFilter: string): MqttMessage[] {
if (!topicFilter.trim()) return messages.value
const filter = topicFilter.trim().toLowerCase()
return messages.value.filter((m) => m.topic.toLowerCase().includes(filter))
}
function filterByConnection(connectionId: string): MqttMessage[] {
if (!connectionId) return messages.value
return messages.value.filter((m) => m.connectionId === connectionId)
}
const messageCount = computed(() => messages.value.length)
return {
messages,
push,
clear,
filterByTopic,
filterByConnection,
messageCount,
}
}
+144
View File
@@ -0,0 +1,144 @@
import { invoke } from '@tauri-apps/api/core'
import { listen } from '@tauri-apps/api/event'
import { onUnmounted } from 'vue'
import type {
MqttConnectionDto,
PublishDto,
SubscribeDto,
MqttMessageEvent,
MqttStatusEvent,
MqttLogEvent,
ConnectionStatus,
} from '../types/mqtt'
import type { MqttMessage } from '../types/mqtt'
import { useConnections } from './useConnections'
import { useMessages } from './useMessages'
import { useLogs } from './useLogs'
import type { LogEntry } from './useLogs'
/**
* Initialize MQTT event listeners.
* Call this once in App.vue setup.
*/
export function useMqttBridge() {
const { updateStatus } = useConnections()
const { push: pushMessage } = useMessages()
const { push: pushLog } = useLogs()
let unlistenMessage: (() => void) | null = null
let unlistenStatus: (() => void) | null = null
let unlistenLog: (() => void) | null = null
async function init() {
// Listen for incoming messages
unlistenMessage = await listen<MqttMessageEvent>('mqtt:message', (event) => {
const e = event.payload
const msg: MqttMessage = {
id: crypto.randomUUID(),
connectionId: e.connection_id,
topic: e.topic,
payload: e.payload,
qos: e.qos,
retain: e.retain,
timestamp: e.timestamp,
direction: 'in',
reasonCode: e.reason_code ?? undefined,
userProperties: e.user_properties.map(([k, v]) => ({ key: k, value: v })),
contentType: e.content_type ?? undefined,
contentEncoding: e.content_encoding ?? undefined,
responseTopic: e.response_topic ?? undefined,
correlationData: e.correlation_data ?? undefined,
messageExpiryInterval: e.message_expiry_interval ?? undefined,
subscriptionIdentifier: e.subscription_identifier ?? undefined,
}
pushMessage(msg)
})
// Listen for connection status changes
unlistenStatus = await listen<MqttStatusEvent>('mqtt:status', (event) => {
const e = event.payload
switch (e.status) {
case 'connecting':
updateStatus(e.connection_id, 'connecting')
break
case 'connected':
updateStatus(e.connection_id, 'connected')
break
case 'disconnected':
updateStatus(e.connection_id, 'disconnected')
break
case 'error':
updateStatus(e.connection_id, 'error', e.error ?? 'Unknown error')
break
}
})
// Listen for log events
unlistenLog = await listen<MqttLogEvent>('mqtt:log', (event) => {
const e = event.payload
const log: LogEntry = {
id: crypto.randomUUID(),
timestamp: e.timestamp,
connectionId: e.connection_id,
level: e.level as 'info' | 'warn' | 'error',
event: e.event,
message: e.message,
details: e.details ?? undefined,
}
pushLog(log)
})
}
onUnmounted(() => {
unlistenMessage?.()
unlistenStatus?.()
unlistenLog?.()
})
return { init }
}
// ─── Tauri invoke wrappers ─────────────────────────────────────────────────────
export async function mqttConnect(conn: MqttConnectionDto): Promise<void> {
await invoke('mqtt_connect', { conn })
}
export async function mqttDisconnect(connectionId: string): Promise<void> {
await invoke('mqtt_disconnect', { connectionId })
}
export async function mqttPublish(publish: PublishDto): Promise<void> {
await invoke('mqtt_publish', { publish })
}
export async function mqttSubscribe(subscribe: SubscribeDto): Promise<void> {
await invoke('mqtt_subscribe', { subscribe })
}
export async function mqttUnsubscribe(
connectionId: string,
topic: string,
): Promise<void> {
await invoke('mqtt_unsubscribe', { connectionId, topic })
}
export async function mqttGetConnections(): Promise<ConnectionStatus[]> {
return await invoke<ConnectionStatus[]>('mqtt_get_connections')
}
export async function mqttGetConnectionStatus(
connectionId: string,
): Promise<ConnectionStatus> {
return await invoke<ConnectionStatus>('mqtt_get_connection_status', {
connectionId,
})
}
export async function mqttTestConnection(conn: MqttConnectionDto): Promise<string> {
return await invoke<string>('mqtt_test_connection', { conn })
}
export async function mqttGetLogDir(): Promise<string> {
return await invoke<string>('mqtt_get_log_dir')
}
+67
View File
@@ -0,0 +1,67 @@
import { ref, watch } from 'vue'
import type { Preset } from '../types/mqtt'
import { defaultPreset } from '../types/mqtt'
const STORAGE_KEY = 'mqttx_presets'
function loadPresets(): Preset[] {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return []
return JSON.parse(raw) as Preset[]
} catch {
return []
}
}
function savePresets(presets: Preset[]) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(presets))
} catch {
// silently ignore
}
}
const presets = ref<Preset[]>(loadPresets())
watch(
presets,
(val) => {
savePresets(val)
},
{ deep: true },
)
export function usePresets() {
function add() {
const preset = defaultPreset()
presets.value.push(preset)
return preset
}
function update(id: string, data: Partial<Preset>) {
const idx = presets.value.findIndex((p) => p.id === id)
if (idx !== -1) {
presets.value[idx] = { ...presets.value[idx], ...data }
}
}
function remove(id: string) {
const idx = presets.value.findIndex((p) => p.id === id)
if (idx !== -1) {
presets.value.splice(idx, 1)
}
}
function getById(id: string): Preset | undefined {
return presets.value.find((p) => p.id === id)
}
return {
presets,
add,
update,
remove,
getById,
}
}
+78
View File
@@ -0,0 +1,78 @@
import { ref, computed, watch } from 'vue'
import type { Subscription } from '../types/mqtt'
const STORAGE_KEY = 'mqttx_subscriptions'
function loadSubscriptions(): Subscription[] {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return []
return JSON.parse(raw) as Subscription[]
} catch {
return []
}
}
function saveSubscriptions(subs: Subscription[]) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(subs))
} catch {
// silently ignore
}
}
const subscriptions = ref<Subscription[]>(loadSubscriptions())
watch(
subscriptions,
(val) => {
saveSubscriptions(val)
},
{ deep: true },
)
export function useSubscriptions() {
function addSubscription(connId: string, topic: string, qos: number = 0) {
const existing = subscriptions.value.find(
(s) => s.connectionId === connId && s.topic === topic,
)
if (existing) {
existing.qos = qos
return false
}
subscriptions.value.push({
id: crypto.randomUUID(),
connectionId: connId,
topic,
qos,
})
return true
}
function removeSubscription(connId: string, topic: string) {
subscriptions.value = subscriptions.value.filter(
(s) => !(s.connectionId === connId && s.topic === topic),
)
}
function removeAllByConnection(connId: string) {
subscriptions.value = subscriptions.value.filter(
(s) => s.connectionId !== connId,
)
}
function getSubscriptionsByConnection(connId: string): Subscription[] {
return subscriptions.value.filter((s) => s.connectionId === connId)
}
const allSubscriptions = computed(() => subscriptions.value)
return {
subscriptions,
addSubscription,
removeSubscription,
removeAllByConnection,
getSubscriptionsByConnection,
allSubscriptions,
}
}
+200
View File
@@ -0,0 +1,200 @@
// ─── Subscription ──────────────────────────────────────────────────────────────
export interface Subscription {
id: string
connectionId: string
topic: string
qos: number
}
// ─── Connection ────────────────────────────────────────────────────────────────
export interface MqttConnection {
id: string
name: string
host: string
port: number
username: string
password: string
clientId: string
cleanStart: boolean
sessionExpiryInterval: number
keepAlive: number
ssl: boolean
receiveMaximum: number | null
maximumPacketSize: number | null
topicAliasMaximum: number | null
// runtime state (not persisted)
status: 'disconnected' | 'connecting' | 'connected' | 'error'
lastError: string
}
export function defaultConnection(): MqttConnection {
return {
id: crypto.randomUUID(),
name: '',
host: 'localhost',
port: 1883,
username: '',
password: '',
clientId: `mqttx_${Math.random().toString(36).slice(2, 10)}`,
cleanStart: true,
sessionExpiryInterval: 0,
keepAlive: 60,
ssl: false,
receiveMaximum: null,
maximumPacketSize: null,
topicAliasMaximum: null,
status: 'disconnected',
lastError: '',
}
}
// ─── Preset ────────────────────────────────────────────────────────────────────
export interface Preset {
id: string
name: string
topic: string
payloadTemplate: string
qos: 0 | 1 | 2
retain: boolean
userProperties: { key: string; value: string }[]
}
export function defaultPreset(): Preset {
return {
id: crypto.randomUUID(),
name: '',
topic: '',
payloadTemplate: '',
qos: 0,
retain: false,
userProperties: [],
}
}
// ─── Message ───────────────────────────────────────────────────────────────────
export interface MqttMessage {
id: string
connectionId: string
topic: string
payload: string
qos: number
retain: boolean
timestamp: number
direction: 'in' | 'out'
// v5 properties
reasonCode?: number
userProperties?: { key: string; value: string }[]
contentType?: string
contentEncoding?: string
responseTopic?: string
correlationData?: string
messageExpiryInterval?: number
subscriptionIdentifier?: number
}
// ─── Variable Bindings ─────────────────────────────────────────────────────────
export interface VariableBindings {
[key: string]: string
}
// ─── Tauri IPC DTOs ────────────────────────────────────────────────────────────
export interface MqttConnectionDto {
id: string
name: string
host: string
port: number
username: string
password: string
client_id: string
clean_start: boolean
session_expiry_interval: number
keep_alive: number
ssl: boolean
receive_maximum: number | null
maximum_packet_size: number | null
topic_alias_maximum: number | null
}
export function toConnectionDto(conn: MqttConnection): MqttConnectionDto {
return {
id: conn.id,
name: conn.name,
host: conn.host,
port: conn.port,
username: conn.username,
password: conn.password,
client_id: conn.clientId,
clean_start: conn.cleanStart,
session_expiry_interval: conn.sessionExpiryInterval,
keep_alive: conn.keepAlive,
ssl: conn.ssl,
receive_maximum: conn.receiveMaximum,
maximum_packet_size: conn.maximumPacketSize,
topic_alias_maximum: conn.topicAliasMaximum,
}
}
export interface PublishDto {
connection_id: string
topic: string
payload: string
qos: number
retain: boolean
user_properties: { key: string; value: string }[]
content_type: string | null
message_expiry_interval: number | null
response_topic: string | null
correlation_data: string | null
}
export interface SubscribeDto {
connection_id: string
topic: string
qos: number
}
export interface MqttMessageEvent {
connection_id: string
topic: string
payload: string
qos: number
retain: boolean
timestamp: number
reason_code: number | null
user_properties: [string, string][]
content_type: string | null
content_encoding: string | null
response_topic: string | null
correlation_data: string | null
message_expiry_interval: number | null
subscription_identifier: number | null
}
export interface MqttStatusEvent {
connection_id: string
status: string
error: string | null
session_present: boolean | null
reason_code: number | null
}
export interface ConnectionStatus {
id: string
connected: boolean
error: string | null
}
export interface MqttLogEvent {
timestamp: number
connection_id: string
level: string
event: string
message: string
details: string | null
}
+58
View File
@@ -0,0 +1,58 @@
/**
* Extract all {{variable}} placeholder names from a template string.
* Returns unique variable names in order of first appearance.
*/
export function extractVariables(template: string): string[] {
const regex = /\{\{(\w+)\}\}/g
const names: string[] = []
const seen = new Set<string>()
let match: RegExpExecArray | null
while ((match = regex.exec(template)) !== null) {
if (!seen.has(match[1])) {
seen.add(match[1])
names.push(match[1])
}
}
return names
}
/**
* Replace {{variable}} placeholders with values from bindings.
* Unresolved placeholders are left as-is.
*/
export function resolveTemplate(
template: string,
bindings: Record<string, string>,
): string {
return template.replace(/\{\{(\w+)\}\}/g, (_, name: string) => {
return bindings[name] ?? `{{${name}}}`
})
}
/**
* Extract user property placeholder names from a list of key-value pairs.
* Both key and value can contain {{variables}}.
*/
export function extractUserPropertyVariables(
userProperties: { key: string; value: string }[],
): string[] {
const names = new Set<string>()
for (const prop of userProperties) {
for (const p of extractVariables(prop.key)) names.add(p)
for (const p of extractVariables(prop.value)) names.add(p)
}
return [...names]
}
/**
* Resolve user property key-value pairs with variable bindings.
*/
export function resolveUserProperties(
userProperties: { key: string; value: string }[],
bindings: Record<string, string>,
): { key: string; value: string }[] {
return userProperties.map((p) => ({
key: resolveTemplate(p.key, bindings),
value: resolveTemplate(p.value, bindings),
}))
}
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>;
export default component;
}
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
/* Linting */
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+32
View File
@@ -0,0 +1,32 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
// @ts-expect-error type error without @types/node package
import process from "node:process";
const host = process.env.TAURI_DEV_HOST;
// https://vite.dev/config/
export default defineConfig(() => ({
plugins: [vue()],
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
//
// 1. prevent Vite from obscuring rust errors
clearScreen: false,
// 2. tauri expects a fixed port, fail if that port is not available
server: {
port: 1420,
strictPort: true,
host: host || false,
hmr: host
? {
protocol: "ws",
host,
port: 1421,
}
: undefined,
watch: {
// 3. tell Vite to ignore watching `src-tauri`
ignored: ["**/src-tauri/**"],
},
},
}));