feat(规则管理): 添加主题规则自动发现设备功能

实现设备自动发现规则管理系统,包含以下主要功能:
1. 新增规则管理页面和API,支持创建、编辑和删除主题匹配规则
2. 添加规则匹配引擎,支持+和#通配符匹配设备主题
3. 实现Broker设备注册表,自动发现并管理符合规则的设备
4. 扩展仪表盘显示Broker信息和活跃主题
5. 修改设备卡片和详情页以区分规则发现的设备
6. 添加相关测试用例确保功能稳定性
This commit is contained in:
walkpan
2026-03-30 19:28:22 +08:00
parent 38766ca792
commit 3ea47d471e
28 changed files with 2606 additions and 22 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

View File

@@ -44,3 +44,9 @@ export const getBrokerTopics = () => request('/broker/topics')
// Dashboard
export const getDashboardStats = () => request('/dashboard')
// Rules
export const getRules = () => request('/rules')
export const createRule = (data) => request('/rules', { method: 'POST', body: data })
export const updateRule = (id, data) => request(`/rules/${id}`, { method: 'PUT', body: data })
export const deleteRule = (id) => request(`/rules/${id}`, { method: 'DELETE' })

View File

@@ -22,7 +22,7 @@
</div>
<div class="flex justify-between">
<span>协议</span>
<span>{{ device.protocol === 'ha_discovery' ? 'HA' : '自定义' }}</span>
<span>{{ device.protocol === 'ha_discovery' ? 'HA' : device.protocol === 'topic_rule' ? '规则' : '手动' }}</span>
</div>
</div>
<button

View File

@@ -0,0 +1,80 @@
<template>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
<div class="flex items-start justify-between mb-3">
<div class="flex items-center gap-2">
<span
class="w-2.5 h-2.5 rounded-full"
:class="rule.is_enabled ? 'bg-green-500' : 'bg-gray-300'"
></span>
<span class="text-sm font-medium text-gray-900 truncate max-w-[160px]">{{ rule.name }}</span>
</div>
<span class="text-xs px-2 py-0.5 rounded-full bg-gray-100 text-gray-600">{{ rule.device_type }}</span>
</div>
<div class="text-xs text-gray-500 space-y-1 mb-3">
<div class="flex justify-between">
<span>主题模式</span>
<span class="font-mono text-gray-700 truncate max-w-[180px]">{{ rule.topic_pattern }}</span>
</div>
<div v-if="rule.command_template" class="flex justify-between">
<span>命令模板</span>
<span class="font-mono text-gray-700 truncate max-w-[180px]">{{ rule.command_template }}</span>
</div>
<div v-if="rule.state_value_path" class="flex justify-between">
<span>状态路径</span>
<span class="font-mono text-gray-700">{{ rule.state_value_path }}</span>
</div>
</div>
<div class="flex items-center gap-2">
<button
@click="toggleEnabled"
class="flex-1 py-1.5 text-xs font-medium rounded-lg transition-colors"
:class="rule.is_enabled ? 'bg-green-50 text-green-700 hover:bg-green-100' : 'bg-gray-100 text-gray-500 hover:bg-gray-200'"
>
{{ rule.is_enabled ? '已启用' : '已禁用' }}
</button>
<button
@click="$emit('edit', rule)"
class="px-2 py-1.5 text-xs font-medium text-gray-600 bg-gray-50 rounded-lg hover:bg-gray-100"
>
编辑
</button>
<button
@click="handleDelete"
class="px-2 py-1.5 text-xs font-medium text-red-600 bg-red-50 rounded-lg hover:bg-red-100"
>
删除
</button>
</div>
</div>
</template>
<script setup>
import { updateRule, deleteRule } from '../api'
const props = defineProps({
rule: { type: Object, required: true },
})
const emit = defineEmits(['edit', 'deleted'])
async function toggleEnabled() {
try {
await updateRule(props.rule.id, { is_enabled: !props.rule.is_enabled })
props.rule.is_enabled = !props.rule.is_enabled
} catch {
// silent fail
}
}
async function handleDelete() {
if (!confirm(`确定要删除规则「${props.rule.name}」吗?`)) return
try {
await deleteRule(props.rule.id)
emit('deleted', props.rule.id)
} catch {
alert('删除失败')
}
}
</script>

View File

@@ -0,0 +1,146 @@
<template>
<div v-if="show" class="fixed inset-0 bg-black/50 flex items-center justify-center z-50" @click.self="$emit('close')">
<div class="bg-white rounded-xl shadow-xl w-full max-w-md mx-4 p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">{{ editRule ? '编辑规则' : '添加规则' }}</h3>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">规则名称</label>
<input
v-model="form.name"
type="text"
placeholder="如:客厅开关"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">主题模式</label>
<input
v-model="form.topic_pattern"
type="text"
placeholder="如home/+/state"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
<p class="text-xs text-gray-400 mt-1">支持 + 匹配单级# 匹配多级</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">设备类型</label>
<select
v-model="form.device_type"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
>
<option value="switch">开关</option>
<option value="light"></option>
<option value="sensor">传感器</option>
<option value="binary_sensor">二进制传感器</option>
<option value="climate">空调/温控</option>
<option value="lock"></option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">命令主题模板</label>
<input
v-model="form.command_template"
type="text"
placeholder="如home/{device_id}/set"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
<p class="text-xs text-gray-400 mt-1">{device_id} 将替换为主题中匹配的部分</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">状态值路径</label>
<input
v-model="form.state_value_path"
type="text"
placeholder="如state留空则使用整个 payload"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
<p class="text-xs text-gray-400 mt-1">JSON 字段路径留空则使用整个 payload 作为状态</p>
</div>
</div>
<div class="flex justify-end gap-3 mt-6">
<button
@click="$emit('close')"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200"
>
取消
</button>
<button
@click="handleSubmit"
:disabled="!form.name || !form.topic_pattern || submitting"
class="px-4 py-2 text-sm font-medium text-white bg-primary-600 rounded-lg hover:bg-primary-700 disabled:opacity-50"
>
{{ submitting ? '保存中...' : '保存' }}
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
import { createRule, updateRule } from '../api'
const props = defineProps({
show: Boolean,
editRule: Object,
})
const emit = defineEmits(['close', 'saved'])
const form = ref({
name: '',
topic_pattern: '',
device_type: 'switch',
command_template: '',
state_value_path: '',
is_enabled: true,
})
const submitting = ref(false)
watch(() => props.show, (val) => {
if (val && props.editRule) {
form.value = { ...props.editRule }
} else if (val) {
form.value = {
name: '',
topic_pattern: '',
device_type: 'switch',
command_template: '',
state_value_path: '',
is_enabled: true,
}
}
})
async function handleSubmit() {
submitting.value = true
try {
const data = {
name: form.value.name,
topic_pattern: form.value.topic_pattern,
device_type: form.value.device_type,
command_template: form.value.command_template || null,
state_value_path: form.value.state_value_path || null,
is_enabled: form.value.is_enabled,
}
if (props.editRule) {
await updateRule(props.editRule.id, data)
} else {
await createRule(data)
}
emit('saved')
emit('close')
} catch (e) {
alert(e.message || '保存失败')
} finally {
submitting.value = false
}
}
</script>

View File

@@ -27,7 +27,7 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { HomeIcon, CpuChipIcon, ServerIcon } from '@heroicons/vue/24/outline'
import { HomeIcon, CpuChipIcon, Cog6ToothIcon, ServerIcon } from '@heroicons/vue/24/outline'
const route = useRoute()
const connected = ref(false)
@@ -35,6 +35,7 @@ const connected = ref(false)
const navItems = [
{ path: '/dashboard', label: '仪表盘', icon: HomeIcon },
{ path: '/devices', label: '设备管理', icon: CpuChipIcon },
{ path: '/rules', label: '规则管理', icon: Cog6ToothIcon },
{ path: '/broker', label: 'Broker', icon: ServerIcon },
]

View File

@@ -19,6 +19,11 @@ const router = createRouter({
name: 'DeviceDetail',
component: () => import('../views/DeviceDetailView.vue'),
},
{
path: '/rules',
name: 'Rules',
component: () => import('../views/RulesView.vue'),
},
{
path: '/broker',
name: 'Broker',

View File

@@ -35,14 +35,31 @@ export const useDeviceStore = defineStore('devices', () => {
function handleWsMessage(data) {
if (data.type !== 'device_update') return
const idx = devices.value.findIndex((d) => d.id === data.device_id)
if (idx === -1) return
const device = devices.value[idx]
devices.value.splice(idx, 1, {
...device,
state: data.state,
is_online: data.is_online,
last_seen: data.last_seen,
})
if (idx !== -1) {
// Update existing device
const device = devices.value[idx]
devices.value.splice(idx, 1, {
...device,
state: data.state,
is_online: data.is_online,
last_seen: data.last_seen,
})
} else if (data.source === 'broker') {
// New broker device discovered via WebSocket
devices.value.push({
id: data.device_id,
name: data.device_id.replace('broker:', ''),
type: 'switch',
protocol: 'topic_rule',
mqtt_topic: '',
command_topic: null,
state: data.state,
is_online: data.is_online,
last_seen: data.last_seen,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
})
}
}
// Initialize WebSocket

View File

@@ -2,7 +2,16 @@
<div>
<h2 class="text-2xl font-bold text-gray-900 mb-6">仪表盘</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
<!-- Stats Cards -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<StatsCard
label="MQTT 连接"
:value="stats.mqtt_connected ? '已连接' : '未连接'"
:icon="SignalIcon"
:color="stats.mqtt_connected ? 'text-green-600' : 'text-red-600'"
:bg-color="stats.mqtt_connected ? 'bg-green-50' : 'bg-red-50'"
:icon-color="stats.mqtt_connected ? 'text-green-500' : 'text-red-500'"
/>
<StatsCard
label="设备总数"
:value="stats.total_devices"
@@ -29,9 +38,62 @@
/>
</div>
<!-- Broker Info + Topics -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-6">
<!-- Broker Info -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Broker 信息</h3>
<div v-if="brokerLoading" class="text-gray-400 text-sm">加载中...</div>
<div v-else-if="brokerError" class="text-red-400 text-sm">{{ brokerError }}</div>
<div v-else class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-gray-500">状态</span>
<span class="text-green-600">
{{ brokerStatusText }}
</span>
</div>
<div v-if="brokerNode" class="flex justify-between">
<span class="text-gray-500">节点</span>
<span class="text-gray-700 font-mono text-xs truncate max-w-[200px]">{{ brokerNode }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">已连接客户端</span>
<span class="text-gray-700">{{ brokerConnections }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">消息发布数</span>
<span class="text-gray-700">{{ brokerPublish }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">Broker 设备</span>
<span class="text-gray-700">{{ stats.broker_device_count || 0 }}</span>
</div>
</div>
</div>
<!-- Active Topics -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
<h3 class="text-lg font-semibold text-gray-900 mb-4">活跃主题</h3>
<div v-if="stats.broker_topics && stats.broker_topics.length === 0" class="text-gray-400 text-sm">
暂无活跃主题
</div>
<div v-else class="space-y-1.5">
<div
v-for="topic in stats.broker_topics"
:key="topic"
class="flex items-center gap-2 text-sm py-1 px-2 rounded bg-gray-50"
>
<span class="text-primary-600 font-mono text-xs">#</span>
<span class="font-mono text-gray-700 truncate">{{ topic }}</span>
</div>
</div>
</div>
</div>
<!-- Recent Activity -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
<h3 class="text-lg font-semibold text-gray-900 mb-4">最近活动</h3>
<div v-if="stats.recent_logs.length === 0" class="text-gray-400 text-sm">
<div v-if="stats.recent_logs && stats.recent_logs.length === 0" class="text-gray-400 text-sm">
暂无活动
</div>
<div v-else class="space-y-2">
@@ -56,16 +118,48 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { getDashboardStats } from '../api'
import { ref, computed, onMounted } from 'vue'
import { getDashboardStats, getBrokerStatus } from '../api'
import StatsCard from '../components/StatsCard.vue'
import { CpuChipIcon, WifiIcon, SignalSlashIcon } from '@heroicons/vue/24/outline'
import { CpuChipIcon, WifiIcon, SignalSlashIcon, SignalIcon } from '@heroicons/vue/24/outline'
const stats = ref({
total_devices: 0,
online_devices: 0,
offline_devices: 0,
recent_logs: [],
broker_topics: [],
broker_device_count: 0,
broker_online_count: 0,
mqtt_connected: false,
})
const broker = ref(null)
const brokerLoading = ref(false)
const brokerError = ref('')
// Extract display values from real EMQX response format
const brokerStatusText = computed(() => {
if (!broker.value) return '未知'
const raw = broker.value.status?.raw || ''
if (raw.includes('is running') || raw.includes('started')) return '运行中'
return raw || '未知'
})
const brokerNode = computed(() => {
if (!broker.value?.metrics) return ''
return broker.value.metrics.node || ''
})
const brokerConnections = computed(() => {
if (!broker.value?.metrics) return 0
return broker.value.metrics['client.connected'] || 0
})
const brokerPublish = computed(() => {
if (!broker.value?.metrics) return 0
return broker.value.metrics['messages.publish'] || 0
})
function formatTime(ts) {
@@ -75,10 +169,21 @@ function formatTime(ts) {
}
onMounted(async () => {
// Fetch dashboard stats
try {
stats.value = await getDashboardStats()
} catch {
// silently fail
}
// Fetch broker status
brokerLoading.value = true
try {
broker.value = await getBrokerStatus()
} catch (e) {
brokerError.value = '无法连接到 Broker'
} finally {
brokerLoading.value = false
}
})
</script>

View File

@@ -19,11 +19,12 @@
</div>
<div class="text-sm text-gray-500 space-x-3">
<span>{{ device.type }}</span>
<span>{{ device.protocol === 'ha_discovery' ? 'HA Discovery' : '自定义' }}</span>
<span>{{ device.protocol === 'ha_discovery' ? 'HA Discovery' : device.protocol === 'topic_rule' ? '主题规则' : '自定义' }}</span>
<span>ID: {{ device.id.slice(0, 8) }}</span>
</div>
</div>
<button
v-if="device.protocol !== 'topic_rule'"
@click="handleDelete"
class="px-3 py-1.5 text-sm font-medium text-red-600 bg-red-50 rounded-lg hover:bg-red-100"
>
@@ -41,7 +42,15 @@
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<DeviceControl :device="device" />
<DeviceLogList :device-id="device.id" />
<template v-if="device.protocol === 'topic_rule'">
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
<h3 class="text-lg font-semibold text-gray-900 mb-2">设备信息</h3>
<p class="text-sm text-gray-500">
此设备通过主题规则自动发现由规则管理系统管理
</p>
</div>
</template>
<DeviceLogList v-else :device-id="device.id" />
</div>
</template>
</div>

View File

@@ -14,7 +14,7 @@
<div v-else-if="store.devices.length === 0" class="text-center py-16 text-gray-400">
<p>暂无设备</p>
<p class="text-sm mt-1">点击"添加设备"手动注册或通过 HA Discovery 自动发现</p>
<p class="text-sm mt-1">配置主题匹配规则自动发现设备或通过 HA Discovery 自动发现</p>
</div>
<div v-else class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">

View File

@@ -0,0 +1,69 @@
<template>
<div>
<div class="flex items-center justify-between mb-6">
<h2 class="text-2xl font-bold text-gray-900">规则管理</h2>
<button
@click="showAdd = true"
class="px-4 py-2 text-sm font-medium text-white bg-primary-600 rounded-lg hover:bg-primary-700"
>
+ 添加规则
</button>
</div>
<div v-if="loading" class="text-gray-400">加载中...</div>
<div v-else-if="rules.length === 0" class="text-center py-16 text-gray-400">
<p>暂无规则</p>
<p class="text-sm mt-1">添加主题匹配规则系统将自动发现 MQTT 设备</p>
</div>
<div v-else class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
<RuleCard
v-for="rule in rules"
:key="rule.id"
:rule="rule"
@edit="editRule"
@deleted="handleDeleted"
/>
</div>
<RuleModal :show="showAdd" :edit-rule="editRuleData" @close="closeModal" @saved="fetchRules" />
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { getRules } from '../api'
import RuleCard from '../components/RuleCard.vue'
import RuleModal from '../components/RuleModal.vue'
const rules = ref([])
const loading = ref(false)
const showAdd = ref(false)
const editRuleData = ref(null)
async function fetchRules() {
loading.value = true
try {
rules.value = await getRules()
} finally {
loading.value = false
}
}
function editRule(rule) {
editRuleData.value = rule
showAdd.value = true
}
function closeModal() {
showAdd.value = false
editRuleData.value = null
}
function handleDeleted(ruleId) {
rules.value = rules.value.filter((r) => r.id !== ruleId)
}
onMounted(() => fetchRules())
</script>