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/**"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||