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
@@ -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?
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"Vue.volar",
|
||||
"tauri-apps.tauri-vscode",
|
||||
"rust-lang.rust-analyzer"
|
||||
]
|
||||
}
|
||||
@@ -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 -->
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 |
@@ -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 |
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 567 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 536 B |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 777 B |
|
After Width: | Height: | Size: 1020 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 848 B |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
@@ -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");
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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":{}}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 |
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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");
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module "*.vue" {
|
||||
import type { DefineComponent } from "vue";
|
||||
const component: DefineComponent<{}, {}, any>;
|
||||
export default component;
|
||||
}
|
||||
@@ -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" }]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -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/**"],
|
||||
},
|
||||
},
|
||||
}));
|
||||