feat: 集成 visual-json 可视化 JSON 编辑/查看并更新品牌图标

- 发送面板 Payload 增加「文本 / JSON 可视化」切换,可视化编辑实时回写
- 消息流 JSON 消息改为只读可视化查看,拦截双击/删除/右键等编辑入口
- visual-json 主题跟随应用亮/暗 comfort 色板
- 更新应用图标与启动页 logo(mqttx.svg + Tauri 各尺寸图标)
This commit is contained in:
2026-08-24 13:41:08 +08:00
parent 72a388a092
commit 8a2c63e575
64 changed files with 654 additions and 32 deletions
+12 -6
View File
@@ -69,10 +69,14 @@ onMounted(() => {
<header class="app-header">
<div class="header-left">
<div class="app-brand">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="brand-icon">
<circle cx="12" cy="12" r="10"/>
<path d="M12 6v6l4 2"/>
</svg>
<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>
@@ -203,7 +207,9 @@ onMounted(() => {
gap: 8px;
}
.brand-icon {
color: var(--el-color-primary);
display: block;
border-radius: 6px;
user-select: none;
}
.brand-name {
font-size: 18px;
@@ -366,4 +372,4 @@ onMounted(() => {
padding: 14px;
overflow: auto;
}
</style>
</style>
+61
View File
@@ -0,0 +1,61 @@
<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>
+23 -8
View File
@@ -2,7 +2,9 @@
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[]
@@ -69,6 +71,14 @@ function formatPayload(msg: MqttMessage): string {
}
}
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++) {
@@ -166,10 +176,15 @@ function formatTime(ts: number): string {
<!-- Payload Content -->
<div class="msg-payload" :class="{ expanded: !collapsedPayloads.has(msg.id) }">
<pre
v-if="!collapsedPayloads.has(msg.id)"
:class="['payload-full', { 'is-json': getEffectiveFormat(msg) === 'json' }]"
><HoverPreview :text="formatPayload(msg)">{{ formatPayload(msg) }}</HoverPreview></pre>
<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>
@@ -357,6 +372,9 @@ function formatTime(ts: number): string {
.msg-payload {
margin-top: 2px;
}
.msg-json-viewer {
margin-top: 4px;
}
.msg-payload pre {
margin: 0;
white-space: pre-wrap;
@@ -373,13 +391,10 @@ function formatTime(ts: number): string {
line-height: 1.5;
border: 1px solid var(--el-border-color-extra-light);
}
.payload-full.is-json {
color: var(--el-color-primary-dark-2);
}
.payload-truncated {
max-height: 1.4em;
overflow: hidden;
font-size: 12px;
color: var(--el-text-color-secondary);
}
</style>
</style>
+111 -3
View File
@@ -5,6 +5,8 @@ import { extractVariables, resolveTemplate, resolveUserProperties } from '../../
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'
@@ -32,6 +34,7 @@ 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[]>([])
@@ -65,6 +68,24 @@ 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]))
@@ -151,6 +172,10 @@ function selectPreset(presetId: string) {
showVariables.value = presetVariables.value.length > 0
}
watch(activePresetId, () => {
payloadMode.value = 'text'
})
// ─── Drag and drop ─────────────────────────────────────────────
function onDragStart(index: number) {
@@ -243,6 +268,22 @@ function onBindingChange(name: string, value: string) {
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>
@@ -352,8 +393,20 @@ function onBindingChange(name: string, value: string) {
<!-- Row: Payload -->
<div class="form-row">
<div class="field" style="flex: 1">
<label>Payload</label>
<HoverPreview :text="prettyJson(activeState.payload)" :delay="600">
<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"
@@ -361,6 +414,17 @@ function onBindingChange(name: string, value: string) {
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>
@@ -650,6 +714,50 @@ function onBindingChange(name: string, value: string) {
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;
@@ -783,4 +891,4 @@ function onBindingChange(name: string, value: string) {
opacity: 1;
max-height: 500px;
}
</style>
</style>