feat: hover preview for truncated JSON content

New HoverPreview component shows a floating tooltip with full
content on mouse hover (400ms delay). Applied to:
- MessageStream: collapsed + expanded payload views
- PresetList: payload preview in table
- LogPanel: details section

The popup follows the mouse cursor, auto-positions within viewport,
and supports monospace code formatting with scroll for long content.
This commit is contained in:
Joe Allen
2026-08-18 19:29:12 +08:00
parent 85e31e2f96
commit 3c04af9532
4 changed files with 130 additions and 4 deletions
+119
View File
@@ -0,0 +1,119 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } 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
const wrapper = ref<HTMLElement | 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 = 12
let left = ev.clientX + pad
let top = ev.clientY + pad
// Keep within viewport
const预估Width = Math.min(props.maxWidth, 480)
if (left + 预估Width > window.innerWidth - 16) {
left = ev.clientX - 预估Width - pad
}
if (top + 360 > window.innerHeight - 16) {
top = window.innerHeight - 360 - 16
}
if (left < 8) left = 8
if (top < 8) top = 8
x.value = left
y.value = top
}
</script>
<template>
<span
ref="wrapper"
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;
position: relative;
}
.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>
+2 -1
View File
@@ -3,6 +3,7 @@ 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()
@@ -122,7 +123,7 @@ function eventIcon(event: string): string {
<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">
{{ prettyJson(log.details) }}
<HoverPreview :text="prettyJson(log.details)">{{ prettyJson(log.details) }}</HoverPreview>
</div>
</div>
</div>
+5 -2
View File
@@ -2,6 +2,7 @@
import { ref, watch, nextTick } from 'vue'
import type { MqttMessage } from '../../types/mqtt'
import { prettyJson, isJson, truncate } from '../../utils/format'
import HoverPreview from '../common/HoverPreview.vue'
const props = defineProps<{
messages: MqttMessage[]
@@ -168,8 +169,10 @@ function formatTime(ts: number): string {
<pre
v-if="!collapsedPayloads.has(msg.id)"
:class="['payload-full', { 'is-json': getEffectiveFormat(msg) === 'json' }]"
>{{ formatPayload(msg) }}</pre>
<pre v-else class="payload-truncated">{{ truncate(prettyJson(msg.payload), 200) }}</pre>
><HoverPreview :text="formatPayload(msg)">{{ formatPayload(msg) }}</HoverPreview></pre>
<pre v-else class="payload-truncated">
<HoverPreview :text="prettyJson(msg.payload)">{{ truncate(prettyJson(msg.payload), 200) }}</HoverPreview>
</pre>
</div>
</div>
</div>
+4 -1
View File
@@ -4,6 +4,7 @@ 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
@@ -77,7 +78,9 @@ const qosLabel = (qos: number) => `QoS ${qos}`
</el-table-column>
<el-table-column label="Payload 预览" min-width="200">
<template #default="{ row }">
<span class="payload-preview">{{ truncate(prettyJson(row.payloadTemplate), 50) }}</span>
<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">