feat(规则管理): 添加主题规则自动发现设备功能
实现设备自动发现规则管理系统,包含以下主要功能: 1. 新增规则管理页面和API,支持创建、编辑和删除主题匹配规则 2. 添加规则匹配引擎,支持+和#通配符匹配设备主题 3. 实现Broker设备注册表,自动发现并管理符合规则的设备 4. 扩展仪表盘显示Broker信息和活跃主题 5. 修改设备卡片和详情页以区分规则发现的设备 6. 添加相关测试用例确保功能稳定性
This commit is contained in:
BIN
frontend/device-detail-screenshot.png
Normal file
BIN
frontend/device-detail-screenshot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
@@ -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' })
|
||||
|
||||
@@ -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
|
||||
|
||||
80
frontend/src/components/RuleCard.vue
Normal file
80
frontend/src/components/RuleCard.vue
Normal 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>
|
||||
146
frontend/src/components/RuleModal.vue
Normal file
146
frontend/src/components/RuleModal.vue
Normal 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>
|
||||
@@ -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 },
|
||||
]
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
69
frontend/src/views/RulesView.vue
Normal file
69
frontend/src/views/RulesView.vue
Normal 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>
|
||||
Reference in New Issue
Block a user