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:
+26
@@ -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>
|
||||
@@ -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 |
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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");
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
}))
|
||||
}
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module "*.vue" {
|
||||
import type { DefineComponent } from "vue";
|
||||
const component: DefineComponent<{}, {}, any>;
|
||||
export default component;
|
||||
}
|
||||
Reference in New Issue
Block a user