feat: 集成 visual-json 可视化 JSON 编辑/查看并更新品牌图标
- 发送面板 Payload 增加「文本 / JSON 可视化」切换,可视化编辑实时回写 - 消息流 JSON 消息改为只读可视化查看,拦截双击/删除/右键等编辑入口 - visual-json 主题跟随应用亮/暗 comfort 色板 - 更新应用图标与启动页 logo(mqttx.svg + Tauri 各尺寸图标)
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user