chore: 移除 Tauri + Vue 遗留实现,仅维护原生 gpui 程序
- 删除 src/(Vue 前端)、src-tauri/(Tauri 后端与全套图标)、 Vite/TS/pnpm 配置、.github 旧 CI、.vscode 推荐 - workspace 去掉 src-tauri exclude;.gitignore 精简为纯 Rust 项目 - README 指向归档分支 tauri-legacy(保留完整历史可随时查阅)
@@ -1,111 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
- '.gitignore'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# ─── Frontend: lint + type-check + build ──────────────────────
|
||||
frontend:
|
||||
name: Frontend
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Type check
|
||||
run: pnpm exec vue-tsc --noEmit
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
# ─── Rust: check + clippy ─────────────────────────────────────
|
||||
rust:
|
||||
name: Rust
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
target: x86_64-unknown-linux-gnu
|
||||
|
||||
- name: Install system deps
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev \
|
||||
librsvg2-dev patchelf libgtk-3-dev libsoup-3.0-dev libjavascriptcoregtk-4.1-dev
|
||||
|
||||
- name: Check
|
||||
run: cargo check --manifest-path src-tauri/Cargo.toml
|
||||
|
||||
- name: Clippy
|
||||
run: cargo clippy --manifest-path src-tauri/Cargo.toml -- -D warnings
|
||||
|
||||
# ─── Tauri build (Windows) ────────────────────────────────────
|
||||
tauri-build:
|
||||
name: Tauri Build
|
||||
needs: [frontend, rust]
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
- os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-gnu
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
target: ${{ matrix.target }}
|
||||
|
||||
- name: Install Linux deps
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev \
|
||||
librsvg2-dev patchelf libgtk-3-dev libsoup-3.0-dev libjavascriptcoregtk-4.1-dev
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build Tauri app
|
||||
run: pnpm tauri build
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: mqttx-${{ runner.os }}
|
||||
path: src-tauri/target/release/bundle/
|
||||
@@ -1,43 +1,19 @@
|
||||
# DSH usage records
|
||||
dsh-usage/
|
||||
|
||||
# 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
|
||||
.pnpm-store
|
||||
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?
|
||||
|
||||
# Workspace-level Rust build artifacts (GPUI app)
|
||||
# Rust 构建产物(GPUI 工作区)
|
||||
/target
|
||||
*.pdb
|
||||
|
||||
# 日志
|
||||
logs
|
||||
*.log
|
||||
|
||||
# 编辑器
|
||||
.vscode/*
|
||||
.idea
|
||||
.DS_Store
|
||||
*.sw?
|
||||
|
||||
# 本仓库专属
|
||||
/.v2c
|
||||
/.video_agent
|
||||
|
||||
# 发布产物暂存目录
|
||||
/dist-publish
|
||||
dsh-usage/
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"Vue.volar",
|
||||
"tauri-apps.tauri-vscode",
|
||||
"rust-lang.rust-analyzer"
|
||||
]
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["gpui-app"]
|
||||
exclude = ["src-tauri"]
|
||||
|
||||
[workspace.package]
|
||||
edition = "2024"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
使用 **Rust + [gpui-kit](https://github.com/longbridge/gpui-kit)**(基于 [GPUI](https://github.com/zed-industries/zed))重写的 MQTT 调试客户端,对标官方 [MQTTX](https://mqttx.app)。
|
||||
纯 Rust 实现:MQTT 引擎、数据持久化与原生 UI 同属一个轻量二进制,无 Electron/WebView 运行时。
|
||||
|
||||
> 旧的 Tauri + Vue 版本仍保留在 `src/` 与 `src-tauri/`,新实现位于 `gpui-app/`。
|
||||
> 早期 Tauri + Vue 实现已归档到 [`tauri-legacy`](../../archive/tauri-legacy.tar.gz) 分支,本项目仅维护 `gpui-app/` 原生实现。
|
||||
|
||||
## 功能特性
|
||||
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/mqttx.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>
|
||||
@@ -1,30 +0,0 @@
|
||||
{
|
||||
"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",
|
||||
"@tauri-apps/plugin-process": "^2",
|
||||
"@tauri-apps/plugin-updater": "^2",
|
||||
"@visual-json/core": "^0.4.0",
|
||||
"@visual-json/vue": "^0.4.0",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="mqttx-bg" x1="96" y1="80" x2="928" y2="944" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#7dab7c"/>
|
||||
<stop offset="0.55" stop-color="#5b8c5a"/>
|
||||
<stop offset="1" stop-color="#446f43"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="mqttx-gloss" x1="256" y1="128" x2="768" y2="896" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#ffffff" stop-opacity="0.16"/>
|
||||
<stop offset="0.45" stop-color="#ffffff" stop-opacity="0"/>
|
||||
<stop offset="0.55" stop-color="#000000" stop-opacity="0"/>
|
||||
<stop offset="1" stop-color="#000000" stop-opacity="0.10"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Badge -->
|
||||
<rect x="64" y="64" width="896" height="896" rx="224" fill="url(#mqttx-bg)"/>
|
||||
<rect x="64" y="64" width="896" height="896" rx="224" fill="url(#mqttx-gloss)"/>
|
||||
|
||||
<!-- M / signal mark -->
|
||||
<path
|
||||
d="M300 711 L408 361 Q424 313 462 339 L512 459 L562 339 Q600 313 616 361 L724 711"
|
||||
stroke="#ffffff"
|
||||
stroke-width="96"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
|
||||
<!-- Live pulse dot -->
|
||||
<circle cx="790" cy="256" r="46" fill="#ffc131"/>
|
||||
<circle cx="790" cy="256" r="66" stroke="#ffffff" stroke-opacity="0.55" stroke-width="12"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -1,7 +0,0 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
|
||||
# Generated by Tauri
|
||||
# will have schema files for capabilities auto-completion
|
||||
/gen/schemas
|
||||
@@ -1,43 +0,0 @@
|
||||
[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"
|
||||
tauri-plugin-process = "2"
|
||||
tauri-plugin-updater = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
# rumqttc-v5-next 与 rumqttc-v4-next 的 lib 目标名都是 "rumqttc",必须重命名依赖才能共存
|
||||
rumqttc = { package = "rumqttc-v5-next", version = "0.34" }
|
||||
rumqttc-v4 = { package = "rumqttc-v4-next", version = "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
|
||||
@@ -1,14 +0,0 @@
|
||||
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()
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"opener:default"
|
||||
]
|
||||
}
|
||||
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 3.2 KiB |
@@ -1,5 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
</adaptive-icon>
|
||||
|
Before Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 16 KiB |
@@ -1,4 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#fff</color>
|
||||
</resources>
|
||||
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 959 B |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 9.5 KiB |
|
Before Width: | Height: | Size: 291 KiB |
|
Before Width: | Height: | Size: 9.5 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 15 KiB |
@@ -1,6 +0,0 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
mqttx_lib::run()
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
{
|
||||
"$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
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"active": true,
|
||||
"dialog": true,
|
||||
"endpoints": [
|
||||
"https://releases.mqttx.app/update/{{target}}-{{arch}}/{{current_version}}"
|
||||
],
|
||||
"pubkey": "请替换为你的公钥——使用 `tauri signer generate` 生成签名密钥对后,将公钥粘贴至此"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"windows": {
|
||||
"nsis": {
|
||||
"languages": ["SimpChinese", "English"],
|
||||
"displayLanguageSelector": true
|
||||
},
|
||||
"wix": {
|
||||
"language": ["zh-CN", "en-US"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useContextMenuController } from "./composables/useContextMenu"
|
||||
import AppLayout from "./components/AppLayout.vue"
|
||||
|
||||
useContextMenuController()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout />
|
||||
</template>
|
||||
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body, #app {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Inter", "Helvetica Neue", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
</style>
|
||||
@@ -1 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 496 B |
@@ -1,375 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useMqttBridge } from '../stores/useMqttBridge'
|
||||
import { useUpdater } from '../composables/useUpdater'
|
||||
import ConnectionList from './connections/ConnectionList.vue'
|
||||
import PresetList from './presets/PresetList.vue'
|
||||
import DashboardView from './dashboard/DashboardView.vue'
|
||||
|
||||
const { init } = useMqttBridge()
|
||||
const {
|
||||
state: updateState,
|
||||
checking: updateChecking,
|
||||
checkForUpdates,
|
||||
downloadAndInstall,
|
||||
silentCheck,
|
||||
} = useUpdater()
|
||||
|
||||
const activeTab = ref<'connections' | 'presets' | 'dashboard'>('dashboard')
|
||||
const isDark = ref(false)
|
||||
|
||||
function applyTheme(dark: boolean) {
|
||||
isDark.value = dark
|
||||
document.documentElement.classList.toggle('dark', dark)
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
applyTheme(!isDark.value)
|
||||
try {
|
||||
localStorage.setItem('mqttx_theme', isDark.value ? 'dark' : 'light')
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function getSystemTheme(): boolean {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
init()
|
||||
|
||||
// 静默检查更新(应用启动时)
|
||||
silentCheck()
|
||||
|
||||
// Listen for system theme changes
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
|
||||
const saved = localStorage.getItem('mqttx_theme')
|
||||
// Only follow system if user hasn't explicitly set a preference
|
||||
if (!saved) {
|
||||
applyTheme(e.matches)
|
||||
}
|
||||
})
|
||||
|
||||
// Restore theme preference, fallback to system
|
||||
try {
|
||||
const saved = localStorage.getItem('mqttx_theme')
|
||||
if (saved === 'dark') {
|
||||
applyTheme(true)
|
||||
} else if (saved === 'light') {
|
||||
applyTheme(false)
|
||||
} else {
|
||||
// No saved preference → follow system
|
||||
applyTheme(getSystemTheme())
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-layout">
|
||||
<header class="app-header">
|
||||
<div class="header-left">
|
||||
<div class="app-brand">
|
||||
<img
|
||||
src="/mqttx.svg"
|
||||
width="22"
|
||||
height="22"
|
||||
alt="MQTTX"
|
||||
class="brand-icon"
|
||||
draggable="false"
|
||||
/>
|
||||
<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">
|
||||
<!-- 更新按钮 -->
|
||||
<button
|
||||
v-if="updateState.available && !updateState.downloading && !updateState.installing"
|
||||
class="update-btn"
|
||||
@click="downloadAndInstall"
|
||||
title="有新版本可用"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7 10 12 15 17 10"/>
|
||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
<span class="update-badge">v{{ updateState.version }}</span>
|
||||
</button>
|
||||
|
||||
<!-- 下载进度 -->
|
||||
<div
|
||||
v-else-if="updateState.downloading"
|
||||
class="update-progress"
|
||||
title="正在下载更新..."
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7 10 12 15 17 10"/>
|
||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
<span class="progress-text">{{ updateState.downloadProgress }}%</span>
|
||||
</div>
|
||||
|
||||
<!-- 安装中 -->
|
||||
<div
|
||||
v-else-if="updateState.installing"
|
||||
class="update-installing"
|
||||
title="正在安装更新..."
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="spin">
|
||||
<circle cx="12" cy="12" r="10" stroke-dasharray="32" stroke-dashoffset="32"/>
|
||||
</svg>
|
||||
<span class="progress-text">安装中...</span>
|
||||
</div>
|
||||
|
||||
<!-- 手动检查更新按钮(无更新时) -->
|
||||
<button
|
||||
v-else
|
||||
class="check-update-btn"
|
||||
:disabled="updateChecking"
|
||||
@click="checkForUpdates"
|
||||
:title="updateChecking ? '检查中...' : '检查更新'"
|
||||
>
|
||||
<svg
|
||||
width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
:class="{ spin: updateChecking }"
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7 10 12 15 17 10"/>
|
||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button class="theme-btn" @click="toggleTheme" :title="isDark ? '切换到亮色模式' : '切换到暗色模式'">
|
||||
<svg v-if="isDark" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
|
||||
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</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(--comfort-bg);
|
||||
}
|
||||
|
||||
/* ─── Header ─────────────────────────────────────────────────── */
|
||||
.app-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 16px;
|
||||
height: 48px;
|
||||
background: var(--comfort-bg-card);
|
||||
border-bottom: 1px solid var(--comfort-border);
|
||||
flex-shrink: 0;
|
||||
gap: 24px;
|
||||
}
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.app-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.brand-icon {
|
||||
display: block;
|
||||
border-radius: 6px;
|
||||
user-select: none;
|
||||
}
|
||||
.brand-name {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--comfort-primary);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.brand-version {
|
||||
font-size: 11px;
|
||||
color: var(--comfort-text-muted);
|
||||
background: var(--comfort-fill-color, var(--el-fill-color));
|
||||
padding: 0 5px;
|
||||
border-radius: 4px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
/* ─── Tabs ───────────────────────────────────────────────────── */
|
||||
.header-tabs {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
background: var(--comfort-fill-color, 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(--comfort-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
font-family: inherit;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tab-btn:hover {
|
||||
color: var(--comfort-text);
|
||||
}
|
||||
.tab-btn.active {
|
||||
background: var(--comfort-bg-card);
|
||||
color: var(--comfort-primary);
|
||||
font-weight: 500;
|
||||
box-shadow: var(--comfort-shadow);
|
||||
}
|
||||
.tab-btn svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.header-right {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.theme-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: 1px solid var(--comfort-border);
|
||||
border-radius: 6px;
|
||||
background: var(--comfort-bg-card);
|
||||
color: var(--comfort-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.theme-btn:hover {
|
||||
color: var(--comfort-primary);
|
||||
border-color: var(--comfort-primary-light);
|
||||
background: var(--comfort-primary-bg);
|
||||
}
|
||||
.theme-btn svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ─── Update Button ──────────────────────────────────────────── */
|
||||
.update-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0 10px;
|
||||
height: 30px;
|
||||
border: 1px solid var(--el-color-primary, #409eff);
|
||||
border-radius: 6px;
|
||||
background: var(--el-color-primary-light-9, #ecf5ff);
|
||||
color: var(--el-color-primary, #409eff);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.update-btn:hover {
|
||||
background: var(--el-color-primary, #409eff);
|
||||
color: #fff;
|
||||
}
|
||||
.update-badge {
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* ─── Update Progress ────────────────────────────────────────── */
|
||||
.update-progress,
|
||||
.update-installing {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0 10px;
|
||||
height: 30px;
|
||||
border-radius: 6px;
|
||||
background: var(--comfort-fill-color, var(--el-fill-color));
|
||||
color: var(--comfort-text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
.progress-text {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ─── Check Update Button ────────────────────────────────────── */
|
||||
.check-update-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: 1px solid var(--comfort-border);
|
||||
border-radius: 6px;
|
||||
background: var(--comfort-bg-card);
|
||||
color: var(--comfort-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.check-update-btn:hover:not(:disabled) {
|
||||
color: var(--comfort-primary);
|
||||
border-color: var(--comfort-primary-light);
|
||||
background: var(--comfort-primary-bg);
|
||||
}
|
||||
.check-update-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ─── Spin animation ─────────────────────────────────────────── */
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ─── Main ───────────────────────────────────────────────────── */
|
||||
.app-main {
|
||||
flex: 1;
|
||||
padding: 14px;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -1,132 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
export interface ContextMenuItem {
|
||||
label: string
|
||||
icon?: string
|
||||
danger?: boolean
|
||||
disabled?: boolean
|
||||
divider?: boolean
|
||||
action: () => void
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
items: ContextMenuItem[]
|
||||
}>()
|
||||
|
||||
const visible = ref(false)
|
||||
const x = ref(0)
|
||||
const y = ref(0)
|
||||
let targetEl: HTMLElement | null = null
|
||||
|
||||
function show(ev: MouseEvent, el: HTMLElement) {
|
||||
x.value = ev.clientX
|
||||
y.value = ev.clientY
|
||||
targetEl = el
|
||||
visible.value = true
|
||||
}
|
||||
|
||||
function hide() {
|
||||
visible.value = false
|
||||
targetEl = null
|
||||
}
|
||||
|
||||
function handleAction(item: ContextMenuItem) {
|
||||
if (item.disabled) return
|
||||
hide()
|
||||
item.action()
|
||||
}
|
||||
|
||||
function onClickOutside(e: MouseEvent) {
|
||||
const menu = document.querySelector('.context-menu')
|
||||
if (menu && !menu.contains(e.target as Node)) {
|
||||
hide()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', onClickOutside)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', onClickOutside)
|
||||
})
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="visible"
|
||||
class="context-menu"
|
||||
:style="{ left: x + 'px', top: y + 'px' }"
|
||||
>
|
||||
<div
|
||||
v-for="(item, idx) in items"
|
||||
:key="idx"
|
||||
:class="['ctx-item', { danger: item.danger, disabled: item.disabled, divider: item.divider }]"
|
||||
@click="handleAction(item)"
|
||||
>
|
||||
<span v-if="item.icon" class="ctx-icon">{{ item.icon }}</span>
|
||||
<span class="ctx-label">{{ item.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.context-menu {
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
min-width: 140px;
|
||||
background: var(--el-bg-color);
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 6px;
|
||||
padding: 4px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
font-size: 12px;
|
||||
}
|
||||
.ctx-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--el-text-color-regular);
|
||||
transition: background 0.1s;
|
||||
user-select: none;
|
||||
}
|
||||
.ctx-item:hover {
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
.ctx-item.danger {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
.ctx-item.danger:hover {
|
||||
background: var(--el-color-danger-light-9);
|
||||
}
|
||||
.ctx-item.disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.ctx-item.disabled:hover {
|
||||
background: transparent;
|
||||
}
|
||||
.ctx-item.divider {
|
||||
border-top: 1px solid var(--el-border-color-light);
|
||||
margin-top: 2px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
.ctx-icon {
|
||||
font-size: 14px;
|
||||
width: 18px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ctx-label {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -1,120 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
/** Full text to show in the preview */
|
||||
text: string
|
||||
/** Maximum width of the preview popup */
|
||||
maxWidth?: number
|
||||
/** Maximum height of the preview popup */
|
||||
maxHeight?: number
|
||||
/** Delay before showing (ms) */
|
||||
delay?: number
|
||||
}>(), {
|
||||
maxWidth: 480,
|
||||
maxHeight: 360,
|
||||
delay: 400,
|
||||
})
|
||||
|
||||
const visible = ref(false)
|
||||
const x = ref(0)
|
||||
const y = ref(0)
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function onMouseEnter(ev: MouseEvent) {
|
||||
timer = setTimeout(() => {
|
||||
visible.value = true
|
||||
updatePosition(ev)
|
||||
}, props.delay)
|
||||
}
|
||||
|
||||
function onMouseMove(ev: MouseEvent) {
|
||||
if (visible.value) {
|
||||
updatePosition(ev)
|
||||
}
|
||||
}
|
||||
|
||||
function onMouseLeave() {
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
function updatePosition(ev: MouseEvent) {
|
||||
const pad = 14
|
||||
const popupW = Math.min(props.maxWidth, 480)
|
||||
const popupH = Math.min(props.maxHeight, 360)
|
||||
|
||||
let left = ev.clientX + pad
|
||||
let top = ev.clientY + pad
|
||||
|
||||
// Flip to left side if too close to right edge
|
||||
if (left + popupW + 16 > window.innerWidth) {
|
||||
left = ev.clientX - popupW - pad
|
||||
}
|
||||
// Flip to top side if too close to bottom edge
|
||||
if (top + popupH + 16 > window.innerHeight) {
|
||||
top = ev.clientY - popupH - pad
|
||||
}
|
||||
// Clamp to safe bounds
|
||||
left = Math.max(8, Math.min(left, window.innerWidth - popupW - 8))
|
||||
top = Math.max(8, Math.min(top, window.innerHeight - popupH - 8))
|
||||
|
||||
x.value = left
|
||||
y.value = top
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="hover-preview-wrap"
|
||||
@mouseenter="onMouseEnter"
|
||||
@mousemove="onMouseMove"
|
||||
@mouseleave="onMouseLeave"
|
||||
>
|
||||
<slot />
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="visible && text"
|
||||
class="hover-preview-popup"
|
||||
:style="{
|
||||
left: x + 'px',
|
||||
top: y + 'px',
|
||||
maxWidth: maxWidth + 'px',
|
||||
maxHeight: maxHeight + 'px',
|
||||
}"
|
||||
>
|
||||
<pre class="hover-preview-content"><code>{{ text }}</code></pre>
|
||||
</div>
|
||||
</Teleport>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.hover-preview-wrap {
|
||||
display: inline;
|
||||
cursor: pointer;
|
||||
}
|
||||
.hover-preview-popup {
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
background: var(--comfort-bg-card, var(--el-bg-color-overlay));
|
||||
border: 1px solid var(--comfort-border, var(--el-border-color));
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12), 0 1px 4px rgba(0, 0, 0, 0.08);
|
||||
padding: 8px 10px;
|
||||
overflow: auto;
|
||||
pointer-events: none;
|
||||
}
|
||||
.hover-preview-content {
|
||||
margin: 0;
|
||||
font-family: var(--comfort-font-mono, 'Cascadia Code', 'Fira Code', 'Consolas', monospace);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--comfort-text, var(--el-text-color-regular));
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
@@ -1,665 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import type { MqttConnection, AliyunPreset } from '../../types/mqtt'
|
||||
import { defaultConnection, defaultAliyunPreset, generateAliyunConnection } from '../../types/mqtt'
|
||||
import { mqttTestConnection, mqttReadDebugLog } from '../../stores/useMqttBridge'
|
||||
import { useConnections } from '../../stores/useConnections'
|
||||
import { useAliyunPresets } from '../../stores/useAliyunPresets'
|
||||
import { toConnectionDto } from '../../types/mqtt'
|
||||
|
||||
const { getGroups: getConnectionGroups } = useConnections()
|
||||
const { presets, add: addPreset, update: updatePreset, remove: removePreset, getGroups: getPresetGroups, renameGroup: renamePresetGroup, deleteGroup: deletePresetGroup, exportPresets, importPresets } = useAliyunPresets()
|
||||
|
||||
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)
|
||||
const importInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
// ─── 连接分组 ──────────────────────────────────────────────────────────────
|
||||
|
||||
const connectionGroupOptions = computed(() => {
|
||||
const existing = getConnectionGroups()
|
||||
const opts = [{ label: '未分组', value: '' }]
|
||||
for (const g of existing) opts.push({ label: g, value: g })
|
||||
return opts
|
||||
})
|
||||
|
||||
// ─── 阿里云预设 ─────────────────────────────────────────────────────────────
|
||||
|
||||
type CloudPreset = '' | 'aliyun'
|
||||
const cloudPreset = ref<CloudPreset>('')
|
||||
const selectedPresetId = ref<string>('')
|
||||
const aliyunPreset = ref<AliyunPreset | null>(null)
|
||||
|
||||
// 预设分组
|
||||
const presetGroupFilter = ref<string | null>(null)
|
||||
const presetGroups = computed(() => getPresetGroups())
|
||||
|
||||
const filteredPresets = computed(() => {
|
||||
if (presetGroupFilter.value === null) return presets.value
|
||||
if (presetGroupFilter.value === '__ungrouped__') {
|
||||
return presets.value.filter((p) => !p.group)
|
||||
}
|
||||
return presets.value.filter((p) => p.group === presetGroupFilter.value)
|
||||
})
|
||||
|
||||
const presetGroupCounts = computed(() => {
|
||||
const map: Record<string, number> = {}
|
||||
for (const p of presets.value) {
|
||||
const key = p.group || '__ungrouped__'
|
||||
map[key] = (map[key] || 0) + 1
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
/** 预设内容签名,用于判断用户是否真的修改过(避免把空白/未改动的预设写进库) */
|
||||
function presetSignature(p: AliyunPreset): string {
|
||||
return JSON.stringify([
|
||||
p.name, p.group, p.instanceId, p.authMode, p.accessKeyId,
|
||||
p.accessKeySecret, p.groupId, p.deviceId,
|
||||
p.deviceAccessKeyId, p.deviceAccessKeySecret,
|
||||
])
|
||||
}
|
||||
let presetBaseline = ''
|
||||
|
||||
function markPresetBaseline() {
|
||||
if (aliyunPreset.value) presetBaseline = presetSignature(aliyunPreset.value)
|
||||
}
|
||||
|
||||
function handleSelectPreset(id: string) {
|
||||
const found = id ? presets.value.find((p) => p.id === id) : undefined
|
||||
if (found) {
|
||||
selectedPresetId.value = id
|
||||
aliyunPreset.value = { ...found }
|
||||
} else {
|
||||
// 清空选择 → 空白预设,字段保持可编辑
|
||||
selectedPresetId.value = ''
|
||||
aliyunPreset.value = defaultAliyunPreset()
|
||||
}
|
||||
markPresetBaseline()
|
||||
applyAliyunPreset()
|
||||
}
|
||||
|
||||
function handleNewPreset() {
|
||||
// 不强制先命名:直接给空白预设,填入有效内容后自动入库
|
||||
selectedPresetId.value = ''
|
||||
aliyunPreset.value = {
|
||||
...defaultAliyunPreset(),
|
||||
group: presetGroupFilter.value && presetGroupFilter.value !== '__ungrouped__' ? presetGroupFilter.value : '',
|
||||
}
|
||||
markPresetBaseline()
|
||||
}
|
||||
|
||||
async function handleDeletePreset() {
|
||||
if (!selectedPresetId.value) return
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除预设 "${aliyunPreset.value?.name || '未命名'}"?`, '确认', { type: 'warning' })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
removePreset(selectedPresetId.value)
|
||||
selectedPresetId.value = ''
|
||||
aliyunPreset.value = defaultAliyunPreset()
|
||||
markPresetBaseline()
|
||||
ElMessage.success('已删除')
|
||||
}
|
||||
|
||||
async function handleRenamePresetGroup(group: string) {
|
||||
const { value } = await ElMessageBox.prompt('重命名预设分组', '重命名', {
|
||||
inputValue: group,
|
||||
inputPattern: /\S+/,
|
||||
inputErrorMessage: '名称不能为空',
|
||||
}).catch(() => ({ value: undefined }))
|
||||
if (value && value !== group) {
|
||||
renamePresetGroup(group, value)
|
||||
if (presetGroupFilter.value === group) presetGroupFilter.value = value
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeletePresetGroup(group: string) {
|
||||
const count = presetGroupCounts.value[group] || 0
|
||||
try {
|
||||
await ElMessageBox.confirm(`删除分组 "${group}"?组内 ${count} 个预设将变为未分组`, '确认', { type: 'warning' })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
deletePresetGroup(group)
|
||||
if (presetGroupFilter.value === group) presetGroupFilter.value = null
|
||||
}
|
||||
|
||||
// ─── 连接监听 ──────────────────────────────────────────────────────────────
|
||||
|
||||
watch(
|
||||
() => props.connection,
|
||||
(conn) => {
|
||||
if (conn) {
|
||||
form.value = { ...conn }
|
||||
if (conn.host.includes('.mqtt.aliyuncs.com') && conn.username.startsWith('Signature|')) {
|
||||
cloudPreset.value = 'aliyun'
|
||||
parseAliyunFromConnection(conn)
|
||||
} else {
|
||||
cloudPreset.value = ''
|
||||
aliyunPreset.value = null
|
||||
selectedPresetId.value = ''
|
||||
}
|
||||
} else {
|
||||
form.value = defaultConnection()
|
||||
cloudPreset.value = ''
|
||||
aliyunPreset.value = null
|
||||
selectedPresetId.value = ''
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function parseAliyunFromConnection(conn: MqttConnection) {
|
||||
const hostMatch = conn.host.match(/^(.+)\.mqtt\.aliyuncs\.com$/)
|
||||
const userMatch = conn.username.match(/^Signature\|(.+)\|(.+)$/)
|
||||
const clientMatch = conn.clientId.match(/^(.+)@@@(.+)$/)
|
||||
|
||||
// 尝试匹配已有预设
|
||||
const match = presets.value.find((p) =>
|
||||
p.instanceId === (hostMatch?.[1] ?? '') &&
|
||||
p.accessKeyId === (userMatch?.[1] ?? '') &&
|
||||
p.groupId === (clientMatch?.[1] ?? ''),
|
||||
)
|
||||
|
||||
if (match) {
|
||||
selectedPresetId.value = match.id
|
||||
aliyunPreset.value = { ...match }
|
||||
} else {
|
||||
aliyunPreset.value = {
|
||||
...defaultAliyunPreset(),
|
||||
name: '',
|
||||
instanceId: hostMatch?.[1] ?? '',
|
||||
accessKeyId: userMatch?.[1] ?? '',
|
||||
groupId: clientMatch?.[1] ?? '',
|
||||
deviceId: clientMatch?.[2] ?? '',
|
||||
}
|
||||
}
|
||||
markPresetBaseline()
|
||||
}
|
||||
|
||||
async function handlePresetChange(val: CloudPreset | undefined) {
|
||||
if (val === 'aliyun') {
|
||||
if (!aliyunPreset.value) {
|
||||
aliyunPreset.value = defaultAliyunPreset()
|
||||
markPresetBaseline()
|
||||
}
|
||||
return
|
||||
}
|
||||
// 退出阿里云模式:已生成过连接参数时先确认,保留名称和分组
|
||||
if (form.value.host.endsWith('.mqtt.aliyuncs.com')) {
|
||||
try {
|
||||
await ElMessageBox.confirm('退出阿里云模式将清空已生成的连接参数(保留名称和分组)', '提示', { type: 'warning' })
|
||||
} catch {
|
||||
cloudPreset.value = 'aliyun'
|
||||
return
|
||||
}
|
||||
}
|
||||
const { name, group } = form.value
|
||||
form.value = { ...defaultConnection(), id: form.value.id, name, group }
|
||||
aliyunPreset.value = null
|
||||
selectedPresetId.value = ''
|
||||
}
|
||||
|
||||
async function applyAliyunPreset() {
|
||||
const p = aliyunPreset.value
|
||||
if (!p || !p.instanceId || !p.accessKeyId || !p.accessKeySecret || !p.groupId) return
|
||||
|
||||
const generated = await generateAliyunConnection(p)
|
||||
|
||||
// 用户手动改过连接名时不覆盖(自动名以“阿里云 ”开头)
|
||||
const autoName = p.name || `阿里云 ${p.groupId}`
|
||||
const keepName = form.value.name !== '' && form.value.name !== autoName && !form.value.name.startsWith('阿里云 ')
|
||||
|
||||
form.value = {
|
||||
...form.value,
|
||||
...generated,
|
||||
port: form.value.ssl ? 8883 : 1883,
|
||||
name: keepName ? form.value.name : autoName,
|
||||
}
|
||||
}
|
||||
|
||||
function persistPresetIfChanged() {
|
||||
const p = aliyunPreset.value
|
||||
if (!p || cloudPreset.value !== 'aliyun') return
|
||||
if (presetSignature(p) === presetBaseline) return
|
||||
presetBaseline = presetSignature(p)
|
||||
if (selectedPresetId.value) {
|
||||
updatePreset(selectedPresetId.value, p)
|
||||
} else if (p.name || p.instanceId || p.groupId || p.accessKeyId) {
|
||||
// 空白预设填入有效内容后才入库
|
||||
const added = addPreset({ ...p })
|
||||
selectedPresetId.value = added.id
|
||||
}
|
||||
}
|
||||
|
||||
// 预设内容变化:去抖后自动入库 + 重新生成连接参数
|
||||
let persistTimer: ReturnType<typeof setTimeout> | undefined
|
||||
watch(aliyunPreset, () => {
|
||||
if (cloudPreset.value !== 'aliyun' || !aliyunPreset.value) return
|
||||
if (persistTimer) clearTimeout(persistTimer)
|
||||
persistTimer = setTimeout(() => {
|
||||
persistTimer = undefined
|
||||
persistPresetIfChanged()
|
||||
applyAliyunPreset()
|
||||
}, 500)
|
||||
}, { deep: true })
|
||||
|
||||
async function flushPendingPreset() {
|
||||
if (!persistTimer) return
|
||||
clearTimeout(persistTimer)
|
||||
persistTimer = undefined
|
||||
persistPresetIfChanged()
|
||||
await applyAliyunPreset()
|
||||
}
|
||||
|
||||
// 阿里云模式下切换 TLS 时端口跟随(1883 / 8883)
|
||||
watch(() => form.value.ssl, (ssl) => {
|
||||
if (cloudPreset.value === 'aliyun' && form.value.host.endsWith('.mqtt.aliyuncs.com')) {
|
||||
form.value.port = ssl ? 8883 : 1883
|
||||
}
|
||||
})
|
||||
|
||||
// ─── 预设导入导出 ──────────────────────────────────────────────────────────
|
||||
|
||||
function handleExportPresets() {
|
||||
const json = exportPresets()
|
||||
const blob = new Blob([json], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `mqttx_presets_${new Date().toISOString().slice(0, 10)}.json`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
ElMessage.success(`已导出 ${presets.value.length} 个预设(含明文密钥,请妥善保管)`)
|
||||
}
|
||||
|
||||
function handleImportPresetsClick() {
|
||||
importInput.value?.click()
|
||||
}
|
||||
|
||||
async function handleImportPresetsFile(event: Event) {
|
||||
const file = (event.target as HTMLInputElement).files?.[0]
|
||||
if (!file) return
|
||||
try {
|
||||
const text = await file.text()
|
||||
const { imported, skipped } = importPresets(text)
|
||||
if (imported === 0 && skipped > 0) {
|
||||
ElMessage.warning(`全部 ${skipped} 个预设与现有内容重复,未导入`)
|
||||
} else {
|
||||
ElMessage.success(skipped > 0 ? `导入 ${imported} 个预设,跳过 ${skipped} 个重复` : `成功导入 ${imported} 个预设`)
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error(`导入失败: ${e}`)
|
||||
} finally {
|
||||
if (importInput.value) importInput.value.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 其他 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function generateClientId() {
|
||||
form.value.clientId = `mqttx_${Math.random().toString(36).slice(2, 10)}`
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
await flushPendingPreset()
|
||||
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() {
|
||||
await flushPendingPreset()
|
||||
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) {
|
||||
ElMessage.error(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
testing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleViewDebugLog() {
|
||||
try {
|
||||
const log = await mqttReadDebugLog()
|
||||
ElMessageBox.alert(
|
||||
log || '暂无调试日志',
|
||||
'MQTT 调试日志',
|
||||
{ type: 'info', customStyle: { whiteSpace: 'pre-wrap', maxWidth: '800px' }, confirmButtonText: '关闭' },
|
||||
)
|
||||
} catch (e: unknown) {
|
||||
ElMessage.error('读取日志失败: ' + (e instanceof Error ? e.message : String(e)))
|
||||
}
|
||||
}
|
||||
</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-select
|
||||
v-model="cloudPreset"
|
||||
placeholder="无"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
@change="handlePresetChange"
|
||||
>
|
||||
<el-option value="aliyun" label="阿里云 MQTT" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 阿里云预设配置 -->
|
||||
<template v-if="cloudPreset === 'aliyun'">
|
||||
<div class="section-title">
|
||||
<span>阿里云 MQTT 配置</span>
|
||||
<div class="section-actions">
|
||||
<span class="autosave-hint">修改自动保存</span>
|
||||
<el-button size="small" @click="handleImportPresetsClick">导入预设</el-button>
|
||||
<el-button size="small" :disabled="presets.length === 0" @click="handleExportPresets">导出预设</el-button>
|
||||
</div>
|
||||
<input ref="importInput" type="file" accept=".json" style="display: none" @change="handleImportPresetsFile" />
|
||||
</div>
|
||||
|
||||
<!-- 预设分组筛选 -->
|
||||
<div v-if="presetGroups.length > 0" class="preset-group-tabs">
|
||||
<span
|
||||
:class="['preset-tab', presetGroupFilter === null && 'active']"
|
||||
@click="presetGroupFilter = null"
|
||||
>全部</span>
|
||||
<span
|
||||
:class="['preset-tab', presetGroupFilter === '__ungrouped__' && 'active']"
|
||||
@click="presetGroupFilter = '__ungrouped__'"
|
||||
>未分组</span>
|
||||
<span
|
||||
v-for="g in presetGroups"
|
||||
:key="g"
|
||||
:class="['preset-tab', presetGroupFilter === g && 'active']"
|
||||
@click="presetGroupFilter = g"
|
||||
>
|
||||
{{ g }}
|
||||
<el-dropdown trigger="click" @command="(cmd: string) => cmd === 'rename' ? handleRenamePresetGroup(g) : handleDeletePresetGroup(g)">
|
||||
<span class="preset-tab-menu" @click.stop>…</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="rename">重命名</el-dropdown-item>
|
||||
<el-dropdown-item command="delete" divided>删除</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 预设选择 -->
|
||||
<el-row :gutter="8">
|
||||
<el-col :span="16">
|
||||
<el-form-item label="选择预设">
|
||||
<el-select
|
||||
:model-value="selectedPresetId"
|
||||
placeholder="选择已保存的预设"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
@change="(val: string) => handleSelectPreset(val)"
|
||||
>
|
||||
<el-option
|
||||
v-for="p in filteredPresets"
|
||||
:key="p.id"
|
||||
:value="p.id"
|
||||
:label="p.name || `${p.instanceId} / ${p.groupId}`"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8" style="display: flex; align-items: flex-end; gap: 4px; padding-bottom: 18px;">
|
||||
<el-button size="small" @click="handleNewPreset">新建</el-button>
|
||||
<el-button size="small" type="danger" :disabled="!selectedPresetId" @click="handleDeletePreset">删除</el-button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item v-if="aliyunPreset" label="预设名称(可选)">
|
||||
<el-input v-model="aliyunPreset.name" placeholder="如:生产环境-门禁设备" />
|
||||
</el-form-item>
|
||||
|
||||
<template v-if="aliyunPreset">
|
||||
<el-form-item v-if="presetGroups.length > 0 || aliyunPreset.group" label="预设分组">
|
||||
<el-select v-model="aliyunPreset.group" placeholder="未分组" clearable style="width: 100%">
|
||||
<el-option v-for="g in presetGroups" :key="g" :label="g" :value="g" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="实例 ID">
|
||||
<el-input v-model="aliyunPreset.instanceId" placeholder="post-cn-xxx" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="Group ID">
|
||||
<el-input v-model="aliyunPreset.groupId" placeholder="GID-prod" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="Access Key ID">
|
||||
<el-input v-model="aliyunPreset.accessKeyId" placeholder="LTAI4xxx" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="Access Key Secret">
|
||||
<el-input
|
||||
v-model="aliyunPreset.accessKeySecret"
|
||||
type="password"
|
||||
show-password
|
||||
placeholder="K24iRxxx"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="设备 ID">
|
||||
<el-input v-model="aliyunPreset.deviceId" placeholder="device_xxx">
|
||||
<template #append>
|
||||
<el-button @click="aliyunPreset!.deviceId = `device_${Math.random().toString(36).slice(2, 10)}`">
|
||||
重新生成
|
||||
</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<el-divider border-style="dashed" />
|
||||
</template>
|
||||
|
||||
<el-form-item label="名称">
|
||||
<el-input v-model="form.name" placeholder="给连接起个名字" />
|
||||
</el-form-item>
|
||||
<el-form-item label="分组">
|
||||
<el-select v-model="form.group" placeholder="未分组" clearable style="width: 100%">
|
||||
<el-option v-for="opt in connectionGroupOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||
</el-select>
|
||||
</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" controls-position="right" 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-row :gutter="12">
|
||||
<el-col :span="16">
|
||||
<el-form-item label="Client ID">
|
||||
<el-input v-model="form.clientId" placeholder="客户端 ID">
|
||||
<template #append>
|
||||
<el-button @click="generateClientId">随机生成</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="协议版本">
|
||||
<el-select v-model="form.protocolVersion" style="width: 100%">
|
||||
<el-option value="5.0" label="MQTT 5.0" />
|
||||
<el-option value="3.1.1" label="MQTT 3.1.1" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- MQTT 选项 -->
|
||||
<div class="section-title">
|
||||
{{ form.protocolVersion === '5.0' ? 'MQTT v5 选项' : 'MQTT 选项' }}
|
||||
</div>
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="8">
|
||||
<el-form-item :label="form.protocolVersion === '5.0' ? 'Clean Start' : 'Clean Session'">
|
||||
<el-switch v-model="form.cleanStart" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col v-if="form.protocolVersion === '5.0'" :span="8">
|
||||
<el-form-item label="Session Expiry">
|
||||
<el-input-number v-model="form.sessionExpiryInterval" :min="0" :value-on-clear="null" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="Keep Alive">
|
||||
<el-input-number v-model="form.keepAlive" :min="0" :max="65535" :value-on-clear="null" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row v-if="form.protocolVersion === '5.0'" :gutter="12">
|
||||
<el-col :span="8">
|
||||
<el-form-item label="Receive Maximum">
|
||||
<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>
|
||||
<div style="display: flex; justify-content: space-between; width: 100%;">
|
||||
<el-button type="info" link @click="handleViewDebugLog">查看调试日志</el-button>
|
||||
<div>
|
||||
<el-button @click="handleCancel">取消</el-button>
|
||||
<el-button :loading="testing" :disabled="testing" @click="handleTestConnection">测试连接</el-button>
|
||||
<el-button type="primary" @click="handleSave">保存</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-primary);
|
||||
margin-bottom: 10px;
|
||||
padding-top: 6px;
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
.section-actions { display: flex; align-items: center; gap: 4px; font-weight: normal; }
|
||||
.autosave-hint { font-size: 12px; color: var(--el-text-color-secondary); margin-right: 4px; }
|
||||
|
||||
.preset-group-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.preset-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
.preset-tab:hover { background: var(--el-fill-color); }
|
||||
.preset-tab.active { background: var(--el-color-primary-light-9); color: var(--el-color-primary); font-weight: 500; }
|
||||
.preset-tab-menu { opacity: 0.5; font-size: 14px; }
|
||||
.preset-tab-menu:hover { opacity: 1; }
|
||||
</style>
|
||||
@@ -1,369 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } 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,
|
||||
getGroups,
|
||||
renameGroup,
|
||||
deleteGroup,
|
||||
exportConnections,
|
||||
importConnections,
|
||||
} = useConnections()
|
||||
|
||||
const showForm = ref(false)
|
||||
const editingConnection = ref<MqttConnection | null>(null)
|
||||
const selectedGroup = ref<string | null>(null) // null = 全部
|
||||
const importInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
// ─── 分组 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const groups = computed(() => getGroups())
|
||||
|
||||
const filteredConnections = computed(() => {
|
||||
if (selectedGroup.value === null) return connections.value
|
||||
if (selectedGroup.value === '__ungrouped__') {
|
||||
return connections.value.filter((c) => !c.group)
|
||||
}
|
||||
return connections.value.filter((c) => c.group === selectedGroup.value)
|
||||
})
|
||||
|
||||
const groupCounts = computed(() => {
|
||||
const map: Record<string, number> = {}
|
||||
for (const c of connections.value) {
|
||||
const key = c.group || '__ungrouped__'
|
||||
map[key] = (map[key] || 0) + 1
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
/** 列表内就地修改连接分组(清空 = 未分组,输入新名称 = 直接创建分组) */
|
||||
function handleGroupChange(conn: MqttConnection, group: string | undefined) {
|
||||
update(conn.id, { group: group || '' })
|
||||
}
|
||||
|
||||
async function handleRenameGroup(group: string) {
|
||||
const { value } = await ElMessageBox.prompt('重命名分组', '重命名', {
|
||||
inputValue: group,
|
||||
inputPattern: /\S+/,
|
||||
inputErrorMessage: '分组名称不能为空',
|
||||
}).catch(() => ({ value: undefined }))
|
||||
if (value && value !== group) {
|
||||
renameGroup(group, value)
|
||||
if (selectedGroup.value === group) selectedGroup.value = value
|
||||
ElMessage.success('分组已重命名')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteGroup(group: string) {
|
||||
const count = groupCounts.value[group] || 0
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`删除分组 "${group}"?组内 ${count} 个连接将变为未分组`,
|
||||
'确认删除',
|
||||
{ type: 'warning' },
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
deleteGroup(group)
|
||||
if (selectedGroup.value === group) selectedGroup.value = null
|
||||
ElMessage.success('分组已删除')
|
||||
}
|
||||
|
||||
// ─── 连接操作 ──────────────────────────────────────────────────────────────
|
||||
|
||||
async function handleConnect(conn: MqttConnection) {
|
||||
try {
|
||||
updateStatus(conn.id, 'connecting')
|
||||
await mqttConnect(toConnectionDto(conn))
|
||||
} 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(editingConnection.value.id, data)
|
||||
ElMessage.success('连接已更新')
|
||||
} else {
|
||||
const newConn = { ...data, id: crypto.randomUUID() }
|
||||
add()
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 导入 / 导出 ──────────────────────────────────────────────────────────
|
||||
|
||||
function handleExport() {
|
||||
const json = exportConnections()
|
||||
const blob = new Blob([json], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `mqttx_connections_${new Date().toISOString().slice(0, 10)}.json`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
ElMessage.success(`已导出 ${connections.value.length} 个连接(含明文密码,请妥善保管)`)
|
||||
}
|
||||
|
||||
function handleImportClick() {
|
||||
importInput.value?.click()
|
||||
}
|
||||
|
||||
async function handleImportFile(event: Event) {
|
||||
const file = (event.target as HTMLInputElement).files?.[0]
|
||||
if (!file) return
|
||||
try {
|
||||
const text = await file.text()
|
||||
const { imported, skipped } = importConnections(text)
|
||||
if (imported === 0 && skipped > 0) {
|
||||
ElMessage.warning(`全部 ${skipped} 个连接与现有内容重复,未导入`)
|
||||
} else {
|
||||
ElMessage.success(skipped > 0 ? `导入 ${imported} 个连接,跳过 ${skipped} 个重复` : `成功导入 ${imported} 个连接`)
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error(`导入失败: ${e}`)
|
||||
} finally {
|
||||
if (importInput.value) importInput.value.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 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>
|
||||
<div class="header-actions">
|
||||
<el-button size="small" @click="handleImportClick">导入</el-button>
|
||||
<el-button size="small" :disabled="connections.length === 0" @click="handleExport">导出</el-button>
|
||||
<el-button type="primary" size="small" @click="handleAdd">+ 新增连接</el-button>
|
||||
</div>
|
||||
<input ref="importInput" type="file" accept=".json" style="display: none" @change="handleImportFile" />
|
||||
</div>
|
||||
|
||||
<!-- 分组侧栏 -->
|
||||
<div v-if="groups.length > 0" class="group-tabs">
|
||||
<div
|
||||
:class="['group-tab', selectedGroup === null && 'active']"
|
||||
@click="selectedGroup = null"
|
||||
>
|
||||
全部
|
||||
<span class="badge">{{ connections.length }}</span>
|
||||
</div>
|
||||
<div
|
||||
:class="['group-tab', selectedGroup === '__ungrouped__' && 'active']"
|
||||
@click="selectedGroup = '__ungrouped__'"
|
||||
>
|
||||
未分组
|
||||
<span class="badge">{{ groupCounts['__ungrouped__'] || 0 }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="g in groups"
|
||||
:key="g"
|
||||
:class="['group-tab', selectedGroup === g && 'active']"
|
||||
@click="selectedGroup = g"
|
||||
>
|
||||
<span class="group-name">{{ g }}</span>
|
||||
<span class="badge">{{ groupCounts[g] || 0 }}</span>
|
||||
<el-dropdown trigger="click" @command="(cmd: string) => cmd === 'rename' ? handleRenameGroup(g) : handleDeleteGroup(g)">
|
||||
<span class="group-menu" @click.stop>…</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="rename">重命名</el-dropdown-item>
|
||||
<el-dropdown-item command="delete" divided>删除分组</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 连接表格 -->
|
||||
<el-table :data="filteredConnections" style="width: 100%" stripe>
|
||||
<el-table-column prop="name" label="名称" min-width="120" />
|
||||
<el-table-column label="分组" width="130">
|
||||
<template #default="scope">
|
||||
<el-select
|
||||
:model-value="scope.row.group"
|
||||
placeholder="未分组"
|
||||
size="small"
|
||||
filterable
|
||||
allow-create
|
||||
default-first-option
|
||||
clearable
|
||||
style="width: 100%"
|
||||
@change="(g: string) => handleGroupChange(scope.row, g)"
|
||||
>
|
||||
<el-option v-for="g in groups" :key="g" :label="g" :value="g" />
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<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="scope">
|
||||
<el-tag size="small">{{ scope.row.protocolVersion === '3.1.1' ? 'v3' : '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="filteredConnections.length === 0" class="empty-hint">
|
||||
{{ connections.length === 0 ? '暂无连接,点击上方按钮创建' : '当前分组下暂无连接' }}
|
||||
</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; }
|
||||
.header-actions { display: flex; gap: 6px; }
|
||||
|
||||
/* 分组标签 */
|
||||
.group-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.group-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
background: var(--el-fill-color-light);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.group-tab:hover { background: var(--el-fill-color); }
|
||||
.group-tab.active { background: var(--el-color-primary-light-9); color: var(--el-color-primary); font-weight: 500; }
|
||||
.group-name { max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.badge {
|
||||
font-size: 11px;
|
||||
padding: 0 5px;
|
||||
border-radius: 8px;
|
||||
background: var(--el-fill-color-dark);
|
||||
color: var(--el-text-color-secondary);
|
||||
line-height: 16px;
|
||||
}
|
||||
.group-tab.active .badge { background: var(--el-color-primary); color: #fff; }
|
||||
.group-menu { cursor: pointer; font-size: 14px; opacity: 0.5; }
|
||||
.group-menu:hover { opacity: 1; }
|
||||
|
||||
.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>
|
||||
@@ -1,422 +0,0 @@
|
||||
<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>
|
||||
@@ -1,72 +0,0 @@
|
||||
<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>
|
||||
@@ -1,190 +0,0 @@
|
||||
<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: boolean) => 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>
|
||||
@@ -1,61 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { JsonEditor } from '@visual-json/vue'
|
||||
import type { JsonValue } from '@visual-json/core'
|
||||
|
||||
defineProps<{
|
||||
value: JsonValue
|
||||
height?: string | number
|
||||
}>()
|
||||
|
||||
/**
|
||||
* visual-json 的 readOnly 只阻止 change 事件,并不会真的禁用编辑 UI
|
||||
* (双击值、Delete 键、右键菜单、拖拽重排都还能改树)。
|
||||
* 这里在捕获阶段拦截这些编辑入口,保留选中/复制/搜索/滚动等查看能力。
|
||||
*/
|
||||
const EDIT_KEYS = ['Delete', 'Backspace', 'Enter', 'F2', 'Insert']
|
||||
|
||||
function guardMutation(e: Event) {
|
||||
const target = e.target as HTMLElement | null
|
||||
const isFormField = !!target && (
|
||||
target.tagName === 'INPUT' ||
|
||||
target.tagName === 'TEXTAREA' ||
|
||||
target.isContentEditable
|
||||
)
|
||||
|
||||
if (e instanceof KeyboardEvent) {
|
||||
// 搜索框里的输入/回车放行;其余位置的编辑键全部拦截
|
||||
if (isFormField) return
|
||||
if (EDIT_KEYS.includes(e.key)) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// dblclick / contextmenu / dragstart
|
||||
if (!isFormField) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="json-viewer"
|
||||
@dblclick.capture="guardMutation"
|
||||
@contextmenu.capture="guardMutation"
|
||||
@dragstart.capture="guardMutation"
|
||||
@keydown.capture="guardMutation"
|
||||
>
|
||||
<JsonEditor :value="value" read-only :tree-show-values="true" :height="height" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.json-viewer {
|
||||
border: 1px solid var(--el-border-color-extra-light);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -1,229 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick } from 'vue'
|
||||
import { useLogs } from '../../stores/useLogs'
|
||||
import { mqttGetLogDir } from '../../stores/useMqttBridge'
|
||||
import { prettyJson } from '../../utils/format'
|
||||
import HoverPreview from '../common/HoverPreview.vue'
|
||||
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 { openPath } = await import('@tauri-apps/plugin-opener')
|
||||
await openPath(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">
|
||||
<HoverPreview :text="prettyJson(log.details)">{{ prettyJson(log.details) }}</HoverPreview>
|
||||
</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>
|
||||
@@ -1,400 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick } from 'vue'
|
||||
import type { MqttMessage } from '../../types/mqtt'
|
||||
import { prettyJson, isJson, truncate } from '../../utils/format'
|
||||
import type { JsonValue } from '@visual-json/core'
|
||||
import HoverPreview from '../common/HoverPreview.vue'
|
||||
import JsonViewer from './JsonViewer.vue'
|
||||
|
||||
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 collapsedPayloads = 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 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': return prettyJson(msg.payload)
|
||||
case 'hex': return bytesToHex(new TextEncoder().encode(msg.payload))
|
||||
case 'base64': return btoa(msg.payload)
|
||||
case 'plain':
|
||||
default: return msg.payload
|
||||
}
|
||||
}
|
||||
|
||||
function getJsonValue(msg: MqttMessage): JsonValue {
|
||||
try {
|
||||
return JSON.parse(msg.payload) as JsonValue
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
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(collapsedPayloads.value)
|
||||
if (s.has(msgId)) s.delete(msgId)
|
||||
else s.add(msgId)
|
||||
collapsedPayloads.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="!collapsedPayloads.has(msg.id)" points="18 15 12 9 6 15"/>
|
||||
<polyline v-else points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
{{ !collapsedPayloads.has(msg.id) ? '收起' : '展开' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Payload Content -->
|
||||
<div class="msg-payload" :class="{ expanded: !collapsedPayloads.has(msg.id) }">
|
||||
<template v-if="!collapsedPayloads.has(msg.id)">
|
||||
<JsonViewer
|
||||
v-if="getEffectiveFormat(msg) === 'json'"
|
||||
:value="getJsonValue(msg)"
|
||||
height="320"
|
||||
class="msg-json-viewer"
|
||||
/>
|
||||
<pre v-else class="payload-full"><HoverPreview :text="formatPayload(msg)">{{ formatPayload(msg) }}</HoverPreview></pre>
|
||||
</template>
|
||||
<pre v-else class="payload-truncated">
|
||||
<HoverPreview :text="prettyJson(msg.payload)">{{ truncate(prettyJson(msg.payload), 200) }}</HoverPreview>
|
||||
</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-json-viewer {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.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-truncated {
|
||||
max-height: 1.4em;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -1,902 +0,0 @@
|
||||
<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 { prettyJson } from '../../utils/format'
|
||||
import { JsonEditor } from '@visual-json/vue'
|
||||
import type { JsonValue } from '@visual-json/core'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import HoverPreview from '../common/HoverPreview.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 activePresetId = ref<string | null>(null)
|
||||
const showVariables = ref(false)
|
||||
const showV5Props = ref(false)
|
||||
const showGlobalVarMgr = ref(false)
|
||||
const showAddPopover = ref(false)
|
||||
const sending = ref(false)
|
||||
const dragIndex = ref<number | null>(null)
|
||||
const dragOverIndex = ref<number | null>(null)
|
||||
const payloadMode = ref<'text' | 'json'>('text')
|
||||
|
||||
/** Ordered list of pinned preset IDs (the tab bar) */
|
||||
const pinnedIds = ref<string[]>([])
|
||||
|
||||
// Per-preset editor state (keyed by preset id)
|
||||
interface EditorState {
|
||||
topic: string
|
||||
payload: string
|
||||
qos: 0 | 1 | 2
|
||||
retain: boolean
|
||||
variableBindings: Record<string, string>
|
||||
userProperties: { key: string; value: string }[]
|
||||
contentType: string
|
||||
messageExpiryInterval: number | null
|
||||
responseTopic: string
|
||||
correlationData: string
|
||||
}
|
||||
const editorStates = ref<Record<string, EditorState>>({})
|
||||
|
||||
// ─── Computed ──────────────────────────────────────────────────
|
||||
|
||||
const connectedConnections = computed(() =>
|
||||
props.connections.filter((c) => c.status === 'connected'),
|
||||
)
|
||||
|
||||
const selectedConnection = computed(() =>
|
||||
props.connections.find((c) => c.id === selectedConnectionId.value),
|
||||
)
|
||||
|
||||
/** MQTT 3.1.1 连接不支持 v5 属性 */
|
||||
const isV5Connection = computed(() => selectedConnection.value?.protocolVersion !== '3.1.1')
|
||||
|
||||
const activePreset = computed(() =>
|
||||
props.presets.find((p) => p.id === activePresetId.value),
|
||||
)
|
||||
|
||||
const activeState = computed(() =>
|
||||
activePresetId.value ? editorStates.value[activePresetId.value] : null,
|
||||
)
|
||||
|
||||
const payloadJsonValid = computed(() => {
|
||||
if (!activeState.value) return false
|
||||
try {
|
||||
JSON.parse(activeState.value.payload)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
const payloadJsonValue = computed<JsonValue>(() => {
|
||||
try {
|
||||
return JSON.parse(activeState.value?.payload ?? '') as JsonValue
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
})
|
||||
|
||||
/** Presets that are pinned (in tab order) */
|
||||
const pinnedPresets = computed(() => {
|
||||
const map = new Map(props.presets.map((p) => [p.id, p]))
|
||||
return pinnedIds.value.map((id) => map.get(id)).filter(Boolean) as Preset[]
|
||||
})
|
||||
|
||||
/** Presets not yet pinned, available to add */
|
||||
const unpinnedPresets = computed(() =>
|
||||
props.presets.filter((p) => !pinnedIds.value.includes(p.id)),
|
||||
)
|
||||
|
||||
const presetVariables = computed(() => {
|
||||
if (!activePreset.value) return []
|
||||
const vars = extractVariables(activePreset.value.payloadTemplate)
|
||||
for (const v of extractVariables(activePreset.value.topic)) {
|
||||
if (!vars.includes(v)) vars.push(v)
|
||||
}
|
||||
for (const up of activePreset.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
|
||||
})
|
||||
|
||||
// ─── Init editor state for a preset ────────────────────────────
|
||||
|
||||
function initEditorState(preset: Preset) {
|
||||
if (editorStates.value[preset.id]) return
|
||||
|
||||
const allVars = [...new Set([
|
||||
...extractVariables(preset.payloadTemplate),
|
||||
...extractVariables(preset.topic),
|
||||
...preset.userProperties.flatMap(up => [
|
||||
...extractVariables(up.key),
|
||||
...extractVariables(up.value),
|
||||
]),
|
||||
])]
|
||||
|
||||
const bindings: Record<string, string> = {}
|
||||
for (const v of allVars) {
|
||||
bindings[v] = globalVars.value[v] ?? ''
|
||||
}
|
||||
|
||||
editorStates.value[preset.id] = {
|
||||
topic: preset.topic,
|
||||
payload: preset.payloadTemplate,
|
||||
qos: preset.qos,
|
||||
retain: preset.retain,
|
||||
variableBindings: bindings,
|
||||
userProperties: JSON.parse(JSON.stringify(preset.userProperties)),
|
||||
contentType: '',
|
||||
messageExpiryInterval: null,
|
||||
responseTopic: '',
|
||||
correlationData: '',
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Pin / Unpin / Select ──────────────────────────────────────
|
||||
|
||||
function pinPreset(presetId: string) {
|
||||
if (pinnedIds.value.includes(presetId)) return
|
||||
const preset = props.presets.find((p) => p.id === presetId)
|
||||
if (!preset) return
|
||||
initEditorState(preset)
|
||||
pinnedIds.value.push(presetId)
|
||||
activePresetId.value = presetId
|
||||
showVariables.value = presetVariables.value.length > 0
|
||||
showAddPopover.value = false
|
||||
}
|
||||
|
||||
function unpinPreset(id: string) {
|
||||
pinnedIds.value = pinnedIds.value.filter((pid) => pid !== id)
|
||||
if (activePresetId.value === id) {
|
||||
const remaining = pinnedIds.value
|
||||
activePresetId.value = remaining.length > 0 ? remaining[0] : null
|
||||
}
|
||||
}
|
||||
|
||||
function selectPreset(presetId: string) {
|
||||
const preset = props.presets.find((p) => p.id === presetId)
|
||||
if (!preset) return
|
||||
initEditorState(preset)
|
||||
activePresetId.value = presetId
|
||||
showVariables.value = presetVariables.value.length > 0
|
||||
}
|
||||
|
||||
watch(activePresetId, () => {
|
||||
payloadMode.value = 'text'
|
||||
})
|
||||
|
||||
// ─── Drag and drop ─────────────────────────────────────────────
|
||||
|
||||
function onDragStart(index: number) {
|
||||
dragIndex.value = index
|
||||
}
|
||||
|
||||
function onDragOver(e: DragEvent, index: number) {
|
||||
e.preventDefault()
|
||||
dragOverIndex.value = index
|
||||
}
|
||||
|
||||
function onDragLeave() {
|
||||
dragOverIndex.value = null
|
||||
}
|
||||
|
||||
function onDrop(index: number) {
|
||||
if (dragIndex.value === null || dragIndex.value === index) {
|
||||
dragIndex.value = null
|
||||
dragOverIndex.value = null
|
||||
return
|
||||
}
|
||||
const arr = [...pinnedIds.value]
|
||||
const [removed] = arr.splice(dragIndex.value, 1)
|
||||
arr.splice(index, 0, removed)
|
||||
pinnedIds.value = arr
|
||||
dragIndex.value = null
|
||||
dragOverIndex.value = null
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
dragIndex.value = null
|
||||
dragOverIndex.value = null
|
||||
}
|
||||
|
||||
// ─── Send ──────────────────────────────────────────────────────
|
||||
|
||||
function getPublishDto(): PublishDto | null {
|
||||
if (!activePreset.value || !activeState.value) return null
|
||||
const st = activeState.value
|
||||
const resolvedTopic = resolveTemplate(st.topic, st.variableBindings)
|
||||
const resolvedPayload = resolveTemplate(st.payload, st.variableBindings)
|
||||
const resolvedProps = resolveUserProperties(st.userProperties, st.variableBindings)
|
||||
|
||||
return {
|
||||
connection_id: selectedConnectionId.value,
|
||||
topic: resolvedTopic,
|
||||
payload: resolvedPayload,
|
||||
qos: st.qos,
|
||||
retain: st.retain,
|
||||
user_properties: resolvedProps,
|
||||
content_type: st.contentType || null,
|
||||
message_expiry_interval: st.messageExpiryInterval,
|
||||
response_topic: st.responseTopic || null,
|
||||
correlation_data: st.correlationData || null,
|
||||
}
|
||||
}
|
||||
|
||||
async function send() {
|
||||
if (!selectedConnectionId.value) {
|
||||
ElMessage.warning('请先选择一个连接')
|
||||
return
|
||||
}
|
||||
if (!activePreset.value || !activeState.value) {
|
||||
ElMessage.warning('请选择一个预设')
|
||||
return
|
||||
}
|
||||
importFromBindings(activeState.value.variableBindings)
|
||||
sending.value = true
|
||||
try {
|
||||
const dto = getPublishDto()
|
||||
if (!dto) { ElMessage.warning('预设数据异常'); return }
|
||||
if (!dto.topic.trim()) { ElMessage.warning('主题为空'); return }
|
||||
if (!dto.payload.trim()) { ElMessage.warning('Payload 为空'); return }
|
||||
await mqttPublish(dto)
|
||||
emit('sent', dto)
|
||||
ElMessage.success(`"${activePreset.value.name}" 已发送`)
|
||||
} catch (e: any) {
|
||||
ElMessage.error(`发送失败: ${e}`)
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Variable binding changed ──────────────────────────────────
|
||||
|
||||
function onBindingChange(name: string, value: string) {
|
||||
if (!activePresetId.value || !activeState.value) return
|
||||
activeState.value.variableBindings = { ...activeState.value.variableBindings, [name]: value }
|
||||
if (value.trim()) {
|
||||
setGlobalVar(name, value)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── JSON 可视化模式 ───────────────────────────────────────────
|
||||
|
||||
function tryEnableJsonMode() {
|
||||
if (!activeState.value) return
|
||||
if (!payloadJsonValid.value) {
|
||||
ElMessage.warning('当前 Payload 不是合法 JSON,请先在文本模式编辑或解析 {{变量}}')
|
||||
return
|
||||
}
|
||||
payloadMode.value = 'json'
|
||||
}
|
||||
|
||||
function onJsonPayloadChange(value: JsonValue) {
|
||||
if (!activeState.value) return
|
||||
activeState.value.payload = JSON.stringify(value, null, 2)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="send-panel">
|
||||
<!-- Connection selector -->
|
||||
<div class="conn-bar">
|
||||
<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>
|
||||
|
||||
<!-- Preset tabs bar -->
|
||||
<div class="preset-tabs">
|
||||
<div class="pt-scroll">
|
||||
<!-- Pinned tabs -->
|
||||
<div
|
||||
v-for="(p, idx) in pinnedPresets"
|
||||
:key="p.id"
|
||||
draggable="true"
|
||||
:class="[
|
||||
'preset-tab',
|
||||
{
|
||||
active: activePresetId === p.id,
|
||||
'drag-over': dragOverIndex === idx && dragIndex !== idx,
|
||||
'dragging': dragIndex === idx,
|
||||
}
|
||||
]"
|
||||
@click="selectPreset(p.id)"
|
||||
@dragstart="onDragStart(idx)"
|
||||
@dragover="(e) => onDragOver(e, idx)"
|
||||
@dragleave="onDragLeave"
|
||||
@drop="onDrop(idx)"
|
||||
@dragend="onDragEnd"
|
||||
>
|
||||
<svg class="pt-grip" width="10" height="10" viewBox="0 0 10 10" fill="currentColor"><circle cx="3" cy="2" r="1"/><circle cx="7" cy="2" r="1"/><circle cx="3" cy="5" r="1"/><circle cx="7" cy="5" r="1"/><circle cx="3" cy="8" r="1"/><circle cx="7" cy="8" r="1"/></svg>
|
||||
<span class="pt-name">{{ p.name }}</span>
|
||||
<span class="pt-topic">{{ p.topic }}</span>
|
||||
<span
|
||||
class="pt-close"
|
||||
@click.stop="unpinPreset(p.id)"
|
||||
title="移除标签"
|
||||
>×</span>
|
||||
</div>
|
||||
|
||||
<!-- Add button -->
|
||||
<el-dropdown
|
||||
v-if="unpinnedPresets.length > 0"
|
||||
trigger="click"
|
||||
placement="bottom-start"
|
||||
@command="pinPreset"
|
||||
>
|
||||
<button class="pt-add-btn" @click.stop>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
</button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item
|
||||
v-for="p in unpinnedPresets"
|
||||
:key="p.id"
|
||||
:command="p.id"
|
||||
>
|
||||
<span class="add-item-name">{{ p.name }}</span>
|
||||
<span class="add-item-topic">{{ p.topic }}</span>
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
|
||||
<div v-if="pinnedIds.length === 0" class="pt-empty">
|
||||
<svg width="14" height="14" 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>
|
||||
<span>点击 + 添加预设标签</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Editor area for active preset -->
|
||||
<div v-if="activePreset && activeState" class="editor-area">
|
||||
<!-- Row: Topic + QoS + Retain -->
|
||||
<div class="form-row">
|
||||
<div class="field" style="flex: 1">
|
||||
<label>主题</label>
|
||||
<el-input v-model="activeState.topic" size="small" placeholder="sensor/temp" />
|
||||
</div>
|
||||
<div class="field" style="flex: 0 0 90px">
|
||||
<label>QoS</label>
|
||||
<el-select v-model="activeState.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="activeState.retain" size="small" label="Retain" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row: Payload -->
|
||||
<div class="form-row">
|
||||
<div class="field" style="flex: 1">
|
||||
<div class="payload-head">
|
||||
<label>Payload</label>
|
||||
<div class="payload-mode">
|
||||
<span
|
||||
:class="['pm-tab', { active: payloadMode === 'text' }]"
|
||||
@click="payloadMode = 'text'"
|
||||
>文本</span>
|
||||
<span
|
||||
:class="['pm-tab', { active: payloadMode === 'json' }]"
|
||||
@click="tryEnableJsonMode"
|
||||
>JSON 可视化</span>
|
||||
</div>
|
||||
</div>
|
||||
<HoverPreview v-if="payloadMode === 'text'" :text="prettyJson(activeState.payload)" :delay="600">
|
||||
<el-input
|
||||
v-model="activeState.payload"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder='{"temp":{{temp}},"device":"{{deviceId}}"}'
|
||||
/>
|
||||
</HoverPreview>
|
||||
<div v-else-if="payloadMode === 'json'" class="json-editor-box">
|
||||
<JsonEditor
|
||||
v-if="payloadJsonValid"
|
||||
:value="payloadJsonValue"
|
||||
height="260"
|
||||
@change="onJsonPayloadChange"
|
||||
/>
|
||||
<div v-else class="json-editor-fallback">
|
||||
Payload 已不是合法 JSON,请切回文本模式
|
||||
</div>
|
||||
</div>
|
||||
</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
|
||||
v-if="isV5Connection"
|
||||
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 || !activeState"
|
||||
@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>
|
||||
发送 {{ activePreset?.name }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- Variable editor -->
|
||||
<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="activeState.variableBindings[v] ?? ''"
|
||||
size="small"
|
||||
placeholder="输入值..."
|
||||
@input="(val: string) => onBindingChange(v, val)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<!-- v5 Props -->
|
||||
<transition name="slide">
|
||||
<div v-if="showV5Props && isV5Connection" class="expand-section">
|
||||
<div class="v5-grid">
|
||||
<div class="field">
|
||||
<label>Content Type</label>
|
||||
<el-input v-model="activeState.contentType" size="small" placeholder="json" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>消息过期(秒)</label>
|
||||
<el-input-number
|
||||
v-model="activeState.messageExpiryInterval"
|
||||
:min="0"
|
||||
size="small"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Response Topic</label>
|
||||
<el-input v-model="activeState.responseTopic" size="small" placeholder="sensor/response" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Correlation Data</label>
|
||||
<el-input v-model="activeState.correlationData" size="small" placeholder="hex string" />
|
||||
</div>
|
||||
</div>
|
||||
<UserPropertiesEditor v-model="activeState.userProperties" />
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-else-if="pinnedIds.length > 0" class="empty-state">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="color: var(--comfort-text-muted)"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
|
||||
<span class="empty-text">点击上方标签页开始编辑</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Global Variable Manager Dialog -->
|
||||
<GlobalVariableManager v-model:show="showGlobalVarMgr" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.send-panel {
|
||||
border: 1px solid var(--comfort-border, var(--el-border-color));
|
||||
border-radius: 8px;
|
||||
background: var(--comfort-bg-card, var(--el-bg-color));
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ─── Connection bar ─────────────────────────────────────────── */
|
||||
.conn-bar {
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--comfort-border-light, var(--el-border-color-light));
|
||||
}
|
||||
|
||||
/* ─── Preset Tabs Bar ────────────────────────────────────────── */
|
||||
.preset-tabs {
|
||||
padding: 6px 8px 0;
|
||||
background: var(--comfort-bg-soft, var(--el-fill-color-light));
|
||||
border-bottom: 1px solid var(--comfort-border-light, var(--el-border-color-light));
|
||||
}
|
||||
.pt-scroll {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: thin;
|
||||
align-items: stretch;
|
||||
}
|
||||
.preset-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid transparent;
|
||||
border-bottom: none;
|
||||
border-radius: 6px 6px 0 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
color: var(--comfort-text-secondary, var(--el-text-color-secondary));
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.15s;
|
||||
margin-bottom: -1px;
|
||||
user-select: none;
|
||||
}
|
||||
.preset-tab:hover {
|
||||
color: var(--comfort-text, var(--el-text-color-primary));
|
||||
background: var(--comfort-bg-card, var(--el-bg-color));
|
||||
}
|
||||
.preset-tab.active {
|
||||
color: var(--comfort-primary, var(--el-color-primary));
|
||||
background: var(--comfort-bg-card, var(--el-bg-color));
|
||||
border-color: var(--comfort-border-light, var(--el-border-color-light));
|
||||
font-weight: 600;
|
||||
}
|
||||
.preset-tab.dragging {
|
||||
opacity: 0.4;
|
||||
}
|
||||
.preset-tab.drag-over {
|
||||
border-left-color: var(--comfort-primary, var(--el-color-primary));
|
||||
border-left-width: 2px;
|
||||
}
|
||||
.pt-grip {
|
||||
flex-shrink: 0;
|
||||
color: var(--comfort-text-muted, var(--el-text-color-placeholder));
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
cursor: grab;
|
||||
}
|
||||
.preset-tab:hover .pt-grip {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.pt-grip:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.pt-name {
|
||||
max-width: 80px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.pt-topic {
|
||||
font-family: var(--comfort-font-mono, monospace);
|
||||
font-size: 10px;
|
||||
color: var(--comfort-text-muted, var(--el-text-color-placeholder));
|
||||
max-width: 100px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.pt-close {
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
color: var(--comfort-text-muted, var(--el-text-color-placeholder));
|
||||
flex-shrink: 0;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 3px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.preset-tab:hover .pt-close {
|
||||
opacity: 0.7;
|
||||
}
|
||||
.pt-close:hover {
|
||||
color: var(--comfort-danger, var(--el-color-danger));
|
||||
background: var(--el-color-danger-light-9);
|
||||
opacity: 1 !important;
|
||||
}
|
||||
.pt-add-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 1px dashed var(--comfort-border, var(--el-border-color));
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: var(--comfort-text-muted, var(--el-text-color-placeholder));
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.pt-add-btn:hover {
|
||||
color: var(--comfort-primary, var(--el-color-primary));
|
||||
border-color: var(--comfort-primary-light, var(--el-color-primary-light-5));
|
||||
background: var(--comfort-primary-bg, var(--el-color-primary-light-9));
|
||||
}
|
||||
.pt-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
font-size: 12px;
|
||||
color: var(--comfort-text-muted, var(--el-text-color-placeholder));
|
||||
}
|
||||
.pt-empty svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Add dropdown items */
|
||||
.add-item-name {
|
||||
font-weight: 500;
|
||||
margin-right: 8px;
|
||||
}
|
||||
.add-item-topic {
|
||||
font-family: var(--comfort-font-mono, monospace);
|
||||
font-size: 11px;
|
||||
color: var(--comfort-text-muted, var(--el-text-color-placeholder));
|
||||
}
|
||||
|
||||
/* ─── Editor area ────────────────────────────────────────────── */
|
||||
.editor-area {
|
||||
padding: 8px 12px 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.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(--comfort-text-secondary, var(--el-text-color-secondary));
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ─── Payload mode toggle ────────────────────────────────────── */
|
||||
.payload-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.payload-mode {
|
||||
display: flex;
|
||||
gap: 1px;
|
||||
background: var(--el-fill-color);
|
||||
border-radius: 4px;
|
||||
padding: 1px;
|
||||
}
|
||||
.pm-tab {
|
||||
font-size: 10px;
|
||||
padding: 1px 8px;
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
color: var(--comfort-text-secondary, var(--el-text-color-secondary));
|
||||
transition: all 0.12s;
|
||||
line-height: 18px;
|
||||
user-select: none;
|
||||
}
|
||||
.pm-tab:hover {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
.pm-tab.active {
|
||||
color: var(--el-color-primary);
|
||||
background: var(--comfort-bg-card, var(--el-bg-color));
|
||||
font-weight: 600;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.06);
|
||||
}
|
||||
.json-editor-box {
|
||||
border: 1px solid var(--comfort-border, var(--el-border-color));
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.json-editor-fallback {
|
||||
padding: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--comfort-text-muted, var(--el-text-color-placeholder));
|
||||
}
|
||||
|
||||
/* ─── Empty state ────────────────────────────────────────────── */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 24px;
|
||||
}
|
||||
.empty-text {
|
||||
font-size: 12px;
|
||||
color: var(--comfort-text-muted, var(--el-text-color-placeholder));
|
||||
}
|
||||
|
||||
/* ─── Toggle Row ─────────────────────────────────────────────── */
|
||||
.toggle-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.toggle-btn {
|
||||
--el-button-bg-color: transparent;
|
||||
--el-button-border-color: var(--comfort-border, 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(--comfort-text-secondary, 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: var(--comfort-text, var(--el-color-white));
|
||||
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 ────────────────────────────────────────── */
|
||||
.expand-section {
|
||||
padding: 8px;
|
||||
background: var(--comfort-bg-soft, var(--el-fill-color-light));
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--comfort-border-light, 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(--comfort-text, var(--el-text-color-primary));
|
||||
}
|
||||
.var-editor-hint {
|
||||
font-size: 11px;
|
||||
color: var(--comfort-text-muted, 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: var(--comfort-font-mono, 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>
|
||||
@@ -1,385 +0,0 @@
|
||||
<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'
|
||||
import ContextMenu from '../common/ContextMenu.vue'
|
||||
import type { ContextMenuItem } from '../common/ContextMenu.vue'
|
||||
|
||||
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 ctxMenu = ref<InstanceType<typeof ContextMenu>>()
|
||||
const ctxTopic = ref('')
|
||||
const ctxNode = ref<TopicNode | null>(null)
|
||||
const ctxMenuItems = ref<ContextMenuItem[]>([])
|
||||
|
||||
function handleContextMenu(ev: MouseEvent, node: TopicNode) {
|
||||
ev.preventDefault()
|
||||
ctxTopic.value = node.fullPath
|
||||
ctxNode.value = node
|
||||
const items: ContextMenuItem[] = []
|
||||
|
||||
// Copy topic
|
||||
items.push({
|
||||
label: '复制主题',
|
||||
icon: '📋',
|
||||
action: () => navigator.clipboard.writeText(node.fullPath),
|
||||
})
|
||||
|
||||
// Subscribe / Unsubscribe
|
||||
if (node.isSubscribed) {
|
||||
items.push({
|
||||
label: `取消订阅 (QoS ${node.subscriptionQos})`,
|
||||
icon: '❌',
|
||||
danger: true,
|
||||
action: () => handleUnsubscribe(node.fullPath),
|
||||
})
|
||||
} else if (props.connected) {
|
||||
items.push({
|
||||
label: '订阅 (QoS 0)',
|
||||
icon: '➕',
|
||||
action: () => handleSubscribeTopic(node.fullPath, 0),
|
||||
})
|
||||
items.push({
|
||||
label: '订阅 (QoS 1)',
|
||||
icon: '➕',
|
||||
action: () => handleSubscribeTopic(node.fullPath, 1),
|
||||
})
|
||||
items.push({
|
||||
label: '订阅 (QoS 2)',
|
||||
icon: '➕',
|
||||
action: () => handleSubscribeTopic(node.fullPath, 2),
|
||||
})
|
||||
}
|
||||
|
||||
items.push({ label: '', icon: '', divider: true, action: () => {} })
|
||||
|
||||
// Select topic (filter messages)
|
||||
items.push({
|
||||
label: '筛选此主题',
|
||||
icon: '🔍',
|
||||
action: () => emit('select', node.fullPath),
|
||||
})
|
||||
|
||||
ctxMenuItems.value = items
|
||||
ctxMenu.value?.show(ev, ev.target as HTMLElement)
|
||||
}
|
||||
|
||||
async function handleSubscribeTopic(topic: string, qos: number) {
|
||||
if (!props.connectionId) return
|
||||
try {
|
||||
await mqttSubscribe({ connection_id: props.connectionId, topic, qos })
|
||||
addSubscription(props.connectionId, topic, qos)
|
||||
ElMessage.success(`已订阅: ${topic} (QoS ${qos})`)
|
||||
} catch (e: unknown) {
|
||||
ElMessage.error(`订阅失败: ${e instanceof Error ? e.message : String(e)}`)
|
||||
}
|
||||
}
|
||||
|
||||
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)" @contextmenu.prevent="handleContextMenu($event, 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)" @contextmenu.prevent="handleContextMenu($event, 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)"
|
||||
@contextmenu.prevent="handleContextMenu($event, 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>
|
||||
|
||||
<ContextMenu ref="ctxMenu" :items="ctxMenuItems" />
|
||||
</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>
|
||||
@@ -1,86 +0,0 @@
|
||||
<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>
|
||||
@@ -1,75 +0,0 @@
|
||||
<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>
|
||||
@@ -1,127 +0,0 @@
|
||||
<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>
|
||||
@@ -1,130 +0,0 @@
|
||||
<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 { prettyJson, truncate } from '../../utils/format'
|
||||
import HoverPreview from '../common/HoverPreview.vue'
|
||||
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">
|
||||
<HoverPreview :text="prettyJson(row.payloadTemplate)">{{ truncate(prettyJson(row.payloadTemplate), 50) }}</HoverPreview>
|
||||
</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>
|
||||
@@ -1,41 +0,0 @@
|
||||
import { onMounted, onUnmounted } from 'vue'
|
||||
|
||||
const contextMenuHandlers = new Set<(e: MouseEvent) => void>()
|
||||
|
||||
/**
|
||||
* Register a custom right-click handler.
|
||||
* The handler should call `e.preventDefault()` if it handles the event.
|
||||
*/
|
||||
export function registerContextMenu(handler: (e: MouseEvent) => void) {
|
||||
contextMenuHandlers.add(handler)
|
||||
}
|
||||
|
||||
export function unregisterContextMenu(handler: (e: MouseEvent) => void) {
|
||||
contextMenuHandlers.delete(handler)
|
||||
}
|
||||
|
||||
/**
|
||||
* Global context menu controller.
|
||||
* In production: prevents default browser context menu everywhere.
|
||||
* In debug (dev mode): allows native context menu.
|
||||
* Custom handlers registered via registerContextMenu take priority.
|
||||
*/
|
||||
export function useContextMenuController() {
|
||||
function onContextMenu(e: MouseEvent) {
|
||||
for (const handler of contextMenuHandlers) {
|
||||
handler(e)
|
||||
if (e.defaultPrevented) return
|
||||
}
|
||||
if (import.meta.env.PROD) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('contextmenu', onContextMenu)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('contextmenu', onContextMenu)
|
||||
})
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { check } from '@tauri-apps/plugin-updater'
|
||||
import { relaunch } from '@tauri-apps/plugin-process'
|
||||
|
||||
/**
|
||||
* 更新状态
|
||||
*/
|
||||
export interface UpdateState {
|
||||
/** 是否有可用更新 */
|
||||
available: boolean
|
||||
/** 当前版本 */
|
||||
currentVersion: string
|
||||
/** 新版本号 */
|
||||
version?: string
|
||||
/** 更新日期 */
|
||||
date?: string
|
||||
/** 更新说明 */
|
||||
body?: string
|
||||
/** 下载进度 (0-100) */
|
||||
downloadProgress: number
|
||||
/** 是否正在下载 */
|
||||
downloading: boolean
|
||||
/** 是否正在安装 */
|
||||
installing: boolean
|
||||
/** 错误信息 */
|
||||
error: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Tauri 自动更新 composable
|
||||
*
|
||||
* 用法:
|
||||
* ```ts
|
||||
* const updater = useUpdater()
|
||||
* await updater.checkForUpdates()
|
||||
* ```
|
||||
*/
|
||||
export function useUpdater() {
|
||||
const state = ref<UpdateState>({
|
||||
available: false,
|
||||
currentVersion: '',
|
||||
downloadProgress: 0,
|
||||
downloading: false,
|
||||
installing: false,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const checking = ref(false)
|
||||
|
||||
/**
|
||||
* 检查更新
|
||||
*/
|
||||
async function checkForUpdates(): Promise<void> {
|
||||
checking.value = true
|
||||
state.value.error = null
|
||||
|
||||
try {
|
||||
const update = await check()
|
||||
|
||||
if (update) {
|
||||
state.value.available = true
|
||||
state.value.version = update.version
|
||||
state.value.date = update.date ?? undefined
|
||||
state.value.body = update.body ?? undefined
|
||||
state.value.currentVersion = update.currentVersion ?? ''
|
||||
} else {
|
||||
state.value.available = false
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
// 忽略网络错误(用户可能离线), 只记录不显示错误
|
||||
console.debug('[updater] 检查更新失败:', message)
|
||||
state.value.error = null // 不显示错误给用户,静默失败
|
||||
} finally {
|
||||
checking.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载并安装更新,然后重启应用
|
||||
*/
|
||||
async function downloadAndInstall(): Promise<void> {
|
||||
if (!state.value.available) return
|
||||
|
||||
state.value.downloading = true
|
||||
state.value.error = null
|
||||
|
||||
try {
|
||||
const update = await check()
|
||||
if (!update) {
|
||||
state.value.available = false
|
||||
return
|
||||
}
|
||||
|
||||
// 下载更新(带进度)
|
||||
let downloaded = 0
|
||||
let total = 0
|
||||
|
||||
await update.download((event) => {
|
||||
switch (event.event) {
|
||||
case 'Started':
|
||||
total = event.data.contentLength ?? 0
|
||||
state.value.downloadProgress = 0
|
||||
break
|
||||
case 'Progress':
|
||||
downloaded += event.data.chunkLength
|
||||
if (total > 0) {
|
||||
state.value.downloadProgress = Math.min(
|
||||
100,
|
||||
Math.round((downloaded / total) * 100),
|
||||
)
|
||||
}
|
||||
break
|
||||
case 'Finished':
|
||||
state.value.downloadProgress = 100
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
state.value.downloading = false
|
||||
state.value.installing = true
|
||||
|
||||
// 安装并重启
|
||||
await update.install()
|
||||
await relaunch()
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
state.value.error = message
|
||||
state.value.downloading = false
|
||||
state.value.installing = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 静默检查更新(应用启动时调用,仅当有更新时设置状态)
|
||||
*/
|
||||
async function silentCheck(): Promise<void> {
|
||||
try {
|
||||
const update = await check()
|
||||
if (update) {
|
||||
state.value.available = true
|
||||
state.value.version = update.version
|
||||
state.value.date = update.date ?? undefined
|
||||
state.value.body = update.body ?? undefined
|
||||
state.value.currentVersion = update.currentVersion ?? ''
|
||||
}
|
||||
} catch {
|
||||
// 静默失败,不显示任何错误
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
checking,
|
||||
checkForUpdates,
|
||||
downloadAndInstall,
|
||||
silentCheck,
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { createApp } from "vue";
|
||||
import ElementPlus from "element-plus";
|
||||
import "element-plus/dist/index.css";
|
||||
import "./styles/comfort.css";
|
||||
import App from "./App.vue";
|
||||
|
||||
const app = createApp(App);
|
||||
app.use(ElementPlus);
|
||||
app.mount("#app");
|
||||
@@ -1,125 +0,0 @@
|
||||
import { ref, watch } from 'vue'
|
||||
import type { AliyunPreset } from '../types/mqtt'
|
||||
import { defaultAliyunPreset } from '../types/mqtt'
|
||||
|
||||
const STORAGE_KEY = 'mqttx_aliyun_presets'
|
||||
|
||||
function loadPresets(): AliyunPreset[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return []
|
||||
const parsed = JSON.parse(raw) as AliyunPreset[]
|
||||
return parsed.map((p) => ({
|
||||
...defaultAliyunPreset(),
|
||||
...p,
|
||||
id: p.id || crypto.randomUUID(),
|
||||
group: p.group ?? '',
|
||||
}))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function savePresets(presets: AliyunPreset[]) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(presets))
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const presets = ref<AliyunPreset[]>(loadPresets())
|
||||
|
||||
watch(presets, (val) => savePresets(val), { deep: true })
|
||||
|
||||
export function useAliyunPresets() {
|
||||
function add(preset?: Partial<AliyunPreset>): AliyunPreset {
|
||||
const p: AliyunPreset = { ...defaultAliyunPreset(), ...preset }
|
||||
presets.value.push(p)
|
||||
return p
|
||||
}
|
||||
|
||||
function update(id: string, data: Partial<AliyunPreset>) {
|
||||
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): AliyunPreset | undefined {
|
||||
return presets.value.find((p) => p.id === id)
|
||||
}
|
||||
|
||||
// ─── 分组 ────────────────────────────────────────────────────────────────
|
||||
|
||||
function getGroups(): string[] {
|
||||
const set = new Set<string>()
|
||||
for (const p of presets.value) {
|
||||
if (p.group) set.add(p.group)
|
||||
}
|
||||
return Array.from(set).sort()
|
||||
}
|
||||
|
||||
function renameGroup(oldName: string, newName: string) {
|
||||
for (const p of presets.value) {
|
||||
if (p.group === oldName) p.group = newName
|
||||
}
|
||||
}
|
||||
|
||||
function deleteGroup(group: string) {
|
||||
for (const p of presets.value) {
|
||||
if (p.group === group) p.group = ''
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 导入 / 导出 ────────────────────────────────────────────────────────
|
||||
|
||||
function exportPresets(ids?: string[]): string {
|
||||
const list = ids
|
||||
? presets.value.filter((p) => ids.includes(p.id))
|
||||
: presets.value
|
||||
return JSON.stringify(list, null, 2)
|
||||
}
|
||||
|
||||
/** 按 实例ID+GroupID+设备ID+AK 去重,返回导入/跳过数量 */
|
||||
function importPresets(json: string): { imported: number; skipped: number } {
|
||||
const parsed = JSON.parse(json) as Partial<AliyunPreset>[]
|
||||
const seen = new Set(
|
||||
presets.value.map((p) => `${p.instanceId}|${p.groupId}|${p.deviceId}|${p.accessKeyId}`),
|
||||
)
|
||||
let imported = 0
|
||||
let skipped = 0
|
||||
for (const item of parsed) {
|
||||
const p: AliyunPreset = {
|
||||
...defaultAliyunPreset(),
|
||||
...item,
|
||||
id: crypto.randomUUID(),
|
||||
}
|
||||
const key = `${p.instanceId}|${p.groupId}|${p.deviceId}|${p.accessKeyId}`
|
||||
if (seen.has(key)) {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
seen.add(key)
|
||||
presets.value.push(p)
|
||||
imported++
|
||||
}
|
||||
return { imported, skipped }
|
||||
}
|
||||
|
||||
return {
|
||||
presets,
|
||||
add,
|
||||
update,
|
||||
remove,
|
||||
getById,
|
||||
getGroups,
|
||||
renameGroup,
|
||||
deleteGroup,
|
||||
exportPresets,
|
||||
importPresets,
|
||||
}
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
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[]
|
||||
return parsed.map((c) => ({
|
||||
...c,
|
||||
protocolVersion: c.protocolVersion ?? '5.0',
|
||||
group: c.group ?? '',
|
||||
status: 'disconnected' as const,
|
||||
lastError: '',
|
||||
}))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function saveConnections(connections: MqttConnection[]) {
|
||||
try {
|
||||
const toSave = connections.map(({ status: _, lastError: __, ...rest }) => rest)
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(toSave))
|
||||
} catch {
|
||||
// localStorage full or unavailable
|
||||
}
|
||||
}
|
||||
|
||||
const connections = ref<MqttConnection[]>(loadConnections())
|
||||
|
||||
watch(
|
||||
connections,
|
||||
(val) => {
|
||||
saveConnections(val)
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
export function useConnections() {
|
||||
function add(): MqttConnection {
|
||||
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')
|
||||
}
|
||||
|
||||
// ─── 分组操作 ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** 获取所有分组名称(去重) */
|
||||
function getGroups(): string[] {
|
||||
const set = new Set<string>()
|
||||
for (const c of connections.value) {
|
||||
if (c.group) set.add(c.group)
|
||||
}
|
||||
return Array.from(set).sort()
|
||||
}
|
||||
|
||||
/** 按分组获取连接列表 */
|
||||
function getByGroup(group: string): MqttConnection[] {
|
||||
return connections.value.filter((c) => c.group === group)
|
||||
}
|
||||
|
||||
/** 重命名分组 */
|
||||
function renameGroup(oldName: string, newName: string) {
|
||||
for (const c of connections.value) {
|
||||
if (c.group === oldName) {
|
||||
c.group = newName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除分组(将组内连接移到未分组) */
|
||||
function deleteGroup(group: string) {
|
||||
for (const c of connections.value) {
|
||||
if (c.group === group) {
|
||||
c.group = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 导入 / 导出 ──────────────────────────────────────────────────────────
|
||||
|
||||
/** 导出为 JSON 字符串(去除运行时字段) */
|
||||
function exportConnections(ids?: string[]): string {
|
||||
const list = ids
|
||||
? connections.value.filter((c) => ids.includes(c.id))
|
||||
: connections.value
|
||||
const data = list.map(({ status: _, lastError: __, ...rest }) => rest)
|
||||
return JSON.stringify(data, null, 2)
|
||||
}
|
||||
|
||||
/** 从 JSON 导入,按名称+地址+端口+ClientID 去重,返回导入/跳过数量 */
|
||||
function importConnections(json: string): { imported: number; skipped: number } {
|
||||
const parsed = JSON.parse(json) as Partial<MqttConnection>[]
|
||||
const seen = new Set(
|
||||
connections.value.map((c) => `${c.name}|${c.host}|${c.port}|${c.clientId}`),
|
||||
)
|
||||
let imported = 0
|
||||
let skipped = 0
|
||||
for (const item of parsed) {
|
||||
const conn: MqttConnection = {
|
||||
...defaultConnection(),
|
||||
...item,
|
||||
id: crypto.randomUUID(), // 始终生成新 ID 避免冲突
|
||||
status: 'disconnected',
|
||||
lastError: '',
|
||||
}
|
||||
const key = `${conn.name}|${conn.host}|${conn.port}|${conn.clientId}`
|
||||
if (seen.has(key)) {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
seen.add(key)
|
||||
connections.value.push(conn)
|
||||
imported++
|
||||
}
|
||||
return { imported, skipped }
|
||||
}
|
||||
|
||||
return {
|
||||
connections,
|
||||
add,
|
||||
update,
|
||||
remove,
|
||||
getById,
|
||||
updateStatus,
|
||||
getConnected,
|
||||
getGroups,
|
||||
getByGroup,
|
||||
renameGroup,
|
||||
deleteGroup,
|
||||
exportConnections,
|
||||
importConnections,
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
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 { useSubscriptions } from './useSubscriptions'
|
||||
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')
|
||||
// Auto-resubscribe all persisted subscriptions for this connection
|
||||
const { getSubscriptionsByConnection } = useSubscriptions()
|
||||
const subs = getSubscriptionsByConnection(e.connection_id)
|
||||
for (const sub of subs) {
|
||||
mqttSubscribe({ connection_id: e.connection_id, topic: sub.topic, qos: sub.qos }).catch(() => {
|
||||
// Silently ignore resubscribe errors — the user will see them in the log
|
||||
})
|
||||
}
|
||||
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 mqttReadDebugLog(): Promise<string> {
|
||||
return await invoke<string>('mqtt_read_debug_log')
|
||||
}
|
||||
|
||||
export async function mqttGetLogDir(): Promise<string> {
|
||||
return await invoke<string>('mqtt_get_log_dir')
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -1,360 +0,0 @@
|
||||
:root {
|
||||
/* ─── Soft color palette ─────────────────────────────────────── */
|
||||
--comfort-bg: #f5f5f0;
|
||||
--comfort-bg-soft: #fafaf7;
|
||||
--comfort-bg-card: #ffffff;
|
||||
--comfort-text: #2c2c2c;
|
||||
--comfort-text-secondary: #6b6b6b;
|
||||
--comfort-text-muted: #999999;
|
||||
--comfort-border: #e8e8e3;
|
||||
--comfort-border-light: #f0f0eb;
|
||||
--comfort-shadow: 0 1px 3px rgba(0, 0, 0, 0.04), 0 1px 2px rgba(0, 0, 0, 0.03);
|
||||
--comfort-shadow-hover: 0 2px 8px rgba(0, 0, 0, 0.06), 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||
--comfort-primary: #5b8c5a;
|
||||
--comfort-primary-light: #7dab7c;
|
||||
--comfort-primary-bg: #eef5ee;
|
||||
--comfort-success: #5b8c5a;
|
||||
--comfort-warning: #c4a35a;
|
||||
--comfort-danger: #c46a5a;
|
||||
--comfort-info: #6b8a9a;
|
||||
--comfort-font: "Inter", "LXGW WenKai", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
|
||||
--comfort-font-mono: "Cascadia Code", "JetBrains Mono", "Fira Code", "Consolas", monospace;
|
||||
--comfort-radius: 8px;
|
||||
--comfort-radius-sm: 6px;
|
||||
|
||||
/* Element Plus variable overrides */
|
||||
--el-bg-color: var(--comfort-bg);
|
||||
--el-bg-color-overlay: var(--comfort-bg-card);
|
||||
--el-text-color-primary: var(--comfort-text);
|
||||
--el-text-color-regular: var(--comfort-text);
|
||||
--el-text-color-secondary: var(--comfort-text-secondary);
|
||||
--el-text-color-placeholder: var(--comfort-text-muted);
|
||||
--el-border-color: var(--comfort-border);
|
||||
--el-border-color-light: var(--comfort-border-light);
|
||||
--el-border-color-extra-light: var(--comfort-border-light);
|
||||
--el-fill-color: #efefea;
|
||||
--el-fill-color-light: #eaeae5;
|
||||
--el-fill-color-lighter: #f0f0eb;
|
||||
--el-fill-color-extra-light: #f5f5f0;
|
||||
--el-color-primary: var(--comfort-primary);
|
||||
--el-color-primary-light-3: var(--comfort-primary-light);
|
||||
--el-color-primary-light-5: #9dc09c;
|
||||
--el-color-primary-light-7: #bdd5bc;
|
||||
--el-color-primary-light-8: #d0e2cf;
|
||||
--el-color-primary-light-9: var(--comfort-primary-bg);
|
||||
--el-color-primary-dark-2: #4a7a49;
|
||||
--el-color-success: var(--comfort-success);
|
||||
--el-color-warning: var(--comfort-warning);
|
||||
--el-color-danger: var(--comfort-danger);
|
||||
--el-color-info: var(--comfort-info);
|
||||
--el-border-radius-base: var(--comfort-radius-sm);
|
||||
--el-font-size-base: 13px;
|
||||
--el-font-size-small: 12px;
|
||||
--el-font-size-extra-small: 11px;
|
||||
--el-font-family: var(--comfort-font);
|
||||
}
|
||||
|
||||
/* ─── Dark mode ─────────────────────────────────────────────────── */
|
||||
.dark {
|
||||
--comfort-bg: #1a1a1e;
|
||||
--comfort-bg-soft: #222226;
|
||||
--comfort-bg-card: #2a2a2e;
|
||||
--comfort-text: #e0e0e0;
|
||||
--comfort-text-secondary: #a0a0a0;
|
||||
--comfort-text-muted: #707070;
|
||||
--comfort-border: #3a3a3e;
|
||||
--comfort-border-light: #323236;
|
||||
--comfort-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
--comfort-shadow-hover: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
--comfort-primary: #7dab7c;
|
||||
--comfort-primary-light: #9dc09c;
|
||||
--comfort-primary-bg: #2a3a2a;
|
||||
--comfort-success: #7dab7c;
|
||||
--comfort-warning: #c4a35a;
|
||||
--comfort-danger: #c46a5a;
|
||||
--comfort-info: #6b8a9a;
|
||||
|
||||
/* Element Plus core */
|
||||
--el-color-white: #1a1a1e;
|
||||
--el-color-black: #e0e0e0;
|
||||
--el-bg-color: var(--comfort-bg);
|
||||
--el-bg-color-overlay: var(--comfort-bg-card);
|
||||
--el-bg-color-page: var(--comfort-bg);
|
||||
--el-text-color-primary: var(--comfort-text);
|
||||
--el-text-color-regular: var(--comfort-text);
|
||||
--el-text-color-secondary: var(--comfort-text-secondary);
|
||||
--el-text-color-placeholder: var(--comfort-text-muted);
|
||||
--el-text-color-disabled: #505050;
|
||||
--el-border-color: var(--comfort-border);
|
||||
--el-border-color-light: var(--comfort-border-light);
|
||||
--el-border-color-lighter: #2e2e32;
|
||||
--el-border-color-extra-light: #2a2a2e;
|
||||
--el-border-color-dark: #4a4a4e;
|
||||
--el-fill-color: #2e2e32;
|
||||
--el-fill-color-light: #36363a;
|
||||
--el-fill-color-lighter: #323236;
|
||||
--el-fill-color-extra-light: #2a2a2e;
|
||||
--el-fill-color-dark: #3a3a3e;
|
||||
--el-fill-color-blank: var(--comfort-bg-card);
|
||||
|
||||
/* Element Plus component colors */
|
||||
--el-color-primary: var(--comfort-primary);
|
||||
--el-color-primary-light-3: var(--comfort-primary-light);
|
||||
--el-color-primary-light-5: #bdd5bc;
|
||||
--el-color-primary-light-7: #3a4a3a;
|
||||
--el-color-primary-light-8: #324032;
|
||||
--el-color-primary-light-9: var(--comfort-primary-bg);
|
||||
--el-color-primary-dark-2: #5b8c5a;
|
||||
--el-color-success: var(--comfort-success);
|
||||
--el-color-success-light-3: #9dc09c;
|
||||
--el-color-success-light-5: #7dab7c;
|
||||
--el-color-success-light-7: #3a4a3a;
|
||||
--el-color-success-light-8: #324032;
|
||||
--el-color-success-light-9: #2a3a2a;
|
||||
--el-color-success-dark-2: #5b8c5a;
|
||||
--el-color-warning: var(--comfort-warning);
|
||||
--el-color-warning-light-3: #d4b86a;
|
||||
--el-color-warning-light-5: #c4a35a;
|
||||
--el-color-warning-light-7: #4a3a2a;
|
||||
--el-color-warning-light-8: #3a3020;
|
||||
--el-color-warning-light-9: #2a2a1a;
|
||||
--el-color-warning-dark-2: #a08040;
|
||||
--el-color-danger: var(--comfort-danger);
|
||||
--el-color-danger-light-3: #d47a6a;
|
||||
--el-color-danger-light-5: #c46a5a;
|
||||
--el-color-danger-light-7: #4a2a2a;
|
||||
--el-color-danger-light-8: #3a2020;
|
||||
--el-color-danger-light-9: #2a1a1a;
|
||||
--el-color-danger-dark-2: #a05040;
|
||||
--el-color-info: var(--comfort-info);
|
||||
--el-color-info-light-3: #8baab0;
|
||||
--el-color-info-light-5: #6b8a9a;
|
||||
--el-color-info-light-7: #2a3a3a;
|
||||
--el-color-info-light-8: #202a2a;
|
||||
--el-color-info-light-9: #1a2a2a;
|
||||
--el-color-info-dark-2: #4a6a7a;
|
||||
|
||||
/* Element Plus specific */
|
||||
--el-mask-color: rgba(0, 0, 0, 0.5);
|
||||
--el-overlay-color: rgba(0, 0, 0, 0.6);
|
||||
--el-overlay-color-lighter: rgba(0, 0, 0, 0.4);
|
||||
--el-popper-bg-color: var(--comfort-bg-card);
|
||||
--el-input-bg-color: var(--comfort-bg-soft);
|
||||
--el-input-text-color: var(--comfort-text);
|
||||
--el-input-border-color: var(--comfort-border);
|
||||
--el-input-hover-border-color: var(--comfort-primary-light);
|
||||
--el-input-focus-border-color: var(--comfort-primary);
|
||||
--el-input-placeholder-color: var(--comfort-text-muted);
|
||||
--el-table-bg-color: var(--comfort-bg-card);
|
||||
--el-table-tr-bg-color: var(--comfort-bg-card);
|
||||
--el-table-header-bg-color: var(--comfort-bg-soft);
|
||||
--el-table-header-text-color: var(--comfort-text-secondary);
|
||||
--el-table-text-color: var(--comfort-text);
|
||||
--el-table-row-hover-bg-color: var(--comfort-primary-bg);
|
||||
--el-table-border-color: var(--comfort-border-light);
|
||||
--el-tag-bg-color: var(--comfort-primary-bg);
|
||||
--el-tag-text-color: var(--comfort-primary);
|
||||
--el-tag-border-color: transparent;
|
||||
--el-dialog-bg-color: var(--comfort-bg-card);
|
||||
--el-select-bg-color: var(--comfort-bg-soft);
|
||||
--el-select-input-focus-border-color: var(--comfort-primary);
|
||||
--el-select-dropdown-bg: var(--comfort-bg-card);
|
||||
--el-select-dropdown-border: var(--comfort-border);
|
||||
--el-message-bg-color: var(--comfort-bg-card);
|
||||
--el-message-shadow: var(--comfort-shadow-hover);
|
||||
--el-checkbox-bg-color: var(--comfort-bg-soft);
|
||||
--el-checkbox-checked-bg-color: var(--comfort-primary);
|
||||
--el-checkbox-input-border-color: var(--comfort-border);
|
||||
--el-checkbox-input-border-color-hover: var(--comfort-primary-light);
|
||||
--el-switch-on-color: var(--comfort-primary);
|
||||
--el-switch-off-color: #3a3a3e;
|
||||
}
|
||||
|
||||
/* ─── Global comfort styles ─────────────────────────────────────── */
|
||||
body {
|
||||
background: var(--comfort-bg);
|
||||
color: var(--comfort-text);
|
||||
font-family: var(--comfort-font);
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
/* Softer scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--comfort-border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--comfort-text-muted);
|
||||
}
|
||||
|
||||
/* Subtle transitions */
|
||||
* {
|
||||
transition: background-color 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
/* Card style */
|
||||
.comfort-card {
|
||||
background: var(--comfort-bg-card);
|
||||
border: 1px solid var(--comfort-border);
|
||||
border-radius: var(--comfort-radius);
|
||||
box-shadow: var(--comfort-shadow);
|
||||
}
|
||||
|
||||
/* Better focus ring */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--comfort-primary-light);
|
||||
outline-offset: 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Input/textarea comfort */
|
||||
.el-input__wrapper,
|
||||
.el-textarea__inner {
|
||||
box-shadow: none !important;
|
||||
border: 1px solid var(--comfort-border) !important;
|
||||
background: var(--comfort-bg-soft) !important;
|
||||
border-radius: var(--comfort-radius-sm) !important;
|
||||
}
|
||||
.el-input__wrapper:hover,
|
||||
.el-textarea__inner:hover {
|
||||
border-color: var(--comfort-primary-light) !important;
|
||||
}
|
||||
.el-input__wrapper.is-focus,
|
||||
.el-textarea__inner:focus {
|
||||
border-color: var(--comfort-primary) !important;
|
||||
}
|
||||
|
||||
/* Button comfort */
|
||||
.el-button {
|
||||
--el-button-hover-bg-color: var(--comfort-primary-bg);
|
||||
--el-button-hover-border-color: var(--comfort-primary-light);
|
||||
border-radius: var(--comfort-radius-sm) !important;
|
||||
font-weight: 450 !important;
|
||||
}
|
||||
.el-button--primary {
|
||||
--el-button-bg-color: var(--comfort-primary);
|
||||
--el-button-border-color: var(--comfort-primary);
|
||||
--el-button-hover-bg-color: var(--comfort-primary-light);
|
||||
--el-button-hover-border-color: var(--comfort-primary-light);
|
||||
}
|
||||
|
||||
/* Table comfort */
|
||||
.el-table {
|
||||
--el-table-border-color: var(--comfort-border-light) !important;
|
||||
--el-table-header-bg-color: var(--comfort-bg-soft) !important;
|
||||
--el-table-row-hover-bg-color: var(--comfort-primary-bg) !important;
|
||||
font-size: 13px !important;
|
||||
}
|
||||
.el-table th.el-table__cell {
|
||||
font-weight: 600 !important;
|
||||
color: var(--comfort-text-secondary) !important;
|
||||
font-size: 12px !important;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
/* Dialog comfort */
|
||||
.el-dialog {
|
||||
--el-dialog-bg-color: var(--comfort-bg-card);
|
||||
border-radius: var(--comfort-radius) !important;
|
||||
box-shadow: var(--comfort-shadow-hover) !important;
|
||||
}
|
||||
|
||||
/* Tag comfort */
|
||||
.el-tag {
|
||||
--el-tag-bg-color: var(--comfort-primary-bg);
|
||||
--el-tag-border-color: transparent;
|
||||
--el-tag-text-color: var(--comfort-primary);
|
||||
font-weight: 450;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Select dropdown comfort */
|
||||
.el-select-dropdown {
|
||||
--el-select-dropdown-bg: var(--comfort-bg-card);
|
||||
border: 1px solid var(--comfort-border) !important;
|
||||
box-shadow: var(--comfort-shadow-hover) !important;
|
||||
border-radius: var(--comfort-radius-sm) !important;
|
||||
}
|
||||
.el-select-dropdown__item.hover {
|
||||
background: var(--comfort-primary-bg) !important;
|
||||
}
|
||||
|
||||
/* Message comfort */
|
||||
.el-message {
|
||||
border-radius: var(--comfort-radius-sm) !important;
|
||||
box-shadow: var(--comfort-shadow-hover) !important;
|
||||
border: 1px solid var(--comfort-border) !important;
|
||||
}
|
||||
|
||||
/* Checkbox comfort */
|
||||
.el-checkbox {
|
||||
--el-checkbox-checked-bg-color: var(--comfort-primary);
|
||||
--el-checkbox-checked-input-border-color: var(--comfort-primary);
|
||||
--el-checkbox-input-border-color-hover: var(--comfort-primary-light);
|
||||
}
|
||||
|
||||
/* ─── visual-json theme override ───────────────────────────────────
|
||||
The library injects its dark defaults as inline custom properties,
|
||||
so we need !important stylesheet rules to win the cascade and follow
|
||||
the app's light/dark comfort palette. */
|
||||
[data-vj-root] {
|
||||
--vj-bg: var(--comfort-bg-card) !important;
|
||||
--vj-bg-panel: var(--comfort-bg-soft) !important;
|
||||
--vj-bg-hover: #f0f0eb !important;
|
||||
--vj-bg-selected: var(--comfort-primary-bg) !important;
|
||||
--vj-bg-selected-muted: #f0f0eb !important;
|
||||
--vj-bg-match: #f7ecd2 !important;
|
||||
--vj-bg-match-active: #f0dc9f !important;
|
||||
--vj-border: var(--comfort-border) !important;
|
||||
--vj-border-subtle: var(--comfort-border-light) !important;
|
||||
--vj-text: var(--comfort-text) !important;
|
||||
--vj-text-muted: var(--comfort-text-secondary) !important;
|
||||
--vj-text-dim: var(--comfort-text-muted) !important;
|
||||
--vj-text-dimmer: #c0c0ba !important;
|
||||
--vj-string: #a31515 !important;
|
||||
--vj-number: #098658 !important;
|
||||
--vj-boolean: #0000ff !important;
|
||||
--vj-accent: var(--comfort-primary) !important;
|
||||
--vj-accent-muted: #d7e5d6 !important;
|
||||
--vj-input-bg: var(--comfort-bg-card) !important;
|
||||
--vj-input-border: var(--comfort-border) !important;
|
||||
--vj-error: var(--comfort-danger) !important;
|
||||
--vj-font: var(--comfort-font-mono) !important;
|
||||
--vj-input-font-size: 13px !important;
|
||||
}
|
||||
|
||||
.dark [data-vj-root] {
|
||||
--vj-bg: var(--comfort-bg-card) !important;
|
||||
--vj-bg-panel: var(--comfort-bg-soft) !important;
|
||||
--vj-bg-hover: #323236 !important;
|
||||
--vj-bg-selected: var(--comfort-primary-bg) !important;
|
||||
--vj-bg-selected-muted: #2e2e32 !important;
|
||||
--vj-bg-match: #3a3520 !important;
|
||||
--vj-bg-match-active: #51502b !important;
|
||||
--vj-border: var(--comfort-border) !important;
|
||||
--vj-border-subtle: var(--comfort-border-light) !important;
|
||||
--vj-text: var(--comfort-text) !important;
|
||||
--vj-text-muted: var(--comfort-text-secondary) !important;
|
||||
--vj-text-dim: var(--comfort-text-muted) !important;
|
||||
--vj-text-dimmer: #555555 !important;
|
||||
--vj-string: #ce9178 !important;
|
||||
--vj-number: #b5cea8 !important;
|
||||
--vj-boolean: #569cd6 !important;
|
||||
--vj-accent: var(--comfort-primary) !important;
|
||||
--vj-accent-muted: #3a4a3a !important;
|
||||
--vj-input-bg: #36363a !important;
|
||||
--vj-input-border: #4a4a4e !important;
|
||||
--vj-error: var(--comfort-danger) !important;
|
||||
--vj-font: var(--comfort-font-mono) !important;
|
||||
--vj-input-font-size: 13px !important;
|
||||
}
|
||||