feat: initial MQTT v5 debugging tool

- Tauri v2 + Vue 3 + TypeScript + Element Plus
- Connection management (CRUD + enable/disable/test)
- Preset system with {{variable}} placeholders
- Dashboard with live message scrolling + topic tree
- Payload inspector (JSON/Hex/Base64/Plain)
- Protocol-level logging (file + UI dual channel)
- Global variable manager with localStorage persistence
- MQTT v5 property support (user properties, content type, etc.)
- SubAck result tracking with subscribe_tracked API
This commit is contained in:
Joe Allen
2026-08-18 14:32:57 +08:00
commit 154fb15130
63 changed files with 11626 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
<script setup lang="ts">
import AppLayout from "./components/AppLayout.vue";
</script>
<template>
<AppLayout />
</template>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body, #app {
height: 100%;
overflow: hidden;
}
body {
font-family: "Inter", "Helvetica Neue", "PingFang SC", "Microsoft YaHei", sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
</style>
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

+155
View File
@@ -0,0 +1,155 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useMqttBridge } from '../stores/useMqttBridge'
import ConnectionList from './connections/ConnectionList.vue'
import PresetList from './presets/PresetList.vue'
import DashboardView from './dashboard/DashboardView.vue'
const { init } = useMqttBridge()
const activeTab = ref<'connections' | 'presets' | 'dashboard'>('dashboard')
onMounted(() => {
init()
})
</script>
<template>
<div class="app-layout">
<header class="app-header">
<div class="header-left">
<div class="app-brand">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="brand-icon">
<circle cx="12" cy="12" r="10"/>
<path d="M12 6v6l4 2"/>
</svg>
<span class="brand-name">MQTTX</span>
<span class="brand-version">v0.1</span>
</div>
</div>
<div class="header-tabs">
<button
:class="['tab-btn', { active: activeTab === 'dashboard' }]"
@click="activeTab = 'dashboard'"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
看板
</button>
<button
:class="['tab-btn', { active: activeTab === 'connections' }]"
@click="activeTab = 'connections'"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4h16v16H4z"/><path d="M9 9h6v6H9z"/><path d="M9 12h6"/></svg>
连接管理
</button>
<button
:class="['tab-btn', { active: activeTab === 'presets' }]"
@click="activeTab = 'presets'"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
预设
</button>
</div>
<div class="header-right" />
</header>
<main class="app-main">
<ConnectionList v-if="activeTab === 'connections'" />
<PresetList v-if="activeTab === 'presets'" />
<DashboardView v-if="activeTab === 'dashboard'" />
</main>
</div>
</template>
<style scoped>
.app-layout {
display: flex;
flex-direction: column;
height: 100vh;
background: var(--el-bg-color-page);
}
/* ─── Header ─────────────────────────────────────────────────── */
.app-header {
display: flex;
align-items: center;
padding: 0 16px;
height: 48px;
background: var(--el-bg-color);
border-bottom: 1px solid var(--el-border-color-light);
flex-shrink: 0;
gap: 24px;
}
.header-left {
display: flex;
align-items: center;
}
.app-brand {
display: flex;
align-items: center;
gap: 8px;
}
.brand-icon {
color: var(--el-color-primary);
}
.brand-name {
font-size: 18px;
font-weight: 700;
color: var(--el-color-primary);
letter-spacing: 0.5px;
}
.brand-version {
font-size: 11px;
color: var(--el-text-color-placeholder);
background: var(--el-fill-color);
padding: 0 5px;
border-radius: 4px;
line-height: 18px;
}
/* ─── Tabs ───────────────────────────────────────────────────── */
.header-tabs {
display: flex;
gap: 2px;
background: var(--el-fill-color);
padding: 2px;
border-radius: 8px;
}
.tab-btn {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 0 12px;
height: 28px;
border: none;
background: transparent;
border-radius: 6px;
font-size: 13px;
color: var(--el-text-color-secondary);
cursor: pointer;
transition: all 0.15s;
font-family: inherit;
white-space: nowrap;
}
.tab-btn:hover {
color: var(--el-text-color-primary);
}
.tab-btn.active {
background: var(--el-bg-color);
color: var(--el-color-primary);
font-weight: 500;
box-shadow: 0 1px 2px rgba(0,0,0,0.06);
}
.tab-btn svg {
flex-shrink: 0;
}
.header-right {
flex: 1;
}
/* ─── Main ───────────────────────────────────────────────────── */
.app-main {
flex: 1;
padding: 14px;
overflow: auto;
}
</style>
@@ -0,0 +1,232 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import type { MqttConnection } from '../../types/mqtt'
import { defaultConnection } from '../../types/mqtt'
import { mqttTestConnection } from '../../stores/useMqttBridge'
import { toConnectionDto } from '../../types/mqtt'
const props = defineProps<{
modelValue: boolean
connection: MqttConnection | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
save: [data: MqttConnection]
}>()
const form = ref<MqttConnection>(defaultConnection())
const testing = ref(false)
watch(
() => props.connection,
(conn) => {
if (conn) {
form.value = { ...conn }
} else {
form.value = defaultConnection()
}
},
{ immediate: true },
)
function generateClientId() {
form.value.clientId = `mqttx_${Math.random().toString(36).slice(2, 10)}`
}
function handleSave() {
if (!form.value.name.trim()) {
ElMessage.warning('请输入连接名称')
return
}
if (!form.value.host.trim()) {
ElMessage.warning('请输入主机地址')
return
}
if (!form.value.clientId.trim()) {
ElMessage.warning('请输入 Client ID')
return
}
emit('save', { ...form.value })
emit('update:modelValue', false)
}
function handleCancel() {
emit('update:modelValue', false)
}
async function handleTestConnection() {
if (!form.value.host.trim()) {
ElMessage.warning('请输入主机地址')
return
}
testing.value = true
try {
const msg = await mqttTestConnection(toConnectionDto(form.value))
ElMessage.success(msg)
} catch (e: unknown) {
const err = e instanceof Error ? e.message : String(e)
ElMessage.error(err)
} finally {
testing.value = false
}
}
</script>
<template>
<el-dialog
:model-value="modelValue"
:title="connection ? '编辑连接' : '新增连接'"
width="600px"
@update:model-value="(val: boolean) => emit('update:modelValue', val)"
>
<el-form
ref="formRef"
:model="form"
label-width="110px"
label-position="top"
size="small"
>
<el-form-item label="名称">
<el-input v-model="form.name" placeholder="给连接起个名字" />
</el-form-item>
<el-row :gutter="12">
<el-col :span="6">
<el-form-item label="协议">
<el-select v-model="form.ssl" :placeholder="false">
<el-option :value="false" label="mqtt://" />
<el-option :value="true" label="mqtts://" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="主机">
<el-input v-model="form.host" placeholder="localhost" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="端口">
<el-input-number
v-model="form.port"
:min="1"
:max="65535"
style="width: 100%"
/>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="用户名">
<el-input v-model="form.username" placeholder="(可选)" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="密码">
<el-input
v-model="form.password"
type="password"
show-password
placeholder="(可选)"
/>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="Client ID">
<el-input v-model="form.clientId" placeholder="自动生成即可">
<template #append>
<el-button @click="generateClientId">重新生成</el-button>
</template>
</el-input>
</el-form-item>
<el-divider border-style="dashed" />
<div class="section-title">MQTT v5 选项</div>
<el-row :gutter="12">
<el-col :span="8">
<el-form-item label="Clean Start">
<el-switch v-model="form.cleanStart" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="Session Expiry">
<el-input-number
v-model="form.sessionExpiryInterval"
:min="0"
style="width: 100%"
/>
<span class="unit">秒</span>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="Keep Alive">
<el-input-number
v-model="form.keepAlive"
:min="10"
:max="3600"
style="width: 100%"
/>
<span class="unit">秒</span>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="12">
<el-col :span="8">
<el-form-item label="Receive Max">
<el-input-number
v-model="form.receiveMaximum"
:min="0"
:value-on-clear="null"
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="Max Packet Size">
<el-input-number
v-model="form.maximumPacketSize"
:min="0"
:value-on-clear="null"
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="Topic Alias Max">
<el-input-number
v-model="form.topicAliasMaximum"
:min="0"
:value-on-clear="null"
style="width: 100%"
/>
</el-form-item>
</el-col>
</el-row>
</el-form>
<template #footer>
<el-button @click="handleCancel">取消</el-button>
<el-button
:loading="testing"
:disabled="testing"
@click="handleTestConnection"
>
测试连接
</el-button>
<el-button type="primary" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.section-title {
font-size: 13px;
font-weight: 600;
color: var(--el-text-color-primary);
margin-bottom: 8px;
}
.unit {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-left: 4px;
}
</style>
@@ -0,0 +1,230 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { MqttConnection } from '../../types/mqtt'
import { useConnections } from '../../stores/useConnections'
import { toConnectionDto } from '../../types/mqtt'
import { mqttConnect, mqttDisconnect } from '../../stores/useMqttBridge'
import ConnectionForm from './ConnectionForm.vue'
const { connections, add, update, remove, updateStatus } = useConnections()
const showForm = ref(false)
const editingConnection = ref<MqttConnection | null>(null)
// ─── Connection actions ────────────────────────────────────────────────────────
async function handleConnect(conn: MqttConnection) {
try {
updateStatus(conn.id, 'connecting')
await mqttConnect(toConnectionDto(conn))
// Status will be updated by the event listener
} catch (e: any) {
updateStatus(conn.id, 'error', String(e))
ElMessage.error(`连接失败: ${e}`)
}
}
async function handleDisconnect(conn: MqttConnection) {
try {
await mqttDisconnect(conn.id)
updateStatus(conn.id, 'disconnected')
ElMessage.success('已断开')
} catch (e: any) {
ElMessage.error(`断开失败: ${e}`)
}
}
// ─── CRUD ──────────────────────────────────────────────────────────────────────
function handleAdd() {
editingConnection.value = null
showForm.value = true
}
function handleEdit(conn: MqttConnection) {
editingConnection.value = conn
showForm.value = true
}
function handleSave(data: MqttConnection) {
if (editingConnection.value) {
// Update existing
update(editingConnection.value.id, data)
ElMessage.success('连接已更新')
} else {
// Add new
const newConn = { ...data, id: crypto.randomUUID() }
add()
// Replace the default with our data
const idx = connections.value.length - 1
connections.value[idx] = { ...connections.value[idx], ...newConn }
ElMessage.success('连接已创建')
}
showForm.value = false
}
async function handleDelete(conn: MqttConnection) {
try {
await ElMessageBox.confirm(`确定删除连接 "${conn.name}"?`, '确认', {
type: 'warning',
})
if (conn.status === 'connected') {
await handleDisconnect(conn)
}
remove(conn.id)
ElMessage.success('已删除')
} catch {
// cancelled
}
}
// ─── Status helpers ────────────────────────────────────────────────────────────
function statusText(status: string): string {
switch (status) {
case 'connected':
return '已连接'
case 'connecting':
return '连接中...'
case 'error':
return '错误'
default:
return '已断开'
}
}
function statusType(status: string): string {
switch (status) {
case 'connected':
return 'success'
case 'connecting':
return 'warning'
case 'error':
return 'danger'
default:
return 'info'
}
}
</script>
<template>
<div class="connection-list">
<div class="list-header">
<h3>连接管理</h3>
<el-button type="primary" size="small" @click="handleAdd">
+ 新增连接
</el-button>
</div>
<el-table :data="connections" style="width: 100%" stripe>
<el-table-column prop="name" label="名称" min-width="120" />
<el-table-column label="地址" min-width="180">
<template #default="scope">
<span>{{ scope.row.ssl ? 'mqtts' : 'mqtt' }}://{{ scope.row.host }}:{{ scope.row.port }}</span>
</template>
</el-table-column>
<el-table-column label="协议" width="60">
<template #default>
<el-tag size="small">v5</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="120">
<template #default="scope">
<el-tag :type="statusType(scope.row.status)" size="small" effect="plain">
<span :class="['status-indicator', scope.row.status]" />
{{ statusText(scope.row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="240" fixed="right">
<template #default="scope">
<el-button
v-if="scope.row.status === 'disconnected' || scope.row.status === 'error'"
size="small"
type="success"
:loading="scope.row.status === 'connecting'"
@click="handleConnect(scope.row)"
>
连接
</el-button>
<el-button
v-else
size="small"
@click="handleDisconnect(scope.row)"
>
断开
</el-button>
<el-button
size="small"
@click="handleEdit(scope.row)"
>
编辑
</el-button>
<el-button
size="small"
type="danger"
@click="handleDelete(scope.row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="connections.length === 0" class="empty-hint">
暂无连接,点击上方按钮创建
</div>
<ConnectionForm
v-model="showForm"
:connection="editingConnection"
@save="handleSave"
/>
</div>
</template>
<style scoped>
.connection-list {
height: 100%;
}
.list-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.list-header h3 {
margin: 0;
font-size: 16px;
}
.empty-hint {
text-align: center;
padding: 40px;
color: var(--el-text-color-placeholder);
}
.status-indicator {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
margin-right: 4px;
}
.status-indicator.connected {
background: var(--el-color-success);
}
.status-indicator.connecting {
background: var(--el-color-warning);
animation: pulse 1s infinite;
}
.status-indicator.error {
background: var(--el-color-danger);
}
.status-indicator.disconnected {
background: var(--el-text-color-disabled);
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
</style>
+422
View File
@@ -0,0 +1,422 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useConnections } from '../../stores/useConnections'
import { usePresets } from '../../stores/usePresets'
import { useMessages } from '../../stores/useMessages'
import { mqttConnect, mqttDisconnect } from '../../stores/useMqttBridge'
import { toConnectionDto } from '../../types/mqtt'
import { ElMessage } from 'element-plus'
import FilterBar from './FilterBar.vue'
import TopicTree from './TopicTree.vue'
import MessageStream from './MessageStream.vue'
import SendPanel from './SendPanel.vue'
import LogPanel from './LogPanel.vue'
import type { PublishDto } from '../../types/mqtt'
const { connections, updateStatus, getConnected } = useConnections()
const { presets } = usePresets()
const { messages, clear, filterByTopic, filterByConnection, messageCount } = useMessages()
const topicFilter = ref('')
const selectedConnectionId = ref('')
const selectedTopic = ref('')
const paused = ref(false)
const showOutgoing = ref(true)
const showTopicTree = ref(true)
const showLogPanel = ref(false)
const connecting = ref(false)
const filteredMessages = computed(() => {
let result = messages.value
if (topicFilter.value.trim()) {
result = filterByTopic(topicFilter.value)
}
if (selectedConnectionId.value) {
result = filterByConnection(selectedConnectionId.value)
}
if (selectedTopic.value) {
result = result.filter((m) => m.topic === selectedTopic.value || m.topic.startsWith(selectedTopic.value + '/'))
}
if (!showOutgoing.value) {
result = result.filter((m) => m.direction === 'in')
}
return result
})
// Selected connection object
const selectedConn = computed(() =>
connections.value.find((c) => c.id === selectedConnectionId.value),
)
const isConnected = computed(() => selectedConn.value?.status === 'connected')
async function handleToggleConnect() {
const conn = selectedConn.value
if (!conn) return
if (isConnected.value) {
try {
await mqttDisconnect(conn.id)
updateStatus(conn.id, 'disconnected')
ElMessage.success('已断开')
} catch (e: any) {
ElMessage.error(`断开失败: ${e}`)
}
} else {
connecting.value = true
try {
updateStatus(conn.id, 'connecting')
await mqttConnect(toConnectionDto(conn))
// Status will be updated by the event listener
} catch (e: any) {
updateStatus(conn.id, 'error', String(e))
ElMessage.error(`连接失败: ${e}`)
} finally {
connecting.value = false
}
}
}
function handleClear() {
clear()
ElMessage.success('消息已清空')
}
function handleSent(_publish: PublishDto) {
// Outgoing message is already handled by the backend echo
}
function handleTopicSelect(topic: string) {
selectedTopic.value = topic
}
</script>
<template>
<div class="dashboard">
<!-- Toolbar -->
<div class="toolbar-card">
<div class="toolbar-left">
<!-- Connection Selector -->
<div class="toolbar-group">
<div class="conn-select-wrap">
<el-select
v-model="selectedConnectionId"
placeholder="选择连接"
size="small"
style="width: 180px"
clearable
>
<el-option
v-for="c in connections"
:key="c.id"
:label="c.name"
:value="c.id"
>
<div class="conn-option">
<span>{{ c.name }}</span>
<span :class="['status-badge', c.status]" />
</div>
</el-option>
</el-select>
<button
v-if="selectedConn"
:class="['conn-toggle-btn', { connected: isConnected, connecting: selectedConn.status === 'connecting' }]"
:disabled="selectedConn.status === 'connecting' || connecting"
@click="handleToggleConnect"
:title="isConnected ? '断开连接' : '连接'"
>
<svg v-if="isConnected" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
<svg v-else width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="1" y1="1" x2="23" y2="23"/><path d="M16.72 3.7A9 9 0 0 1 21 6.5"/><path d="M3.5 6.5a9 9 0 0 1 5.22-3.2"/><path d="M9 12a3 3 0 0 1 3-3 3 3 0 0 1 1.24.26"/><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/></svg>
</button>
</div>
<FilterBar v-model="topicFilter" />
</div>
<!-- View Toggles -->
<div class="toolbar-group">
<el-button
size="small"
:class="['view-btn', { active: showTopicTree }]"
@click="showTopicTree = !showTopicTree"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 3h14v4H5z"/><path d="M3 10h7v11H3z"/><path d="M14 10h7v11h-7z"/></svg>
主题树
</el-button>
<el-button
size="small"
:class="['view-btn', { active: showLogPanel }]"
@click="showLogPanel = !showLogPanel"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
日志
</el-button>
</div>
</div>
<div class="toolbar-right">
<el-checkbox v-model="showOutgoing" size="small">
<span class="checkbox-label">显示发送</span>
</el-checkbox>
<div class="msg-stat">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
<span>{{ messageCount }}</span>
</div>
<div class="toolbar-divider" />
<el-button
size="small"
:class="['action-btn', { 'is-paused': paused }]"
@click="paused = !paused"
>
<svg v-if="paused" width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>
<svg v-else width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>
{{ paused ? '继续' : '暂停' }}
</el-button>
<el-button
size="small"
class="action-btn danger"
@click="handleClear"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
清空
</el-button>
</div>
</div>
<!-- Connection not connected hint -->
<div
v-if="selectedConn && !isConnected && selectedConn.status !== 'connecting'"
class="connect-hint"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
连接「{{ selectedConn.name }}」未连接,请先
<button class="connect-hint-btn" @click="handleToggleConnect">点击连接</button>
</div>
<!-- Main Content -->
<div class="content-area">
<TopicTree
v-if="showTopicTree"
:messages="messages"
:selected-topic="selectedTopic"
:connection-id="selectedConnectionId"
:connected="isConnected"
@select="handleTopicSelect"
/>
<MessageStream
:messages="filteredMessages"
:paused="paused"
@update:paused="paused = $event"
/>
</div>
<!-- Log Panel -->
<LogPanel v-if="showLogPanel" v-model:show="showLogPanel" />
<!-- Send Panel -->
<SendPanel
:connections="connections"
:presets="presets"
@sent="handleSent"
/>
</div>
</template>
<style scoped>
.dashboard {
display: flex;
flex-direction: column;
height: 100%;
gap: 10px;
}
/* ─── Toolbar ─────────────────────────────────────────────────── */
.toolbar-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 14px;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color);
border-radius: 8px;
flex-shrink: 0;
}
.toolbar-left {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.toolbar-right {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.toolbar-group {
display: flex;
align-items: center;
gap: 6px;
}
.toolbar-divider {
width: 1px;
height: 20px;
background: var(--el-border-color);
}
/* Connection selector with toggle button */
.conn-select-wrap {
display: flex;
align-items: center;
gap: 4px;
}
.conn-option {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.status-badge {
display: inline-block;
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
}
.status-badge.connected { background: var(--el-color-success); }
.status-badge.disconnected { background: var(--el-text-color-disabled); }
.status-badge.connecting { background: var(--el-color-warning); }
.status-badge.error { background: var(--el-color-danger); }
.conn-toggle-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: 1px solid var(--el-border-color);
border-radius: 6px;
background: var(--el-bg-color);
cursor: pointer;
color: var(--el-text-color-secondary);
transition: all 0.15s;
flex-shrink: 0;
}
.conn-toggle-btn:hover {
border-color: var(--el-color-primary);
color: var(--el-color-primary);
}
.conn-toggle-btn.connected {
color: var(--el-color-success);
border-color: var(--el-color-success);
}
.conn-toggle-btn.connecting {
opacity: 0.5;
cursor: not-allowed;
}
/* View toggle buttons */
.view-btn {
--el-button-bg-color: transparent;
--el-button-border-color: var(--el-border-color);
--el-button-hover-bg-color: var(--el-fill-color);
--el-button-hover-border-color: var(--el-border-color);
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--el-text-color-secondary);
padding: 0 10px !important;
height: 28px;
border-radius: 6px;
}
.view-btn.active {
--el-button-bg-color: var(--el-color-primary-light-9);
--el-button-border-color: var(--el-color-primary-light-5);
color: var(--el-color-primary);
}
.view-btn svg {
flex-shrink: 0;
}
/* Action buttons */
.action-btn {
--el-button-bg-color: transparent;
--el-button-border-color: var(--el-border-color);
--el-button-hover-bg-color: var(--el-fill-color);
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
padding: 0 10px !important;
height: 28px;
border-radius: 6px;
}
.action-btn.is-paused {
--el-button-bg-color: var(--el-color-warning-light-9);
--el-button-border-color: var(--el-color-warning-light-5);
color: var(--el-color-warning);
}
.action-btn.danger {
color: var(--el-color-danger);
}
.action-btn svg {
flex-shrink: 0;
}
/* Message stat */
.msg-stat {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--el-text-color-secondary);
white-space: nowrap;
}
.msg-stat svg {
flex-shrink: 0;
}
.checkbox-label {
font-size: 12px;
}
/* ─── Connection hint ──────────────────────────────────────────── */
.connect-hint {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: var(--el-color-warning-light-9);
border: 1px solid var(--el-color-warning-light-7);
border-radius: 6px;
font-size: 12px;
color: var(--el-color-warning);
flex-shrink: 0;
}
.connect-hint svg {
flex-shrink: 0;
}
.connect-hint-btn {
background: none;
border: none;
color: var(--el-color-primary);
cursor: pointer;
font-size: 12px;
font-weight: 600;
text-decoration: underline;
font-family: inherit;
padding: 0;
}
.connect-hint-btn:hover {
color: var(--el-color-primary-dark-2);
}
/* ─── Content Area ────────────────────────────────────────────── */
.content-area {
display: flex;
flex: 1;
gap: 10px;
min-height: 0;
}
</style>
+72
View File
@@ -0,0 +1,72 @@
<script setup lang="ts">
defineProps<{
modelValue: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
</script>
<template>
<div class="filter-bar">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="search-icon"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input
:value="modelValue"
@input="(e: any) => emit('update:modelValue', e.target.value)"
placeholder="搜索主题..."
class="filter-input"
/>
<button
v-if="modelValue"
class="filter-clear"
@click="emit('update:modelValue', '')"
>✕</button>
</div>
</template>
<style scoped>
.filter-bar {
display: flex;
align-items: center;
gap: 4px;
padding: 0 8px;
border: 1px solid var(--el-border-color);
border-radius: 6px;
background: var(--el-bg-color);
height: 28px;
width: 180px;
transition: border-color 0.15s;
}
.filter-bar:focus-within {
border-color: var(--el-color-primary);
}
.search-icon {
color: var(--el-text-color-placeholder);
flex-shrink: 0;
}
.filter-input {
flex: 1;
border: none;
outline: none;
font-size: 12px;
color: var(--el-text-color-regular);
background: transparent;
font-family: inherit;
}
.filter-input::placeholder {
color: var(--el-text-color-placeholder);
}
.filter-clear {
border: none;
background: none;
font-size: 12px;
color: var(--el-text-color-secondary);
cursor: pointer;
padding: 0 2px;
line-height: 1;
}
.filter-clear:hover {
color: var(--el-text-color-primary);
}
</style>
@@ -0,0 +1,190 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useGlobalVariables } from '../../stores/useGlobalVariables'
const { variables, setVariable, deleteVariable, clear } = useGlobalVariables()
const showPanel = defineModel<boolean>('show', { default: false })
const newVarName = ref('')
const newVarValue = ref('')
function addVariable() {
const name = newVarName.value.trim()
if (!name) {
ElMessage.warning('请输入变量名')
return
}
if (!/^\w+$/.test(name)) {
ElMessage.warning('变量名只能包含字母、数字和下划线')
return
}
setVariable(name, newVarValue.value.trim())
newVarName.value = ''
newVarValue.value = ''
ElMessage.success(`已添加变量: {{${name}}}`)
}
function handleDelete(name: string) {
deleteVariable(name)
ElMessage.success(`已删除变量: {{${name}}}`)
}
function handleClear() {
clear()
ElMessage.success('已清空所有全局变量')
}
const entries = ref<{ name: string; value: string }[]>([])
// Refresh entries when panel opens
function refreshEntries() {
const vars = useGlobalVariables().getAllVariables()
entries.value = Object.entries(vars).map(([name, value]) => ({ name, value }))
}
</script>
<template>
<el-dialog
:model-value="showPanel"
title="全局变量管理"
width="500px"
@update:model-value="(val) => showPanel = val"
@open="refreshEntries"
@closed="newVarName = ''; newVarValue = ''"
>
<div class="global-var-mgr">
<!-- Add new variable -->
<div class="add-row">
<el-input
v-model="newVarName"
size="small"
placeholder="变量名"
style="width: 140px"
@keyup.enter="addVariable"
/>
<span class="equals-sign">=</span>
<el-input
v-model="newVarValue"
size="small"
placeholder="值"
style="flex: 1"
@keyup.enter="addVariable"
/>
<el-button size="small" type="primary" @click="addVariable">添加</el-button>
</div>
<div class="hint-text">
变量名只能包含字母、数字和下划线。在预设模板中使用 <code v-pre>{{变量名}}</code> 引用。
</div>
<!-- Variable list -->
<div v-if="entries.length === 0" class="empty-state">
暂无全局变量,在上方添加
</div>
<div v-else class="var-list">
<div
v-for="(entry, idx) in entries"
:key="entry.name"
class="var-row"
>
<span class="var-name">{{ entry.name }}</span>
<span class="var-eq">=</span>
<el-input
:model-value="entry.value"
size="small"
style="flex: 1"
@input="(val: string) => { setVariable(entry.name, val); entries[idx] = { ...entries[idx], value: val } }"
/>
<el-button
size="small"
text
type="danger"
@click="handleDelete(entry.name)"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
</el-button>
</div>
</div>
</div>
<template #footer>
<el-button @click="showPanel = false">关闭</el-button>
<el-button
v-if="entries.length > 0"
type="danger"
plain
@click="handleClear"
>
清空全部
</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.global-var-mgr {
display: flex;
flex-direction: column;
gap: 10px;
}
.add-row {
display: flex;
align-items: center;
gap: 6px;
}
.equals-sign {
font-family: monospace;
color: var(--el-text-color-secondary);
font-size: 14px;
}
.hint-text {
font-size: 12px;
color: var(--el-text-color-secondary);
line-height: 1.4;
}
.hint-text code {
background: var(--el-fill-color-light);
padding: 0 4px;
border-radius: 2px;
font-family: monospace;
}
.empty-state {
text-align: center;
padding: 24px;
color: var(--el-text-color-placeholder);
font-size: 13px;
}
.var-list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 300px;
overflow-y: auto;
}
.var-row {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 6px;
background: var(--el-fill-color-lighter);
border-radius: 6px;
}
.var-name {
font-family: monospace;
font-size: 13px;
font-weight: 600;
color: var(--el-color-primary);
min-width: 80px;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.var-eq {
font-family: monospace;
color: var(--el-text-color-secondary);
}
</style>
+227
View File
@@ -0,0 +1,227 @@
<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue'
import { useLogs } from '../../stores/useLogs'
import { mqttGetLogDir } from '../../stores/useMqttBridge'
import { ElMessage } from 'element-plus'
const { logs, clear } = useLogs()
const filterLevel = ref('')
const filterConnId = ref('')
const showPanel = defineModel<boolean>('show', { default: false })
const expandedDetails = ref(new Set<string>())
const logDirPath = ref('')
function toggleDetails(id: string) {
const s = new Set(expandedDetails.value)
if (s.has(id)) s.delete(id)
else s.add(id)
expandedDetails.value = s
}
const filteredLogs = computed(() => {
let result = logs.value
if (filterLevel.value) result = result.filter((l) => l.level === filterLevel.value)
if (filterConnId.value) result = result.filter((l) => l.connectionId === filterConnId.value)
return result
})
const connectionIds = computed(() => [...new Set(logs.value.map((l) => l.connectionId))])
const logContainer = ref<HTMLDivElement | null>(null)
const autoScroll = ref(true)
function onScroll() {
if (!logContainer.value) return
const el = logContainer.value
autoScroll.value = el.scrollHeight - el.scrollTop - el.clientHeight < 30
}
watch(filteredLogs, async () => {
if (autoScroll.value) {
await nextTick()
if (logContainer.value) logContainer.value.scrollTop = logContainer.value.scrollHeight
}
})
function formatTime(ts: number): string {
const d = new Date(ts)
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}.${String(d.getMilliseconds()).padStart(3, '0')}`
}
async function handleOpenLogDir() {
try {
if (!logDirPath.value) {
logDirPath.value = await mqttGetLogDir()
}
// Try to open the directory using the opener plugin
const { open } = await import('@tauri-apps/plugin-opener')
await open(logDirPath.value)
} catch (e: any) {
ElMessage.warning(`日志目录: ${logDirPath.value || '未知'}`)
}
}
function eventIcon(event: string): string {
switch (event) {
case 'connect': return '🔌'
case 'connack': return '✅'
case 'disconnect': case 'disconnect_sent': case 'disconnect_received': case 'disconnect_cancelled': return '🔌'
case 'event_loop_started': return '▶️'
case 'publish_sent': return '📤'
case 'publish_received': return '📥'
case 'publish_error': case 'subscribe_error': case 'unsubscribe_error': case 'connection_error': return '❌'
case 'subscribe': return '📋'
case 'suback': case 'unsuback': return '✅'
case 'unsubscribe': return '🗑️'
case 'puback': case 'pubrec': case 'pubcomp': return '📨'
case 'requests_done': return '🏁'
default: return '📝'
}
}
</script>
<template>
<div class="log-panel">
<div class="log-header">
<div class="log-title-row">
<span class="log-title">事件日志</span>
<span class="log-count">{{ filteredLogs.length }}</span>
</div>
<div class="log-tools">
<el-select v-model="filterLevel" size="small" placeholder="级别" style="width: 80px" clearable>
<el-option value="" label="全部" />
<el-option value="info" label="信息" />
<el-option value="warn" label="警告" />
<el-option value="error" label="错误" />
</el-select>
<el-select v-model="filterConnId" size="small" placeholder="连接" style="width: 100px" clearable>
<el-option value="" label="全部" />
<el-option v-for="cid in connectionIds" :key="cid" :value="cid" :label="cid.substring(0, 6) + '…'" />
</el-select>
<el-button size="small" class="log-action-btn" @click="handleOpenLogDir" title="打开日志文件夹">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
文件夹
</el-button>
<el-button size="small" class="log-action-btn" @click="clear()">清空</el-button>
<el-button size="small" class="log-action-btn" @click="showPanel = false">✕</el-button>
</div>
</div>
<div class="log-list" ref="logContainer" @scroll="onScroll">
<div v-if="filteredLogs.length === 0" class="log-empty">暂无日志</div>
<div
v-for="log in filteredLogs"
:key="log.id"
>
<div :class="['log-entry', `level-${log.level}`]">
<span class="log-time">{{ formatTime(log.timestamp) }}</span>
<span :class="['log-lvl', `lvl-${log.level}`]">{{ { info: 'INF', warn: 'WRN', error: 'ERR' }[log.level] }}</span>
<span class="log-ico">{{ eventIcon(log.event) }}</span>
<span class="log-msg">{{ log.message }}</span>
<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">
{{ log.details }}
</div>
</div>
</div>
</div>
</template>
<style scoped>
.log-panel {
border: 1px solid var(--el-border-color);
border-radius: 8px;
background: var(--el-bg-color);
display: flex;
flex-direction: column;
max-height: 220px;
font-size: 12px;
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
flex-shrink: 0;
}
.log-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 10px;
border-bottom: 1px solid var(--el-border-color-light);
flex-shrink: 0;
gap: 8px;
}
.log-title-row {
display: flex;
align-items: center;
gap: 6px;
}
.log-title {
font-size: 13px;
font-weight: 600;
white-space: nowrap;
}
.log-count {
font-size: 10px;
color: var(--el-text-color-secondary);
background: var(--el-fill-color);
padding: 0 5px;
border-radius: 6px;
line-height: 16px;
}
.log-tools {
display: flex;
align-items: center;
gap: 4px;
}
.log-action-btn {
--el-button-bg-color: transparent;
--el-button-border-color: var(--el-border-color);
--el-button-hover-bg-color: var(--el-fill-color);
font-size: 11px;
height: 24px;
padding: 0 6px !important;
border-radius: 4px;
display: inline-flex;
align-items: center;
gap: 3px;
}
.log-action-btn svg {
flex-shrink: 0;
}
.log-list {
flex: 1;
overflow-y: auto;
padding: 2px 0;
}
.log-empty {
padding: 24px;
text-align: center;
color: var(--el-text-color-placeholder);
}
.log-entry {
display: flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
transition: background 0.1s;
}
.log-entry:hover { background: var(--el-fill-color-light); }
.log-time { font-size: 11px; color: var(--el-text-color-secondary); white-space: nowrap; flex-shrink: 0; }
.log-lvl { font-size: 10px; font-weight: 700; padding: 0 3px; border-radius: 2px; flex-shrink: 0; }
.lvl-info { color: var(--el-color-primary); background: var(--el-color-primary-light-9); }
.lvl-warn { color: var(--el-color-warning); background: var(--el-color-warning-light-9); }
.lvl-err { color: var(--el-color-danger); background: var(--el-color-danger-light-9); }
.log-ico { font-size: 11px; flex-shrink: 0; }
.log-msg { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--el-text-color-regular); }
.log-dtl-btn { font-size: 11px; color: var(--el-color-primary); cursor: pointer; flex-shrink: 0; opacity: 0; }
.log-entry:hover .log-dtl-btn { opacity: 1; }
.log-dtl-content {
padding: 2px 8px 4px 20px;
font-size: 11px;
color: var(--el-text-color-secondary);
background: var(--el-fill-color-lighter);
word-break: break-all;
white-space: pre-wrap;
max-height: 80px;
overflow-y: auto;
}
</style>
+393
View File
@@ -0,0 +1,393 @@
<script setup lang="ts">
import { ref, watch, nextTick } from 'vue'
import type { MqttMessage } from '../../types/mqtt'
const props = defineProps<{
messages: MqttMessage[]
paused: boolean
}>()
const emit = defineEmits<{
'update:paused': [value: boolean]
}>()
const scrollRef = ref<HTMLDivElement | null>(null)
const autoScroll = ref(true)
const expandedPayloads = ref(new Set<string>())
const messageFormats = ref<Record<string, 'auto' | 'json' | 'hex' | 'base64' | 'plain'>>({})
const formatOptions: { value: string; label: string }[] = [
{ value: 'auto', label: '自动' },
{ value: 'json', label: 'JSON' },
{ value: 'hex', label: 'Hex' },
{ value: 'base64', label: 'Base64' },
{ value: 'plain', label: '文本' },
]
watch(
() => props.messages.length,
async () => {
if (autoScroll.value && !props.paused) {
await nextTick()
if (scrollRef.value) {
scrollRef.value.scrollTop = scrollRef.value.scrollHeight
}
}
},
)
function onScroll() {
if (!scrollRef.value) return
const el = scrollRef.value
const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 50
autoScroll.value = isAtBottom
if (!isAtBottom && !props.paused) {
emit('update:paused', true)
} else if (isAtBottom && props.paused) {
emit('update:paused', false)
}
}
// ─── Payload Format Helpers ────────────────────────────────────
function isJson(str: string): boolean {
try { JSON.parse(str); return true }
catch { return false }
}
function getEffectiveFormat(msg: MqttMessage): string {
const fmt = messageFormats.value[msg.id] || 'auto'
if (fmt === 'auto') return isJson(msg.payload) ? 'json' : 'plain'
return fmt
}
function formatPayload(msg: MqttMessage): string {
const fmt = getEffectiveFormat(msg)
switch (fmt) {
case 'json':
try {
const parsed = JSON.parse(msg.payload)
return JSON.stringify(parsed, null, 2)
} catch { return msg.payload }
case 'hex':
return bytesToHex(new TextEncoder().encode(msg.payload))
case 'base64':
return btoa(msg.payload)
case 'plain':
default:
return msg.payload
}
}
function bytesToHex(bytes: Uint8Array): string {
const parts: string[] = []
for (let i = 0; i < bytes.length; i++) {
if (i > 0 && i % 16 === 0) parts.push('\n')
else if (i > 0) parts.push(' ')
parts.push(bytes[i].toString(16).padStart(2, '0'))
}
return parts.join('')
}
function setFormat(msgId: string, fmt: 'auto' | 'json' | 'hex' | 'base64' | 'plain') {
messageFormats.value = { ...messageFormats.value, [msgId]: fmt }
}
function togglePayload(msgId: string) {
const s = new Set(expandedPayloads.value)
if (s.has(msgId)) s.delete(msgId)
else s.add(msgId)
expandedPayloads.value = s
}
// ─── Display helpers ───────────────────────────────────────────
function formatTime(ts: number): string {
const d = new Date(ts)
const pad = (n: number) => String(n).padStart(2, '0')
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${String(d.getMilliseconds()).padStart(3, '0')}`
}
</script>
<template>
<div class="message-stream" ref="scrollRef" @scroll="onScroll">
<div v-if="messages.length === 0" class="empty-hint">
<div class="empty-icon">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" opacity="0.3">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
</div>
<div class="empty-text">暂无消息</div>
<div class="empty-sub">连接后消息将自动出现在这里</div>
</div>
<div
v-for="msg in messages"
:key="msg.id"
:class="['msg-card', msg.direction === 'in' ? 'msg-in' : 'msg-out']"
>
<!-- Header -->
<div class="msg-header">
<span :class="['dir-badge', msg.direction]">
<svg v-if="msg.direction === 'in'" width="10" height="10" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2zm-1 14.5v-9l5 4.5z"/></svg>
<svg v-else width="10" height="10" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2zm1 14.5v-9l5 4.5z"/></svg>
{{ msg.direction === 'in' ? '收' : '发' }}
</span>
<span class="msg-time">{{ formatTime(msg.timestamp) }}</span>
<span class="msg-topic">{{ msg.topic }}</span>
<span class="qos-badge">Q{{ msg.qos }}</span>
<span v-if="msg.retain" class="retain-badge">R</span>
</div>
<!-- v5 Properties -->
<div v-if="msg.contentType || msg.reasonCode !== undefined || msg.messageExpiryInterval" class="v5-bar">
<span v-if="msg.contentType" class="v5-chip">CT: {{ msg.contentType }}</span>
<span v-if="msg.reasonCode !== undefined" class="v5-chip">RC: {{ msg.reasonCode }}</span>
<span v-if="msg.messageExpiryInterval" class="v5-chip">Exp: {{ msg.messageExpiryInterval }}s</span>
</div>
<div v-if="msg.userProperties && msg.userProperties.length > 0" class="v5-bar">
<span v-for="(up, i) in msg.userProperties.slice(0, 3)" :key="i" class="v5-chip">
{{ up.key }}: {{ up.value }}
</span>
<span v-if="msg.userProperties.length > 3" class="v5-chip more">+{{ msg.userProperties.length - 3 }}</span>
</div>
<!-- Format Tabs + Expand -->
<div class="payload-bar">
<div class="format-tabs">
<span
v-for="opt in formatOptions"
:key="opt.value"
:class="['fmt-tab', { active: (messageFormats[msg.id] || 'auto') === opt.value }]"
@click="setFormat(msg.id, opt.value as any)"
>{{ opt.label }}</span>
</div>
<span
class="expand-btn"
@click="togglePayload(msg.id)"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline v-if="expandedPayloads.has(msg.id)" points="18 15 12 9 6 15"/>
<polyline v-else points="6 9 12 15 18 9"/>
</svg>
{{ expandedPayloads.has(msg.id) ? '收起' : '展开' }}
</span>
</div>
<!-- Payload Content -->
<div class="msg-payload" :class="{ expanded: expandedPayloads.has(msg.id) }">
<pre
v-if="expandedPayloads.has(msg.id)"
:class="['payload-full', { 'is-json': getEffectiveFormat(msg) === 'json' }]"
>{{ formatPayload(msg) }}</pre>
<pre v-else class="payload-truncated">{{ msg.payload.substring(0, 200) }}{{ msg.payload.length > 200 ? '...' : '' }}</pre>
</div>
</div>
</div>
</template>
<style scoped>
.message-stream {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 6px;
padding: 4px;
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
font-size: 13px;
}
/* ─── Empty State ────────────────────────────────────────────── */
.empty-hint {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
color: var(--el-text-color-placeholder);
gap: 6px;
}
.empty-text {
font-size: 14px;
font-weight: 500;
}
.empty-sub {
font-size: 12px;
opacity: 0.7;
}
/* ─── Message Card ───────────────────────────────────────────── */
.msg-card {
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-light);
border-radius: 8px;
padding: 8px 10px;
transition: box-shadow 0.15s;
}
.msg-card:hover {
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
}
.msg-card.msg-in {
border-left: 3px solid var(--el-color-primary);
}
.msg-card.msg-out {
border-left: 3px solid var(--el-color-success);
}
/* ─── Header ─────────────────────────────────────────────────── */
.msg-header {
display: flex;
align-items: center;
gap: 6px;
}
.dir-badge {
display: inline-flex;
align-items: center;
gap: 2px;
font-size: 10px;
font-weight: 600;
padding: 1px 5px;
border-radius: 4px;
flex-shrink: 0;
}
.dir-badge.in {
background: var(--el-color-primary-light-9);
color: var(--el-color-primary);
}
.dir-badge.out {
background: var(--el-color-success-light-9);
color: var(--el-color-success);
}
.dir-badge svg {
flex-shrink: 0;
}
.msg-time {
font-size: 11px;
color: var(--el-text-color-secondary);
white-space: nowrap;
flex-shrink: 0;
}
.msg-topic {
font-weight: 500;
color: var(--el-text-color-primary);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
}
.qos-badge {
font-size: 10px;
color: var(--el-text-color-secondary);
background: var(--el-fill-color);
padding: 0 4px;
border-radius: 3px;
flex-shrink: 0;
}
.retain-badge {
font-size: 10px;
font-weight: 700;
color: var(--el-color-warning);
background: var(--el-color-warning-light-9);
padding: 0 4px;
border-radius: 3px;
flex-shrink: 0;
}
/* ─── v5 Properties ──────────────────────────────────────────── */
.v5-bar {
display: flex;
gap: 4px;
flex-wrap: wrap;
margin-top: 4px;
}
.v5-chip {
font-size: 10px;
color: var(--el-color-info);
background: var(--el-color-info-light-9);
padding: 0 5px;
border-radius: 3px;
line-height: 18px;
}
.v5-chip.more {
color: var(--el-text-color-secondary);
background: var(--el-fill-color);
}
/* ─── Payload Bar ────────────────────────────────────────────── */
.payload-bar {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 4px;
}
.format-tabs {
display: flex;
gap: 1px;
background: var(--el-fill-color);
border-radius: 4px;
padding: 1px;
}
.fmt-tab {
font-size: 10px;
padding: 1px 6px;
cursor: pointer;
border-radius: 3px;
color: var(--el-text-color-secondary);
transition: all 0.12s;
line-height: 18px;
}
.fmt-tab:hover {
color: var(--el-color-primary);
}
.fmt-tab.active {
color: var(--el-color-primary);
background: var(--el-bg-color);
font-weight: 600;
box-shadow: 0 1px 2px rgba(0,0,0,0.06);
}
.expand-btn {
display: inline-flex;
align-items: center;
gap: 2px;
font-size: 11px;
color: var(--el-color-primary);
cursor: pointer;
opacity: 0.6;
transition: opacity 0.12s;
}
.expand-btn:hover {
opacity: 1;
}
/* ─── Payload ────────────────────────────────────────────────── */
.msg-payload {
margin-top: 2px;
}
.msg-payload pre {
margin: 0;
white-space: pre-wrap;
word-break: break-all;
color: var(--el-text-color-regular);
}
.payload-full {
max-height: 300px;
overflow: auto;
background: var(--el-fill-color-lighter);
padding: 6px 8px;
border-radius: 6px;
font-size: 12px;
line-height: 1.5;
border: 1px solid var(--el-border-color-extra-light);
}
.payload-full.is-json {
color: var(--el-color-primary-dark-2);
}
.payload-truncated {
max-height: 1.4em;
overflow: hidden;
font-size: 12px;
color: var(--el-text-color-secondary);
}
</style>
+491
View File
@@ -0,0 +1,491 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import type { MqttConnection, Preset, PublishDto } from '../../types/mqtt'
import { extractVariables, resolveTemplate, resolveUserProperties } from '../../utils/envVarResolver'
import { mqttPublish } from '../../stores/useMqttBridge'
import { useGlobalVariables } from '../../stores/useGlobalVariables'
import { ElMessage } from 'element-plus'
import VariableEditor from './VariableEditor.vue'
import UserPropertiesEditor from './UserPropertiesEditor.vue'
import GlobalVariableManager from './GlobalVariableManager.vue'
const props = defineProps<{
connections: MqttConnection[]
presets: Preset[]
}>()
const emit = defineEmits<{
sent: [message: PublishDto]
}>()
const { variables: globalVars, setVariable: setGlobalVar, importFromBindings } = useGlobalVariables()
// ─── State ─────────────────────────────────────────────────────
const selectedConnectionId = ref('')
const selectedPresetId = ref('')
const topic = ref('')
const payload = ref('')
const qos = ref<0 | 1 | 2>(0)
const retain = ref(false)
const showVariables = ref(false)
const showV5Props = ref(false)
const variableBindings = ref<Record<string, string>>({})
const userProperties = ref<{ key: string; value: string }[]>([])
const contentType = ref('')
const messageExpiryInterval = ref<number | null>(null)
const responseTopic = ref('')
const correlationData = ref('')
const sending = ref(false)
const showGlobalVarMgr = ref(false)
// ─── Computed ──────────────────────────────────────────────────
const connectedConnections = computed(() =>
props.connections.filter((c) => c.status === 'connected'),
)
const selectedPreset = computed(() =>
props.presets.find((p) => p.id === selectedPresetId.value),
)
const presetVariables = computed(() => {
if (!selectedPreset.value) return []
const vars = extractVariables(selectedPreset.value.payloadTemplate)
for (const v of extractVariables(selectedPreset.value.topic)) {
if (!vars.includes(v)) vars.push(v)
}
for (const up of selectedPreset.value.userProperties) {
for (const v of extractVariables(up.key)) if (!vars.includes(v)) vars.push(v)
for (const v of extractVariables(up.value)) if (!vars.includes(v)) vars.push(v)
}
return vars
})
// ─── Watch preset ──────────────────────────────────────────────
watch(selectedPreset, (preset) => {
if (preset) {
topic.value = preset.topic
payload.value = preset.payloadTemplate
qos.value = preset.qos
retain.value = preset.retain
userProperties.value = JSON.parse(JSON.stringify(preset.userProperties))
const newBindings: Record<string, string> = {}
const presetVars = extractVariables(preset.payloadTemplate)
const topicVars = extractVariables(preset.topic)
const allVars = [...new Set([...presetVars, ...topicVars])]
for (const v of allVars) {
// Auto-fill from global variables first, then keep existing
newBindings[v] = globalVars.value[v] ?? variableBindings.value[v] ?? ''
}
variableBindings.value = newBindings
showVariables.value = allVars.length > 0
}
})
// ─── Send ──────────────────────────────────────────────────────
async function send() {
if (!selectedConnectionId.value) {
ElMessage.warning('请先选择一个连接')
return
}
if (!topic.value.trim()) {
ElMessage.warning('请输入主题')
return
}
if (!payload.value.trim()) {
ElMessage.warning('请输入消息内容')
return
}
const resolvedTopic = resolveTemplate(topic.value, variableBindings.value)
const resolvedPayload = resolveTemplate(payload.value, variableBindings.value)
const resolvedProps = resolveUserProperties(userProperties.value, variableBindings.value)
// Save current bindings to global variables
importFromBindings(variableBindings.value)
const publishDto: PublishDto = {
connection_id: selectedConnectionId.value,
topic: resolvedTopic,
payload: resolvedPayload,
qos: qos.value,
retain: retain.value,
user_properties: resolvedProps,
content_type: contentType.value || null,
message_expiry_interval: messageExpiryInterval.value,
response_topic: responseTopic.value || null,
correlation_data: correlationData.value || null,
}
sending.value = true
try {
await mqttPublish(publishDto)
ElMessage.success('消息已发送')
emit('sent', publishDto)
} catch (e: any) {
ElMessage.error(`发送失败: ${e}`)
} finally {
sending.value = false
}
}
// ─── Save as global variable when a binding value changes ──────
function onBindingChange(name: string, value: string) {
variableBindings.value = { ...variableBindings.value, [name]: value }
// Auto-save to global if value is non-empty
if (value.trim()) {
setGlobalVar(name, value)
}
}
</script>
<template>
<div class="send-panel">
<div class="panel-header">
<span class="panel-title">发送消息</span>
<div class="panel-actions">
<el-select
v-model="selectedPresetId"
placeholder="选择预设..."
size="small"
style="width: 160px"
clearable
>
<el-option
v-for="p in presets"
:key="p.id"
:label="p.name"
:value="p.id"
/>
</el-select>
</div>
</div>
<div class="panel-body">
<!-- Row 1: Connection + Topic -->
<div class="form-row">
<div class="field" style="flex: 0 0 160px">
<label>连接</label>
<el-select
v-model="selectedConnectionId"
placeholder="选择连接"
size="small"
style="width: 100%"
>
<el-option
v-for="c in connectedConnections"
:key="c.id"
:label="c.name"
:value="c.id"
/>
</el-select>
</div>
<div class="field" style="flex: 1">
<label>主题</label>
<el-input
v-model="topic"
size="small"
placeholder="sensor/temp"
/>
</div>
<div class="field" style="flex: 0 0 90px">
<label>QoS</label>
<el-select v-model="qos" size="small">
<el-option :value="0" label="QoS 0" />
<el-option :value="1" label="QoS 1" />
<el-option :value="2" label="QoS 2" />
</el-select>
</div>
<div class="field" style="flex: 0 0 70px; align-items: center; justify-content: flex-end; padding-bottom: 0;">
<el-checkbox v-model="retain" size="small" label="Retain" />
</div>
</div>
<!-- Row 2: Payload -->
<div class="form-row">
<div class="field" style="flex: 1">
<label>Payload</label>
<el-input
v-model="payload"
type="textarea"
:rows="2"
placeholder='{"temp":{{temp}},"device":"{{deviceId}}"}'
/>
</div>
</div>
<!-- Toggle buttons -->
<div class="toggle-row">
<el-button
size="small"
:class="['toggle-btn', { active: showVariables }]"
@click="showVariables = !showVariables"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 7V4h16v3"/><path d="M9 20h6"/><path d="M12 4v16"/></svg>
变量
<span v-if="presetVariables.length" class="toggle-badge">{{ presetVariables.length }}</span>
</el-button>
<el-button
size="small"
:class="['toggle-btn', { active: showV5Props }]"
@click="showV5Props = !showV5Props"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
v5 属性
</el-button>
<el-button
size="small"
class="toggle-btn"
@click="showGlobalVarMgr = true"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>
全局变量
</el-button>
<div class="toggle-spacer" />
<el-button
type="primary"
size="small"
:loading="sending"
:disabled="!selectedConnectionId || !topic.trim()"
@click="send"
class="send-btn"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
发送
</el-button>
</div>
<!-- Variable editor (now uses onBindingChange to auto-save) -->
<transition name="slide">
<div v-if="showVariables" class="expand-section">
<div class="var-editor-header">
<span class="var-editor-title">变量值</span>
<span class="var-editor-hint">输入后自动保存为全局变量</span>
</div>
<div class="var-grid">
<div
v-for="v in presetVariables"
:key="v"
class="var-item"
>
<span class="var-label">{{ v }}</span>
<el-input
:model-value="variableBindings[v] ?? ''"
size="small"
placeholder="输入值..."
@input="(val: string) => onBindingChange(v, val)"
/>
</div>
</div>
</div>
</transition>
<!-- v5 Props -->
<transition name="slide">
<div v-if="showV5Props" class="expand-section">
<div class="v5-grid">
<div class="field">
<label>Content Type</label>
<el-input v-model="contentType" size="small" placeholder="json" />
</div>
<div class="field">
<label>消息过期(秒)</label>
<el-input-number
v-model="messageExpiryInterval"
:min="0"
size="small"
style="width: 100%"
/>
</div>
<div class="field">
<label>Response Topic</label>
<el-input v-model="responseTopic" size="small" placeholder="sensor/response" />
</div>
<div class="field">
<label>Correlation Data</label>
<el-input v-model="correlationData" size="small" placeholder="hex string" />
</div>
</div>
<UserPropertiesEditor v-model="userProperties" />
</div>
</transition>
</div>
<!-- Global Variable Manager Dialog -->
<GlobalVariableManager v-model:show="showGlobalVarMgr" />
</div>
</template>
<style scoped>
.send-panel {
border: 1px solid var(--el-border-color);
border-radius: 8px;
background: var(--el-bg-color);
flex-shrink: 0;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-bottom: 1px solid var(--el-border-color-light);
}
.panel-title {
font-size: 13px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.panel-actions {
display: flex;
align-items: center;
gap: 6px;
}
.panel-body {
padding: 8px 12px 10px;
display: flex;
flex-direction: column;
gap: 6px;
}
/* ─── Form Row ───────────────────────────────────────────────── */
.form-row {
display: flex;
gap: 8px;
align-items: flex-start;
}
.field {
display: flex;
flex-direction: column;
gap: 2px;
}
.field label {
font-size: 11px;
color: var(--el-text-color-secondary);
font-weight: 500;
}
/* ─── Toggle Row ─────────────────────────────────────────────── */
.toggle-row {
display: flex;
align-items: center;
gap: 4px;
}
.toggle-btn {
--el-button-bg-color: transparent;
--el-button-border-color: var(--el-border-color);
--el-button-hover-bg-color: var(--el-fill-color);
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--el-text-color-secondary);
padding: 0 8px !important;
height: 26px;
border-radius: 6px;
}
.toggle-btn.active {
--el-button-bg-color: var(--el-color-primary-light-9);
--el-button-border-color: var(--el-color-primary-light-5);
color: var(--el-color-primary);
}
.toggle-btn svg {
flex-shrink: 0;
}
.toggle-badge {
font-size: 10px;
background: var(--el-color-primary);
color: #fff;
padding: 0 4px;
border-radius: 6px;
line-height: 14px;
min-width: 14px;
text-align: center;
}
.toggle-spacer {
flex: 1;
}
.send-btn {
display: inline-flex;
align-items: center;
gap: 4px;
height: 28px;
padding: 0 14px !important;
border-radius: 6px;
}
.send-btn svg {
flex-shrink: 0;
}
/* ─── Variable Editor (inline) ───────────────────────────────── */
.expand-section {
padding: 8px;
background: var(--el-fill-color-light);
border-radius: 6px;
border: 1px solid var(--el-border-color-extra-light);
}
.var-editor-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.var-editor-title {
font-size: 12px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.var-editor-hint {
font-size: 11px;
color: var(--el-text-color-placeholder);
}
.var-grid {
display: flex;
flex-direction: column;
gap: 4px;
}
.var-item {
display: flex;
align-items: center;
gap: 8px;
}
.var-label {
min-width: 80px;
font-family: monospace;
font-size: 13px;
font-weight: 600;
color: var(--el-color-primary);
}
/* ─── v5 Props ───────────────────────────────────────────────── */
.v5-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
margin-bottom: 6px;
}
/* ─── Transition ─────────────────────────────────────────────── */
.slide-enter-active,
.slide-leave-active {
transition: all 0.2s ease;
overflow: hidden;
}
.slide-enter-from,
.slide-leave-to {
opacity: 0;
max-height: 0;
padding-top: 0;
padding-bottom: 0;
margin-bottom: 0;
border-width: 0;
}
.slide-enter-to,
.slide-leave-from {
opacity: 1;
max-height: 500px;
}
</style>
+312
View File
@@ -0,0 +1,312 @@
<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'
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 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)">
<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)">
<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)"
>
<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>
</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>
@@ -0,0 +1,86 @@
<script setup lang="ts">
const props = defineProps<{
modelValue: { key: string; value: string }[]
}>()
const emit = defineEmits<{
'update:modelValue': [value: { key: string; value: string }[]]
}>()
function addRow() {
emit('update:modelValue', [...props.modelValue, { key: '', value: '' }])
}
function removeRow(index: number) {
const arr = [...props.modelValue]
arr.splice(index, 1)
emit('update:modelValue', arr)
}
function updateRow(index: number, field: 'key' | 'value', val: string) {
const arr = [...props.modelValue]
arr[index] = { ...arr[index], [field]: val }
emit('update:modelValue', arr)
}
</script>
<template>
<div class="user-props-editor">
<div class="editor-header">
<span class="editor-title">User Properties</span>
<el-button size="small" type="primary" link @click="addRow">
+ 添加
</el-button>
</div>
<div
v-for="(prop, index) in modelValue"
:key="index"
class="prop-row"
>
<el-input
:model-value="prop.key"
size="small"
placeholder="Key"
style="flex: 1"
@input="(val: string) => updateRow(index, 'key', val)"
/>
<el-input
:model-value="prop.value"
size="small"
placeholder="Value"
style="flex: 1"
@input="(val: string) => updateRow(index, 'value', val)"
/>
<el-button
size="small"
type="danger"
link
@click="removeRow(index)"
>
删除
</el-button>
</div>
</div>
</template>
<style scoped>
.user-props-editor {
margin-top: 8px;
}
.editor-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 4px;
}
.editor-title {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.prop-row {
display: flex;
gap: 4px;
margin-bottom: 4px;
align-items: center;
}
</style>
@@ -0,0 +1,75 @@
<script setup lang="ts">
import { computed } from 'vue'
import { extractVariables } from '../../utils/envVarResolver'
const props = defineProps<{
payloadTemplate: string
modelValue: Record<string, string>
}>()
const emit = defineEmits<{
'update:modelValue': [value: Record<string, string>]
}>()
const variables = computed(() => {
return extractVariables(props.payloadTemplate)
})
function updateValue(name: string, value: string) {
emit('update:modelValue', { ...props.modelValue, [name]: value })
}
function formatVariableName(name: string): string {
return '\u007B\u007B' + name + '\u007D\u007D'
}
</script>
<template>
<div v-if="variables.length > 0" class="variable-editor">
<div class="variable-editor-title">变量</div>
<div class="variable-grid">
<div
v-for="v in variables"
:key="v"
class="variable-item"
>
<span class="variable-label">{{ formatVariableName(v) }}</span>
<el-input
:model-value="modelValue[v] ?? ''"
size="small"
placeholder="输入值..."
@input="(val: string) => updateValue(v, val)"
/>
</div>
</div>
</div>
</template>
<style scoped>
.variable-editor {
margin-top: 8px;
padding: 8px;
background: var(--el-fill-color-light);
border-radius: 4px;
}
.variable-editor-title {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-bottom: 6px;
}
.variable-grid {
display: flex;
flex-direction: column;
gap: 4px;
}
.variable-item {
display: flex;
align-items: center;
gap: 8px;
}
.variable-label {
min-width: 80px;
font-family: monospace;
font-size: 13px;
color: var(--el-color-primary);
}
</style>
+127
View File
@@ -0,0 +1,127 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import type { Preset } from '../../types/mqtt'
import { defaultPreset } from '../../types/mqtt'
import UserPropertiesEditor from '../dashboard/UserPropertiesEditor.vue'
const props = defineProps<{
modelValue: boolean
preset: Preset | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
save: [data: Preset]
}>()
const form = ref<Preset>(defaultPreset())
watch(
() => props.preset,
(preset) => {
if (preset) {
form.value = { ...preset, userProperties: JSON.parse(JSON.stringify(preset.userProperties)) }
} else {
form.value = defaultPreset()
}
},
{ immediate: true },
)
function handleSave() {
if (!form.value.name.trim()) {
ElMessage.warning('请输入预设名称')
return
}
if (!form.value.topic.trim()) {
ElMessage.warning('请输入主题')
return
}
emit('save', { ...form.value })
emit('update:modelValue', false)
}
function handleCancel() {
emit('update:modelValue', false)
}
</script>
<template>
<el-dialog
:model-value="modelValue"
:title="preset ? '编辑预设' : '新增预设'"
width="550px"
@update:model-value="(val: boolean) => emit('update:modelValue', val)"
>
<el-form
:model="form"
label-position="top"
size="small"
>
<el-form-item label="名称">
<el-input v-model="form.name" placeholder="温度上报" />
</el-form-item>
<el-form-item label="主题">
<el-input v-model="form.topic" placeholder="sensor/{{deviceId}}/temp" />
<div class="form-hint">
支持 <code v-pre>{{变量名}}</code> 占位符,发送时自动替换
</div>
</el-form-item>
<el-form-item label="Payload 模板">
<el-input
v-model="form.payloadTemplate"
type="textarea"
:rows="4"
placeholder='{"temp":{{temp}},"device":"{{deviceId}}"}'
/>
<div class="form-hint">
使用 <code v-pre>{{变量名}}</code> 作为占位符,发送时自动替换
</div>
</el-form-item>
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="QoS">
<el-select v-model="form.qos">
<el-option :value="0" label="QoS 0 (至多一次)" />
<el-option :value="1" label="QoS 1 (至少一次)" />
<el-option :value="2" label="QoS 2 (正好一次)" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12" style="padding-top: 24px">
<el-form-item>
<el-checkbox v-model="form.retain">Retain</el-checkbox>
</el-form-item>
</el-col>
</el-row>
<el-divider border-style="dashed" />
<div class="section-title">MQTT v5 User Properties (模板)</div>
<UserPropertiesEditor v-model="form.userProperties" />
</el-form>
<template #footer>
<el-button @click="handleCancel">取消</el-button>
<el-button type="primary" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.form-hint {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-top: 4px;
}
.form-hint code {
background: var(--el-fill-color-light);
padding: 0 4px;
border-radius: 2px;
font-family: monospace;
}
.section-title {
font-size: 13px;
font-weight: 600;
color: var(--el-text-color-primary);
margin-bottom: 4px;
}
</style>
+126
View File
@@ -0,0 +1,126 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { Preset } from '../../types/mqtt'
import { usePresets } from '../../stores/usePresets'
import PresetForm from './PresetForm.vue'
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _PresetForm = PresetForm
const { presets, add, update, remove } = usePresets()
const showForm = ref(false)
const editingPreset = ref<Preset | null>(null)
function handleAdd() {
editingPreset.value = null
showForm.value = true
}
function handleEdit(preset: Preset) {
editingPreset.value = preset
showForm.value = true
}
function handleSave(data: Preset) {
if (editingPreset.value) {
update(editingPreset.value.id, data)
ElMessage.success('预设已更新')
} else {
const newPreset = { ...data, id: crypto.randomUUID() }
add()
const idx = presets.value.length - 1
presets.value[idx] = { ...presets.value[idx], ...newPreset }
ElMessage.success('预设已创建')
}
showForm.value = false
}
async function handleDelete(preset: Preset) {
try {
await ElMessageBox.confirm(`确定删除预设 "${preset.name}"?`, '确认', {
type: 'warning',
})
remove(preset.id)
ElMessage.success('已删除')
} catch {
// cancelled
}
}
const qosLabel = (qos: number) => `QoS ${qos}`
</script>
<template>
<div class="preset-list">
<div class="list-header">
<h3>预设管理</h3>
<el-button type="primary" size="small" @click="handleAdd">
+ 新增预设
</el-button>
</div>
<el-table :data="presets" style="width: 100%" stripe>
<el-table-column prop="name" label="名称" min-width="120" />
<el-table-column prop="topic" label="主题" min-width="200" />
<el-table-column label="QoS" width="80">
<template #default="{ row }">
<el-tag size="small">{{ qosLabel(row.qos) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="Retain" width="70">
<template #default="{ row }">
<el-tag v-if="row.retain" size="small" type="warning">R</el-tag>
</template>
</el-table-column>
<el-table-column label="Payload 预览" min-width="200">
<template #default="{ row }">
<span class="payload-preview">{{ row.payloadTemplate.substring(0, 50) }}{{ row.payloadTemplate.length > 50 ? '...' : '' }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="handleEdit(row)">编辑</el-button>
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="presets.length === 0" class="empty-hint">
暂无预设,点击上方按钮创建
</div>
<PresetForm
v-model="showForm"
:preset="editingPreset"
@save="handleSave"
/>
</div>
</template>
<style scoped>
.preset-list {
height: 100%;
}
.list-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.list-header h3 {
margin: 0;
font-size: 16px;
}
.empty-hint {
text-align: center;
padding: 40px;
color: var(--el-text-color-placeholder);
}
.payload-preview {
font-family: monospace;
font-size: 12px;
color: var(--el-text-color-secondary);
}
</style>
+8
View File
@@ -0,0 +1,8 @@
import { createApp } from "vue";
import ElementPlus from "element-plus";
import "element-plus/dist/index.css";
import App from "./App.vue";
const app = createApp(App);
app.use(ElementPlus);
app.mount("#app");
+94
View File
@@ -0,0 +1,94 @@
import { ref, watch } from 'vue'
import type { MqttConnection } from '../types/mqtt'
import { defaultConnection } from '../types/mqtt'
const STORAGE_KEY = 'mqttx_connections'
function loadConnections(): MqttConnection[] {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return []
const parsed = JSON.parse(raw) as MqttConnection[]
// Ensure each connection has runtime status fields
return parsed.map((c) => ({
...c,
status: 'disconnected' as const,
lastError: '',
}))
} catch {
return []
}
}
function saveConnections(connections: MqttConnection[]) {
try {
// Strip runtime fields before persisting
const toSave = connections.map(({ status: _, lastError: __, ...rest }) => rest)
localStorage.setItem(STORAGE_KEY, JSON.stringify(toSave))
} catch {
// localStorage full or unavailable — silently ignore
}
}
const connections = ref<MqttConnection[]>(loadConnections())
// Auto-persist on changes
watch(
connections,
(val) => {
saveConnections(val)
},
{ deep: true },
)
export function useConnections() {
function add() {
const conn = defaultConnection()
connections.value.push(conn)
return conn
}
function update(id: string, data: Partial<MqttConnection>) {
const idx = connections.value.findIndex((c) => c.id === id)
if (idx !== -1) {
connections.value[idx] = { ...connections.value[idx], ...data }
}
}
function remove(id: string) {
const idx = connections.value.findIndex((c) => c.id === id)
if (idx !== -1) {
connections.value.splice(idx, 1)
}
}
function getById(id: string): MqttConnection | undefined {
return connections.value.find((c) => c.id === id)
}
function updateStatus(
id: string,
status: MqttConnection['status'],
lastError = '',
) {
const conn = getById(id)
if (conn) {
conn.status = status
conn.lastError = lastError
}
}
function getConnected(): MqttConnection[] {
return connections.value.filter((c) => c.status === 'connected')
}
return {
connections,
add,
update,
remove,
getById,
updateStatus,
getConnected,
}
}
+79
View File
@@ -0,0 +1,79 @@
import { ref, watch } from 'vue'
const STORAGE_KEY = 'mqttx_global_variables'
function loadVariables(): Record<string, string> {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return {}
return JSON.parse(raw) as Record<string, string>
} catch {
return {}
}
}
function saveVariables(vars: Record<string, string>) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(vars))
} catch {
// silently ignore
}
}
const variables = ref<Record<string, string>>(loadVariables())
watch(
variables,
(val) => {
saveVariables(val)
},
{ deep: true },
)
export function useGlobalVariables() {
function setVariable(name: string, value: string) {
if (value.trim() === '') {
delete variables.value[name]
} else {
variables.value = { ...variables.value, [name]: value }
}
}
function deleteVariable(name: string) {
const newVars = { ...variables.value }
delete newVars[name]
variables.value = newVars
}
function getVariable(name: string): string {
return variables.value[name] ?? ''
}
function clear() {
variables.value = {}
}
function importFromBindings(bindings: Record<string, string>) {
const newVars = { ...variables.value }
for (const [key, value] of Object.entries(bindings)) {
if (value.trim()) {
newVars[key] = value
}
}
variables.value = newVars
}
function getAllVariables(): Record<string, string> {
return { ...variables.value }
}
return {
variables,
setVariable,
deleteVariable,
getVariable,
clear,
importFromBindings,
getAllVariables,
}
}
+46
View File
@@ -0,0 +1,46 @@
import { ref } from 'vue'
export interface LogEntry {
id: string
timestamp: number
connectionId: string
level: 'info' | 'warn' | 'error'
event: string
message: string
details?: string
}
const MAX_LOGS = 1000
const logs = ref<LogEntry[]>([])
export function useLogs() {
function push(entry: LogEntry) {
logs.value.push(entry)
if (logs.value.length > MAX_LOGS) {
logs.value.splice(0, logs.value.length - MAX_LOGS)
}
}
function clear() {
logs.value = []
}
function filterByConnection(connectionId: string): LogEntry[] {
if (!connectionId) return logs.value
return logs.value.filter((l) => l.connectionId === connectionId)
}
function filterByLevel(level: string): LogEntry[] {
if (!level) return logs.value
return logs.value.filter((l) => l.level === level)
}
return {
logs,
push,
clear,
filterByConnection,
filterByLevel,
}
}
+41
View File
@@ -0,0 +1,41 @@
import { ref, computed } from 'vue'
import type { MqttMessage } from '../types/mqtt'
const MAX_MESSAGES = 500
const messages = ref<MqttMessage[]>([])
export function useMessages() {
function push(msg: MqttMessage) {
messages.value.push(msg)
if (messages.value.length > MAX_MESSAGES) {
messages.value.splice(0, messages.value.length - MAX_MESSAGES)
}
}
function clear() {
messages.value = []
}
function filterByTopic(topicFilter: string): MqttMessage[] {
if (!topicFilter.trim()) return messages.value
const filter = topicFilter.trim().toLowerCase()
return messages.value.filter((m) => m.topic.toLowerCase().includes(filter))
}
function filterByConnection(connectionId: string): MqttMessage[] {
if (!connectionId) return messages.value
return messages.value.filter((m) => m.connectionId === connectionId)
}
const messageCount = computed(() => messages.value.length)
return {
messages,
push,
clear,
filterByTopic,
filterByConnection,
messageCount,
}
}
+144
View File
@@ -0,0 +1,144 @@
import { invoke } from '@tauri-apps/api/core'
import { listen } from '@tauri-apps/api/event'
import { onUnmounted } from 'vue'
import type {
MqttConnectionDto,
PublishDto,
SubscribeDto,
MqttMessageEvent,
MqttStatusEvent,
MqttLogEvent,
ConnectionStatus,
} from '../types/mqtt'
import type { MqttMessage } from '../types/mqtt'
import { useConnections } from './useConnections'
import { useMessages } from './useMessages'
import { useLogs } from './useLogs'
import type { LogEntry } from './useLogs'
/**
* Initialize MQTT event listeners.
* Call this once in App.vue setup.
*/
export function useMqttBridge() {
const { updateStatus } = useConnections()
const { push: pushMessage } = useMessages()
const { push: pushLog } = useLogs()
let unlistenMessage: (() => void) | null = null
let unlistenStatus: (() => void) | null = null
let unlistenLog: (() => void) | null = null
async function init() {
// Listen for incoming messages
unlistenMessage = await listen<MqttMessageEvent>('mqtt:message', (event) => {
const e = event.payload
const msg: MqttMessage = {
id: crypto.randomUUID(),
connectionId: e.connection_id,
topic: e.topic,
payload: e.payload,
qos: e.qos,
retain: e.retain,
timestamp: e.timestamp,
direction: 'in',
reasonCode: e.reason_code ?? undefined,
userProperties: e.user_properties.map(([k, v]) => ({ key: k, value: v })),
contentType: e.content_type ?? undefined,
contentEncoding: e.content_encoding ?? undefined,
responseTopic: e.response_topic ?? undefined,
correlationData: e.correlation_data ?? undefined,
messageExpiryInterval: e.message_expiry_interval ?? undefined,
subscriptionIdentifier: e.subscription_identifier ?? undefined,
}
pushMessage(msg)
})
// Listen for connection status changes
unlistenStatus = await listen<MqttStatusEvent>('mqtt:status', (event) => {
const e = event.payload
switch (e.status) {
case 'connecting':
updateStatus(e.connection_id, 'connecting')
break
case 'connected':
updateStatus(e.connection_id, 'connected')
break
case 'disconnected':
updateStatus(e.connection_id, 'disconnected')
break
case 'error':
updateStatus(e.connection_id, 'error', e.error ?? 'Unknown error')
break
}
})
// Listen for log events
unlistenLog = await listen<MqttLogEvent>('mqtt:log', (event) => {
const e = event.payload
const log: LogEntry = {
id: crypto.randomUUID(),
timestamp: e.timestamp,
connectionId: e.connection_id,
level: e.level as 'info' | 'warn' | 'error',
event: e.event,
message: e.message,
details: e.details ?? undefined,
}
pushLog(log)
})
}
onUnmounted(() => {
unlistenMessage?.()
unlistenStatus?.()
unlistenLog?.()
})
return { init }
}
// ─── Tauri invoke wrappers ─────────────────────────────────────────────────────
export async function mqttConnect(conn: MqttConnectionDto): Promise<void> {
await invoke('mqtt_connect', { conn })
}
export async function mqttDisconnect(connectionId: string): Promise<void> {
await invoke('mqtt_disconnect', { connectionId })
}
export async function mqttPublish(publish: PublishDto): Promise<void> {
await invoke('mqtt_publish', { publish })
}
export async function mqttSubscribe(subscribe: SubscribeDto): Promise<void> {
await invoke('mqtt_subscribe', { subscribe })
}
export async function mqttUnsubscribe(
connectionId: string,
topic: string,
): Promise<void> {
await invoke('mqtt_unsubscribe', { connectionId, topic })
}
export async function mqttGetConnections(): Promise<ConnectionStatus[]> {
return await invoke<ConnectionStatus[]>('mqtt_get_connections')
}
export async function mqttGetConnectionStatus(
connectionId: string,
): Promise<ConnectionStatus> {
return await invoke<ConnectionStatus>('mqtt_get_connection_status', {
connectionId,
})
}
export async function mqttTestConnection(conn: MqttConnectionDto): Promise<string> {
return await invoke<string>('mqtt_test_connection', { conn })
}
export async function mqttGetLogDir(): Promise<string> {
return await invoke<string>('mqtt_get_log_dir')
}
+67
View File
@@ -0,0 +1,67 @@
import { ref, watch } from 'vue'
import type { Preset } from '../types/mqtt'
import { defaultPreset } from '../types/mqtt'
const STORAGE_KEY = 'mqttx_presets'
function loadPresets(): Preset[] {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return []
return JSON.parse(raw) as Preset[]
} catch {
return []
}
}
function savePresets(presets: Preset[]) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(presets))
} catch {
// silently ignore
}
}
const presets = ref<Preset[]>(loadPresets())
watch(
presets,
(val) => {
savePresets(val)
},
{ deep: true },
)
export function usePresets() {
function add() {
const preset = defaultPreset()
presets.value.push(preset)
return preset
}
function update(id: string, data: Partial<Preset>) {
const idx = presets.value.findIndex((p) => p.id === id)
if (idx !== -1) {
presets.value[idx] = { ...presets.value[idx], ...data }
}
}
function remove(id: string) {
const idx = presets.value.findIndex((p) => p.id === id)
if (idx !== -1) {
presets.value.splice(idx, 1)
}
}
function getById(id: string): Preset | undefined {
return presets.value.find((p) => p.id === id)
}
return {
presets,
add,
update,
remove,
getById,
}
}
+78
View File
@@ -0,0 +1,78 @@
import { ref, computed, watch } from 'vue'
import type { Subscription } from '../types/mqtt'
const STORAGE_KEY = 'mqttx_subscriptions'
function loadSubscriptions(): Subscription[] {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return []
return JSON.parse(raw) as Subscription[]
} catch {
return []
}
}
function saveSubscriptions(subs: Subscription[]) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(subs))
} catch {
// silently ignore
}
}
const subscriptions = ref<Subscription[]>(loadSubscriptions())
watch(
subscriptions,
(val) => {
saveSubscriptions(val)
},
{ deep: true },
)
export function useSubscriptions() {
function addSubscription(connId: string, topic: string, qos: number = 0) {
const existing = subscriptions.value.find(
(s) => s.connectionId === connId && s.topic === topic,
)
if (existing) {
existing.qos = qos
return false
}
subscriptions.value.push({
id: crypto.randomUUID(),
connectionId: connId,
topic,
qos,
})
return true
}
function removeSubscription(connId: string, topic: string) {
subscriptions.value = subscriptions.value.filter(
(s) => !(s.connectionId === connId && s.topic === topic),
)
}
function removeAllByConnection(connId: string) {
subscriptions.value = subscriptions.value.filter(
(s) => s.connectionId !== connId,
)
}
function getSubscriptionsByConnection(connId: string): Subscription[] {
return subscriptions.value.filter((s) => s.connectionId === connId)
}
const allSubscriptions = computed(() => subscriptions.value)
return {
subscriptions,
addSubscription,
removeSubscription,
removeAllByConnection,
getSubscriptionsByConnection,
allSubscriptions,
}
}
+200
View File
@@ -0,0 +1,200 @@
// ─── Subscription ──────────────────────────────────────────────────────────────
export interface Subscription {
id: string
connectionId: string
topic: string
qos: number
}
// ─── Connection ────────────────────────────────────────────────────────────────
export interface MqttConnection {
id: string
name: string
host: string
port: number
username: string
password: string
clientId: string
cleanStart: boolean
sessionExpiryInterval: number
keepAlive: number
ssl: boolean
receiveMaximum: number | null
maximumPacketSize: number | null
topicAliasMaximum: number | null
// runtime state (not persisted)
status: 'disconnected' | 'connecting' | 'connected' | 'error'
lastError: string
}
export function defaultConnection(): MqttConnection {
return {
id: crypto.randomUUID(),
name: '',
host: 'localhost',
port: 1883,
username: '',
password: '',
clientId: `mqttx_${Math.random().toString(36).slice(2, 10)}`,
cleanStart: true,
sessionExpiryInterval: 0,
keepAlive: 60,
ssl: false,
receiveMaximum: null,
maximumPacketSize: null,
topicAliasMaximum: null,
status: 'disconnected',
lastError: '',
}
}
// ─── Preset ────────────────────────────────────────────────────────────────────
export interface Preset {
id: string
name: string
topic: string
payloadTemplate: string
qos: 0 | 1 | 2
retain: boolean
userProperties: { key: string; value: string }[]
}
export function defaultPreset(): Preset {
return {
id: crypto.randomUUID(),
name: '',
topic: '',
payloadTemplate: '',
qos: 0,
retain: false,
userProperties: [],
}
}
// ─── Message ───────────────────────────────────────────────────────────────────
export interface MqttMessage {
id: string
connectionId: string
topic: string
payload: string
qos: number
retain: boolean
timestamp: number
direction: 'in' | 'out'
// v5 properties
reasonCode?: number
userProperties?: { key: string; value: string }[]
contentType?: string
contentEncoding?: string
responseTopic?: string
correlationData?: string
messageExpiryInterval?: number
subscriptionIdentifier?: number
}
// ─── Variable Bindings ─────────────────────────────────────────────────────────
export interface VariableBindings {
[key: string]: string
}
// ─── Tauri IPC DTOs ────────────────────────────────────────────────────────────
export interface MqttConnectionDto {
id: string
name: string
host: string
port: number
username: string
password: string
client_id: string
clean_start: boolean
session_expiry_interval: number
keep_alive: number
ssl: boolean
receive_maximum: number | null
maximum_packet_size: number | null
topic_alias_maximum: number | null
}
export function toConnectionDto(conn: MqttConnection): MqttConnectionDto {
return {
id: conn.id,
name: conn.name,
host: conn.host,
port: conn.port,
username: conn.username,
password: conn.password,
client_id: conn.clientId,
clean_start: conn.cleanStart,
session_expiry_interval: conn.sessionExpiryInterval,
keep_alive: conn.keepAlive,
ssl: conn.ssl,
receive_maximum: conn.receiveMaximum,
maximum_packet_size: conn.maximumPacketSize,
topic_alias_maximum: conn.topicAliasMaximum,
}
}
export interface PublishDto {
connection_id: string
topic: string
payload: string
qos: number
retain: boolean
user_properties: { key: string; value: string }[]
content_type: string | null
message_expiry_interval: number | null
response_topic: string | null
correlation_data: string | null
}
export interface SubscribeDto {
connection_id: string
topic: string
qos: number
}
export interface MqttMessageEvent {
connection_id: string
topic: string
payload: string
qos: number
retain: boolean
timestamp: number
reason_code: number | null
user_properties: [string, string][]
content_type: string | null
content_encoding: string | null
response_topic: string | null
correlation_data: string | null
message_expiry_interval: number | null
subscription_identifier: number | null
}
export interface MqttStatusEvent {
connection_id: string
status: string
error: string | null
session_present: boolean | null
reason_code: number | null
}
export interface ConnectionStatus {
id: string
connected: boolean
error: string | null
}
export interface MqttLogEvent {
timestamp: number
connection_id: string
level: string
event: string
message: string
details: string | null
}
+58
View File
@@ -0,0 +1,58 @@
/**
* Extract all {{variable}} placeholder names from a template string.
* Returns unique variable names in order of first appearance.
*/
export function extractVariables(template: string): string[] {
const regex = /\{\{(\w+)\}\}/g
const names: string[] = []
const seen = new Set<string>()
let match: RegExpExecArray | null
while ((match = regex.exec(template)) !== null) {
if (!seen.has(match[1])) {
seen.add(match[1])
names.push(match[1])
}
}
return names
}
/**
* Replace {{variable}} placeholders with values from bindings.
* Unresolved placeholders are left as-is.
*/
export function resolveTemplate(
template: string,
bindings: Record<string, string>,
): string {
return template.replace(/\{\{(\w+)\}\}/g, (_, name: string) => {
return bindings[name] ?? `{{${name}}}`
})
}
/**
* Extract user property placeholder names from a list of key-value pairs.
* Both key and value can contain {{variables}}.
*/
export function extractUserPropertyVariables(
userProperties: { key: string; value: string }[],
): string[] {
const names = new Set<string>()
for (const prop of userProperties) {
for (const p of extractVariables(prop.key)) names.add(p)
for (const p of extractVariables(prop.value)) names.add(p)
}
return [...names]
}
/**
* Resolve user property key-value pairs with variable bindings.
*/
export function resolveUserProperties(
userProperties: { key: string; value: string }[],
bindings: Record<string, string>,
): { key: string; value: string }[] {
return userProperties.map((p) => ({
key: resolveTemplate(p.key, bindings),
value: resolveTemplate(p.value, bindings),
}))
}
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>;
export default component;
}