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 { useLogs } from '../../stores/useLogs'
|
||||||
import { mqttGetLogDir } from '../../stores/useMqttBridge'
|
import { mqttGetLogDir } from '../../stores/useMqttBridge'
|
||||||
import { prettyJson } from '../../utils/format'
|
import { prettyJson } from '../../utils/format'
|
||||||
|
import HoverPreview from '../common/HoverPreview.vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
const { logs, clear } = useLogs()
|
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>
|
<span v-if="log.details" class="log-dtl-btn" @click="toggleDetails(log.id)">{{ expandedDetails.has(log.id) ? '收起' : '详情' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="log.details && expandedDetails.has(log.id)" class="log-dtl-content">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { ref, watch, nextTick } from 'vue'
|
import { ref, watch, nextTick } from 'vue'
|
||||||
import type { MqttMessage } from '../../types/mqtt'
|
import type { MqttMessage } from '../../types/mqtt'
|
||||||
import { prettyJson, isJson, truncate } from '../../utils/format'
|
import { prettyJson, isJson, truncate } from '../../utils/format'
|
||||||
|
import HoverPreview from '../common/HoverPreview.vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
messages: MqttMessage[]
|
messages: MqttMessage[]
|
||||||
@@ -168,8 +169,10 @@ function formatTime(ts: number): string {
|
|||||||
<pre
|
<pre
|
||||||
v-if="!collapsedPayloads.has(msg.id)"
|
v-if="!collapsedPayloads.has(msg.id)"
|
||||||
:class="['payload-full', { 'is-json': getEffectiveFormat(msg) === 'json' }]"
|
:class="['payload-full', { 'is-json': getEffectiveFormat(msg) === 'json' }]"
|
||||||
>{{ formatPayload(msg) }}</pre>
|
><HoverPreview :text="formatPayload(msg)">{{ formatPayload(msg) }}</HoverPreview></pre>
|
||||||
<pre v-else class="payload-truncated">{{ truncate(prettyJson(msg.payload), 200) }}</pre>
|
<pre v-else class="payload-truncated">
|
||||||
|
<HoverPreview :text="prettyJson(msg.payload)">{{ truncate(prettyJson(msg.payload), 200) }}</HoverPreview>
|
||||||
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ElMessage, ElMessageBox } from 'element-plus'
|
|||||||
import type { Preset } from '../../types/mqtt'
|
import type { Preset } from '../../types/mqtt'
|
||||||
import { usePresets } from '../../stores/usePresets'
|
import { usePresets } from '../../stores/usePresets'
|
||||||
import { prettyJson, truncate } from '../../utils/format'
|
import { prettyJson, truncate } from '../../utils/format'
|
||||||
|
import HoverPreview from '../common/HoverPreview.vue'
|
||||||
import PresetForm from './PresetForm.vue'
|
import PresetForm from './PresetForm.vue'
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// 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>
|
||||||
<el-table-column label="Payload 预览" min-width="200">
|
<el-table-column label="Payload 预览" min-width="200">
|
||||||
<template #default="{ row }">
|
<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>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="160" fixed="right">
|
<el-table-column label="操作" width="160" fixed="right">
|
||||||
|
|||||||
Reference in New Issue
Block a user