feat: global right-click control + topic tree context menu
- UseContextMenu composable: global right-click prevention - PROD: prevent default browser context menu everywhere - DEV: allow native context menu for debugging - ContextMenu component: reusable custom right-click menu - TopicTree: right-click nodes for copy topic / subscribe / unsubscribe / filter - Copy topic to clipboard - Subscribe with QoS 0/1/2 - Unsubscribe with confirmation - Filter by topic
This commit is contained in:
+4
-1
@@ -1,5 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import AppLayout from "./components/AppLayout.vue";
|
import { useContextMenuController } from "./composables/useContextMenu"
|
||||||
|
import AppLayout from "./components/AppLayout.vue"
|
||||||
|
|
||||||
|
useContextMenuController()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
export interface ContextMenuItem {
|
||||||
|
label: string
|
||||||
|
icon?: string
|
||||||
|
danger?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
divider?: boolean
|
||||||
|
action: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
items: ContextMenuItem[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const visible = ref(false)
|
||||||
|
const x = ref(0)
|
||||||
|
const y = ref(0)
|
||||||
|
let targetEl: HTMLElement | null = null
|
||||||
|
let cleanup: (() => void) | null = null
|
||||||
|
|
||||||
|
function show(ev: MouseEvent, el: HTMLElement) {
|
||||||
|
x.value = ev.clientX
|
||||||
|
y.value = ev.clientY
|
||||||
|
targetEl = el
|
||||||
|
visible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function hide() {
|
||||||
|
visible.value = false
|
||||||
|
targetEl = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAction(item: ContextMenuItem) {
|
||||||
|
if (item.disabled) return
|
||||||
|
hide()
|
||||||
|
item.action()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClickOutside(e: MouseEvent) {
|
||||||
|
const menu = document.querySelector('.context-menu')
|
||||||
|
if (menu && !menu.contains(e.target as Node)) {
|
||||||
|
hide()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('click', onClickOutside)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('click', onClickOutside)
|
||||||
|
if (cleanup) cleanup()
|
||||||
|
})
|
||||||
|
|
||||||
|
defineExpose({ show, hide })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="visible"
|
||||||
|
class="context-menu"
|
||||||
|
:style="{ left: x + 'px', top: y + 'px' }"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="(item, idx) in items"
|
||||||
|
:key="idx"
|
||||||
|
:class="['ctx-item', { danger: item.danger, disabled: item.disabled, divider: item.divider }]"
|
||||||
|
@click="handleAction(item)"
|
||||||
|
>
|
||||||
|
<span v-if="item.icon" class="ctx-icon">{{ item.icon }}</span>
|
||||||
|
<span class="ctx-label">{{ item.label }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.context-menu {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 9999;
|
||||||
|
min-width: 140px;
|
||||||
|
background: var(--el-bg-color);
|
||||||
|
border: 1px solid var(--el-border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 4px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.ctx-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
transition: background 0.1s;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.ctx-item:hover {
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
}
|
||||||
|
.ctx-item.danger {
|
||||||
|
color: var(--el-color-danger);
|
||||||
|
}
|
||||||
|
.ctx-item.danger:hover {
|
||||||
|
background: var(--el-color-danger-light-9);
|
||||||
|
}
|
||||||
|
.ctx-item.disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.ctx-item.disabled:hover {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.ctx-item.divider {
|
||||||
|
border-top: 1px solid var(--el-border-color-light);
|
||||||
|
margin-top: 2px;
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
.ctx-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
width: 18px;
|
||||||
|
text-align: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.ctx-label {
|
||||||
|
flex: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -5,6 +5,8 @@ import type { MqttMessage } from '../../types/mqtt'
|
|||||||
import type { Subscription } from '../../types/mqtt'
|
import type { Subscription } from '../../types/mqtt'
|
||||||
import { useSubscriptions } from '../../stores/useSubscriptions'
|
import { useSubscriptions } from '../../stores/useSubscriptions'
|
||||||
import { mqttSubscribe, mqttUnsubscribe } from '../../stores/useMqttBridge'
|
import { mqttSubscribe, mqttUnsubscribe } from '../../stores/useMqttBridge'
|
||||||
|
import ContextMenu from '../common/ContextMenu.vue'
|
||||||
|
import type { ContextMenuItem } from '../common/ContextMenu.vue'
|
||||||
|
|
||||||
interface TopicNode {
|
interface TopicNode {
|
||||||
id: string
|
id: string
|
||||||
@@ -30,6 +32,74 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const { addSubscription, removeSubscription, getSubscriptionsByConnection } = useSubscriptions()
|
const { addSubscription, removeSubscription, getSubscriptionsByConnection } = useSubscriptions()
|
||||||
|
|
||||||
|
const ctxMenu = ref<InstanceType<typeof ContextMenu>>()
|
||||||
|
const ctxTopic = ref('')
|
||||||
|
const ctxNode = ref<TopicNode | null>(null)
|
||||||
|
const ctxMenuItems = ref<ContextMenuItem[]>([])
|
||||||
|
|
||||||
|
function handleContextMenu(ev: MouseEvent, node: TopicNode) {
|
||||||
|
ev.preventDefault()
|
||||||
|
ctxTopic.value = node.fullPath
|
||||||
|
ctxNode.value = node
|
||||||
|
const items: ContextMenuItem[] = []
|
||||||
|
|
||||||
|
// Copy topic
|
||||||
|
items.push({
|
||||||
|
label: '复制主题',
|
||||||
|
icon: '📋',
|
||||||
|
action: () => navigator.clipboard.writeText(node.fullPath),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Subscribe / Unsubscribe
|
||||||
|
if (node.isSubscribed) {
|
||||||
|
items.push({
|
||||||
|
label: `取消订阅 (QoS ${node.subscriptionQos})`,
|
||||||
|
icon: '❌',
|
||||||
|
danger: true,
|
||||||
|
action: () => handleUnsubscribe(node.fullPath),
|
||||||
|
})
|
||||||
|
} else if (props.connected) {
|
||||||
|
items.push({
|
||||||
|
label: '订阅 (QoS 0)',
|
||||||
|
icon: '➕',
|
||||||
|
action: () => handleSubscribeTopic(node.fullPath, 0),
|
||||||
|
})
|
||||||
|
items.push({
|
||||||
|
label: '订阅 (QoS 1)',
|
||||||
|
icon: '➕',
|
||||||
|
action: () => handleSubscribeTopic(node.fullPath, 1),
|
||||||
|
})
|
||||||
|
items.push({
|
||||||
|
label: '订阅 (QoS 2)',
|
||||||
|
icon: '➕',
|
||||||
|
action: () => handleSubscribeTopic(node.fullPath, 2),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
items.push({ label: '', icon: '', divider: true, action: () => {} })
|
||||||
|
|
||||||
|
// Select topic (filter messages)
|
||||||
|
items.push({
|
||||||
|
label: '筛选此主题',
|
||||||
|
icon: '🔍',
|
||||||
|
action: () => emit('select', node.fullPath),
|
||||||
|
})
|
||||||
|
|
||||||
|
ctxMenuItems.value = items
|
||||||
|
ctxMenu.value?.show(ev, ev.target as HTMLElement)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubscribeTopic(topic: string, qos: number) {
|
||||||
|
if (!props.connectionId) return
|
||||||
|
try {
|
||||||
|
await mqttSubscribe({ connection_id: props.connectionId, topic, qos })
|
||||||
|
addSubscription(props.connectionId, topic, qos)
|
||||||
|
ElMessage.success(`已订阅: ${topic} (QoS ${qos})`)
|
||||||
|
} catch (e: unknown) {
|
||||||
|
ElMessage.error(`订阅失败: ${e instanceof Error ? e.message : String(e)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const subscribeInput = ref('')
|
const subscribeInput = ref('')
|
||||||
const subscribeQos = ref<0 | 1 | 2>(0)
|
const subscribeQos = ref<0 | 1 | 2>(0)
|
||||||
const subscribing = ref(false)
|
const subscribing = ref(false)
|
||||||
@@ -194,7 +264,7 @@ function getNodeClass(node: TopicNode): string {
|
|||||||
|
|
||||||
<div class="tree-list">
|
<div class="tree-list">
|
||||||
<template v-for="node in treeData" :key="node.id">
|
<template v-for="node in treeData" :key="node.id">
|
||||||
<div :class="['tree-node', getNodeClass(node)]" @click="handleNodeClick(node)">
|
<div :class="['tree-node', getNodeClass(node)]" @click="handleNodeClick(node)" @contextmenu.prevent="handleContextMenu($event, node)">
|
||||||
<span class="node-icon">{{ getNodeIcon(node) }}</span>
|
<span class="node-icon">{{ getNodeIcon(node) }}</span>
|
||||||
<span class="node-label">{{ node.label }}</span>
|
<span class="node-label">{{ node.label }}</span>
|
||||||
<span class="node-count">{{ node.count }}</span>
|
<span class="node-count">{{ node.count }}</span>
|
||||||
@@ -202,7 +272,7 @@ function getNodeClass(node: TopicNode): string {
|
|||||||
</div>
|
</div>
|
||||||
<div v-if="node.children.length > 0" class="children-group">
|
<div v-if="node.children.length > 0" class="children-group">
|
||||||
<template v-for="child in node.children" :key="child.id">
|
<template v-for="child in node.children" :key="child.id">
|
||||||
<div :class="['tree-node child', getNodeClass(child)]" @click="handleNodeClick(child)">
|
<div :class="['tree-node child', getNodeClass(child)]" @click="handleNodeClick(child)" @contextmenu.prevent="handleContextMenu($event, child)">
|
||||||
<span class="node-icon">{{ getNodeIcon(child) }}</span>
|
<span class="node-icon">{{ getNodeIcon(child) }}</span>
|
||||||
<span class="node-label">{{ child.label }}</span>
|
<span class="node-label">{{ child.label }}</span>
|
||||||
<span class="node-count">{{ child.count }}</span>
|
<span class="node-count">{{ child.count }}</span>
|
||||||
@@ -214,6 +284,7 @@ function getNodeClass(node: TopicNode): string {
|
|||||||
:key="gc.id"
|
:key="gc.id"
|
||||||
:class="['tree-node child', { selected: gc.fullPath === selectedTopic, subscribed: gc.isSubscribed }]"
|
:class="['tree-node child', { selected: gc.fullPath === selectedTopic, subscribed: gc.isSubscribed }]"
|
||||||
@click="handleNodeClick(gc)"
|
@click="handleNodeClick(gc)"
|
||||||
|
@contextmenu.prevent="handleContextMenu($event, gc)"
|
||||||
>
|
>
|
||||||
<span class="node-icon">{{ getNodeIcon(gc) }}</span>
|
<span class="node-icon">{{ getNodeIcon(gc) }}</span>
|
||||||
<span class="node-label">{{ gc.label }}</span>
|
<span class="node-label">{{ gc.label }}</span>
|
||||||
@@ -225,6 +296,8 @@ function getNodeClass(node: TopicNode): string {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ContextMenu ref="ctxMenu" :items="ctxMenuItems" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { onMounted, onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
const contextMenuHandlers = new Set<(e: MouseEvent) => void>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a custom right-click handler.
|
||||||
|
* The handler should call `e.preventDefault()` if it handles the event.
|
||||||
|
*/
|
||||||
|
export function registerContextMenu(handler: (e: MouseEvent) => void) {
|
||||||
|
contextMenuHandlers.add(handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unregisterContextMenu(handler: (e: MouseEvent) => void) {
|
||||||
|
contextMenuHandlers.delete(handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global context menu controller.
|
||||||
|
* In production: prevents default browser context menu everywhere.
|
||||||
|
* In debug (dev mode): allows native context menu.
|
||||||
|
* Custom handlers registered via registerContextMenu take priority.
|
||||||
|
*/
|
||||||
|
export function useContextMenuController() {
|
||||||
|
function onContextMenu(e: MouseEvent) {
|
||||||
|
for (const handler of contextMenuHandlers) {
|
||||||
|
handler(e)
|
||||||
|
if (e.defaultPrevented) return
|
||||||
|
}
|
||||||
|
if (import.meta.env.PROD) {
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('contextmenu', onContextMenu)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('contextmenu', onContextMenu)
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user