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:
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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">
|
||||
|
||||
Reference in New Issue
Block a user