chore: 移除 Tauri + Vue 遗留实现,仅维护原生 gpui 程序

- 删除 src/(Vue 前端)、src-tauri/(Tauri 后端与全套图标)、
  Vite/TS/pnpm 配置、.github 旧 CI、.vscode 推荐
- workspace 去掉 src-tauri exclude;.gitignore 精简为纯 Rust 项目
- README 指向归档分支 tauri-legacy(保留完整历史可随时查阅)
This commit is contained in:
2026-09-29 20:12:31 +08:00
parent 33d0ca10e7
commit 303b348ffe
107 changed files with 15 additions and 15057 deletions
-111
View File
@@ -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/
+14 -38
View File
@@ -1,43 +1,19 @@
# DSH usage records # Rust 构建产物(GPUI 工作区)
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)
/target /target
*.pdb *.pdb
# 日志
logs
*.log
# 编辑器
.vscode/*
.idea
.DS_Store
*.sw?
# 本仓库专属
/.v2c /.v2c
/.video_agent /.video_agent
# 发布产物暂存目录
/dist-publish /dist-publish
dsh-usage/
-7
View File
@@ -1,7 +0,0 @@
{
"recommendations": [
"Vue.volar",
"tauri-apps.tauri-vscode",
"rust-lang.rust-analyzer"
]
}
-1
View File
@@ -1,7 +1,6 @@
[workspace] [workspace]
resolver = "2" resolver = "2"
members = ["gpui-app"] members = ["gpui-app"]
exclude = ["src-tauri"]
[workspace.package] [workspace.package]
edition = "2024" edition = "2024"
+1 -1
View File
@@ -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 + [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 运行时。 纯 Rust 实现:MQTT 引擎、数据持久化与原生 UI 同属一个轻量二进制,无 Electron/WebView 运行时。
> 旧的 Tauri + Vue 版本仍保留在 `src/` 与 `src-tauri/`,新实现位于 `gpui-app/`。 > 早期 Tauri + Vue 实现已归档到 [`tauri-legacy`](../../archive/tauri-legacy.tar.gz) 分支,本项目仅维护 `gpui-app/` 原生实现。
## 功能特性 ## 功能特性
-14
View File
@@ -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>
-30
View File
@@ -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"
}
}
-1032
View File
File diff suppressed because it is too large Load Diff
-33
View File
@@ -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

-7
View File
@@ -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
-5741
View File
File diff suppressed because it is too large Load Diff
-43
View File
@@ -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
-14
View File
@@ -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()
}
-10
View File
@@ -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"
]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

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>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

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>
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 959 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

-1334
View File
File diff suppressed because it is too large Load Diff
-6
View File
@@ -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()
}
-56
View File
@@ -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"]
}
}
}
}
-29
View File
@@ -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
View File
@@ -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

-375
View File
@@ -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>
-132
View File
@@ -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>
-120
View File
@@ -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>&#8230;</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>&#8230;</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>
-422
View File
@@ -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>
-72
View File
@@ -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>
-61
View File
@@ -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>
-229
View File
@@ -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>
-400
View File
@@ -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>
-902
View File
@@ -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>
-385
View File
@@ -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>
-127
View File
@@ -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>
-130
View File
@@ -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>
-41
View File
@@ -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)
})
}
-159
View File
@@ -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,
}
}
-9
View File
@@ -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");
-125
View File
@@ -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,
}
}
-172
View File
@@ -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,
}
}
-79
View File
@@ -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,
}
}
-46
View File
@@ -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,
}
}
-41
View File
@@ -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,
}
}
-158
View File
@@ -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')
}
-67
View File
@@ -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,
}
}
-78
View File
@@ -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,
}
}
-360
View File
@@ -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;
}

Some files were not shown because too many files have changed in this diff Show More