- 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
385 lines
12 KiB
Vue
385 lines
12 KiB
Vue
<script setup lang="ts">
|
||
import { ref, computed } from 'vue'
|
||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||
import type { MqttMessage } from '../../types/mqtt'
|
||
import type { Subscription } from '../../types/mqtt'
|
||
import { useSubscriptions } from '../../stores/useSubscriptions'
|
||
import { mqttSubscribe, mqttUnsubscribe } from '../../stores/useMqttBridge'
|
||
import ContextMenu from '../common/ContextMenu.vue'
|
||
import type { ContextMenuItem } from '../common/ContextMenu.vue'
|
||
|
||
interface TopicNode {
|
||
id: string
|
||
label: string
|
||
fullPath: string
|
||
count: number
|
||
isSubscribed: boolean
|
||
subscriptionQos: number
|
||
children: TopicNode[]
|
||
}
|
||
|
||
const props = defineProps<{
|
||
messages: MqttMessage[]
|
||
selectedTopic: string
|
||
connectionId: string
|
||
connected: boolean
|
||
}>()
|
||
|
||
const emit = defineEmits<{
|
||
'update:selectedTopic': [value: string]
|
||
'select': [topic: string]
|
||
}>()
|
||
|
||
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 subscribeQos = ref<0 | 1 | 2>(0)
|
||
const subscribing = ref(false)
|
||
|
||
// Build tree from both subscriptions and messages
|
||
const treeData = computed(() => {
|
||
const root: TopicNode[] = []
|
||
const nodeMap = new Map<string, TopicNode>()
|
||
|
||
function getOrCreateNode(path: string, label: string): TopicNode {
|
||
let node = nodeMap.get(path)
|
||
if (!node) {
|
||
node = {
|
||
id: path,
|
||
label,
|
||
fullPath: path,
|
||
count: 0,
|
||
isSubscribed: false,
|
||
subscriptionQos: 0,
|
||
children: [],
|
||
}
|
||
nodeMap.set(path, node)
|
||
}
|
||
return node
|
||
}
|
||
|
||
function addToTree(fullPath: string) {
|
||
const segments = fullPath.split('/')
|
||
let currentPath = ''
|
||
let parentList = root
|
||
for (let i = 0; i < segments.length; i++) {
|
||
const seg = segments[i]
|
||
currentPath = currentPath ? `${currentPath}/${seg}` : seg
|
||
let node = parentList.find((n) => n.id === currentPath)
|
||
if (!node) {
|
||
node = getOrCreateNode(currentPath, seg)
|
||
parentList.push(node)
|
||
}
|
||
parentList = node.children
|
||
}
|
||
}
|
||
|
||
const subs = getSubscriptionsByConnection(props.connectionId)
|
||
for (const sub of subs) {
|
||
addToTree(sub.topic)
|
||
const node = nodeMap.get(sub.topic)
|
||
if (node) { node.isSubscribed = true; node.subscriptionQos = sub.qos }
|
||
}
|
||
for (const msg of props.messages) {
|
||
addToTree(msg.topic)
|
||
}
|
||
for (const msg of props.messages) {
|
||
const node = nodeMap.get(msg.topic)
|
||
if (node) node.count++
|
||
}
|
||
|
||
function sortTree(nodes: TopicNode[]) {
|
||
nodes.sort((a, b) => {
|
||
const aIsFolder = a.children.length > 0
|
||
const bIsFolder = b.children.length > 0
|
||
if (aIsFolder !== bIsFolder) return aIsFolder ? -1 : 1
|
||
return a.label.localeCompare(b.label)
|
||
})
|
||
for (const n of nodes) sortTree(n.children)
|
||
}
|
||
sortTree(root)
|
||
return root
|
||
})
|
||
|
||
function handleNodeClick(node: TopicNode) {
|
||
if (props.selectedTopic === node.fullPath) {
|
||
emit('update:selectedTopic', '')
|
||
emit('select', '')
|
||
} else {
|
||
emit('update:selectedTopic', node.fullPath)
|
||
emit('select', node.fullPath)
|
||
}
|
||
}
|
||
|
||
async function handleSubscribe() {
|
||
const topic = subscribeInput.value.trim()
|
||
if (!topic) { ElMessage.warning('请输入订阅主题'); return }
|
||
if (!props.connectionId) { ElMessage.warning('请先选择一个连接'); return }
|
||
subscribing.value = true
|
||
try {
|
||
await mqttSubscribe({ connection_id: props.connectionId, topic, qos: subscribeQos.value })
|
||
addSubscription(props.connectionId, topic, subscribeQos.value)
|
||
ElMessage.success(`已订阅: ${topic}`)
|
||
subscribeInput.value = ''
|
||
} catch (e: unknown) {
|
||
ElMessage.error(`订阅失败: ${e instanceof Error ? e.message : String(e)}`)
|
||
} finally {
|
||
subscribing.value = false
|
||
}
|
||
}
|
||
|
||
async function handleUnsubscribe(topic: string) {
|
||
if (!props.connectionId) return
|
||
try {
|
||
await ElMessageBox.confirm(`确定取消订阅 "${topic}"?`, '取消订阅', {
|
||
type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消',
|
||
})
|
||
await mqttUnsubscribe(props.connectionId, topic)
|
||
removeSubscription(props.connectionId, topic)
|
||
ElMessage.success(`已取消订阅: ${topic}`)
|
||
} catch { /* cancelled */ }
|
||
}
|
||
|
||
function getNodeIcon(node: TopicNode): string {
|
||
if (node.isSubscribed) return '✅'
|
||
if (node.children.length > 0) return '📁'
|
||
return '📄'
|
||
}
|
||
|
||
function getNodeClass(node: TopicNode): string {
|
||
if (node.fullPath === props.selectedTopic) return 'selected'
|
||
if (node.isSubscribed) return 'subscribed'
|
||
return ''
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="topic-tree">
|
||
<div class="tree-header">
|
||
<span class="tree-title">主题树</span>
|
||
<span class="tree-count">{{ treeData.length }}</span>
|
||
</div>
|
||
|
||
<div class="subscribe-row">
|
||
<el-input
|
||
v-model="subscribeInput"
|
||
size="small"
|
||
:placeholder="connectionId ? (connected ? '输入主题...' : '连接未就绪') : '先选择连接'"
|
||
:disabled="!connectionId || !connected"
|
||
@keyup.enter="handleSubscribe"
|
||
>
|
||
<template #prefix>
|
||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="color: var(--el-text-color-placeholder)"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||
</template>
|
||
<template #append>
|
||
<el-select v-model="subscribeQos" size="small" style="width: 62px" :disabled="!connectionId || !connected">
|
||
<el-option :value="0" label="Q0" />
|
||
<el-option :value="1" label="Q1" />
|
||
<el-option :value="2" label="Q2" />
|
||
</el-select>
|
||
</template>
|
||
</el-input>
|
||
<el-button
|
||
size="small"
|
||
type="primary"
|
||
:loading="subscribing"
|
||
:disabled="!connectionId || !connected || !subscribeInput.trim()"
|
||
@click="handleSubscribe"
|
||
class="sub-btn"
|
||
>+</el-button>
|
||
</div>
|
||
|
||
<div v-if="treeData.length === 0" class="empty-state">
|
||
<div class="empty-text">暂无主题</div>
|
||
<div class="empty-sub">输入上方搜索框订阅</div>
|
||
</div>
|
||
|
||
<div class="tree-list">
|
||
<template v-for="node in treeData" :key="node.id">
|
||
<div :class="['tree-node', getNodeClass(node)]" @click="handleNodeClick(node)" @contextmenu.prevent="handleContextMenu($event, node)">
|
||
<span class="node-icon">{{ getNodeIcon(node) }}</span>
|
||
<span class="node-label">{{ node.label }}</span>
|
||
<span class="node-count">{{ node.count }}</span>
|
||
<span v-if="node.isSubscribed" class="node-unsub" title="取消订阅" @click.stop="handleUnsubscribe(node.fullPath)">✕</span>
|
||
</div>
|
||
<div v-if="node.children.length > 0" class="children-group">
|
||
<template v-for="child in node.children" :key="child.id">
|
||
<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-label">{{ child.label }}</span>
|
||
<span class="node-count">{{ child.count }}</span>
|
||
<span v-if="child.isSubscribed" class="node-unsub" @click.stop="handleUnsubscribe(child.fullPath)">✕</span>
|
||
</div>
|
||
<div v-if="child.children.length > 0" class="children-group">
|
||
<div
|
||
v-for="gc in child.children"
|
||
:key="gc.id"
|
||
:class="['tree-node child', { selected: gc.fullPath === selectedTopic, subscribed: gc.isSubscribed }]"
|
||
@click="handleNodeClick(gc)"
|
||
@contextmenu.prevent="handleContextMenu($event, gc)"
|
||
>
|
||
<span class="node-icon">{{ getNodeIcon(gc) }}</span>
|
||
<span class="node-label">{{ gc.label }}</span>
|
||
<span class="node-count">{{ gc.count }}</span>
|
||
<span v-if="gc.isSubscribed" class="node-unsub" @click.stop="handleUnsubscribe(gc.fullPath)">✕</span>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
|
||
<ContextMenu ref="ctxMenu" :items="ctxMenuItems" />
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.topic-tree {
|
||
width: 240px;
|
||
min-width: 200px;
|
||
border: 1px solid var(--el-border-color);
|
||
border-radius: 8px;
|
||
background: var(--el-bg-color);
|
||
display: flex;
|
||
flex-direction: column;
|
||
font-size: 13px;
|
||
overflow: hidden;
|
||
}
|
||
.tree-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 8px 10px;
|
||
border-bottom: 1px solid var(--el-border-color-light);
|
||
flex-shrink: 0;
|
||
}
|
||
.tree-title {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
}
|
||
.tree-count {
|
||
font-size: 10px;
|
||
color: var(--el-text-color-secondary);
|
||
background: var(--el-fill-color);
|
||
padding: 0 6px;
|
||
border-radius: 6px;
|
||
line-height: 18px;
|
||
}
|
||
.subscribe-row {
|
||
display: flex;
|
||
gap: 4px;
|
||
padding: 6px 8px;
|
||
border-bottom: 1px solid var(--el-border-color-light);
|
||
flex-shrink: 0;
|
||
}
|
||
.sub-btn {
|
||
flex-shrink: 0;
|
||
width: 28px;
|
||
padding: 0 !important;
|
||
font-size: 16px;
|
||
font-weight: 700;
|
||
border-radius: 6px;
|
||
}
|
||
.empty-state {
|
||
padding: 24px 16px;
|
||
text-align: center;
|
||
color: var(--el-text-color-placeholder);
|
||
}
|
||
.empty-text { font-size: 13px; font-weight: 500; }
|
||
.empty-sub { font-size: 11px; margin-top: 2px; opacity: 0.7; }
|
||
.tree-list {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 2px 0;
|
||
}
|
||
.tree-node {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
padding: 3px 8px;
|
||
cursor: pointer;
|
||
border-radius: 0;
|
||
transition: background 0.1s;
|
||
user-select: none;
|
||
margin: 0 2px;
|
||
border-radius: 4px;
|
||
}
|
||
.tree-node:hover { background: var(--el-fill-color-light); }
|
||
.tree-node.selected { background: var(--el-color-primary-light-9) !important; color: var(--el-color-primary); font-weight: 600; }
|
||
.tree-node.subscribed { color: var(--el-color-success); }
|
||
.tree-node.child { padding-left: 20px; }
|
||
.node-icon { font-size: 12px; flex-shrink: 0; }
|
||
.node-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.node-count { font-size: 11px; color: var(--el-text-color-secondary); background: var(--el-fill-color); padding: 0 5px; border-radius: 6px; min-width: 16px; text-align: center; flex-shrink: 0; line-height: 16px; }
|
||
.node-unsub { font-size: 10px; color: var(--el-color-danger); cursor: pointer; padding: 0 2px; opacity: 0; transition: opacity 0.12s; flex-shrink: 0; }
|
||
.tree-node:hover .node-unsub { opacity: 1; }
|
||
.children-group { margin-left: 8px; border-left: 1px solid var(--el-border-color-extra-light); }
|
||
</style> |