feat: device detail page with controls and logs, broker management page
🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
82
frontend/src/components/DeviceControl.vue
Normal file
82
frontend/src/components/DeviceControl.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<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="canToggle" class="mb-4">
|
||||
<button
|
||||
@click="toggle"
|
||||
:disabled="!device.command_topic"
|
||||
class="px-6 py-2.5 text-sm font-medium rounded-lg transition-colors"
|
||||
:class="isOn ? 'bg-primary-600 text-white hover:bg-primary-700' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
|
||||
>
|
||||
{{ isOn ? '关闭' : '开启' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">发送命令</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="commandPayload"
|
||||
type="text"
|
||||
placeholder='{"state":"on"}'
|
||||
:disabled="!device.command_topic"
|
||||
class="flex-1 border border-gray-300 rounded-lg px-3 py-2 text-sm font-mono focus:ring-2 focus:ring-primary-500 focus:border-primary-500 disabled:bg-gray-50 disabled:text-gray-400"
|
||||
/>
|
||||
<button
|
||||
@click="sendCustom"
|
||||
:disabled="!device.command_topic || sending"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-primary-600 rounded-lg hover:bg-primary-700 disabled:opacity-50"
|
||||
>
|
||||
发送
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="!device.command_topic" class="text-xs text-gray-400 mt-1">此设备未配置命令主题</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { useDeviceStore } from '../stores/devices'
|
||||
|
||||
const props = defineProps({
|
||||
device: { type: Object, required: true },
|
||||
})
|
||||
|
||||
const store = useDeviceStore()
|
||||
const sending = ref(false)
|
||||
const commandPayload = ref('')
|
||||
|
||||
const canToggle = computed(() => ['switch', 'light'].includes(props.device.type))
|
||||
|
||||
const isOn = computed(() => {
|
||||
try {
|
||||
const s = JSON.parse(props.device.state || '{}')
|
||||
return s.state === 'ON' || s.state === 'on' || s.state === 'true'
|
||||
} catch {
|
||||
return props.device.state === 'ON' || props.device.state === 'on'
|
||||
}
|
||||
})
|
||||
|
||||
async function toggle() {
|
||||
const newState = isOn.value ? 'off' : 'on'
|
||||
sending.value = true
|
||||
try {
|
||||
await store.sendCommand(props.device.id, JSON.stringify({ state: newState }))
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function sendCustom() {
|
||||
if (!commandPayload.value.trim()) return
|
||||
sending.value = true
|
||||
try {
|
||||
await store.sendCommand(props.device.id, commandPayload.value)
|
||||
commandPayload.value = ''
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
46
frontend/src/components/DeviceLogList.vue
Normal file
46
frontend/src/components/DeviceLogList.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<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="logs.length === 0" class="text-gray-400 text-sm">暂无日志</div>
|
||||
<div v-else class="space-y-1.5 max-h-[300px] overflow-y-auto">
|
||||
<div
|
||||
v-for="log in logs"
|
||||
:key="log.id"
|
||||
class="flex items-center gap-3 text-sm py-1.5 border-b border-gray-50 last:border-0"
|
||||
>
|
||||
<span
|
||||
class="px-2 py-0.5 rounded text-xs font-medium shrink-0"
|
||||
:class="log.direction === 'rx' ? 'bg-blue-50 text-blue-700' : 'bg-orange-50 text-orange-700'"
|
||||
>
|
||||
{{ log.direction === 'rx' ? 'RX' : 'TX' }}
|
||||
</span>
|
||||
<span class="text-gray-700 font-mono text-xs truncate flex-1">{{ log.payload }}</span>
|
||||
<span class="text-gray-400 text-xs whitespace-nowrap">{{ formatTime(log.timestamp) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { getDeviceLogs } from '../api'
|
||||
|
||||
const props = defineProps({
|
||||
deviceId: { type: String, required: true },
|
||||
})
|
||||
|
||||
const logs = ref([])
|
||||
|
||||
function formatTime(ts) {
|
||||
if (!ts) return ''
|
||||
return new Date(ts).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
logs.value = await getDeviceLogs(props.deviceId)
|
||||
} catch {
|
||||
// silent
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -1 +1,99 @@
|
||||
<template><div>Broker</div></template>
|
||||
<template>
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-6">Broker 管理</h2>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5 mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-3">状态</h3>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<span class="text-gray-500">状态</span>
|
||||
<p class="font-medium text-green-600">运行中</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500">连接数</span>
|
||||
<p class="font-medium">{{ metrics['client.connected'] || 0 }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500">订阅数</span>
|
||||
<p class="font-medium">{{ metrics['client.subscribe'] || 0 }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500">消息发布</span>
|
||||
<p class="font-medium">{{ metrics['messages.publish'] || 0 }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="statusRaw" class="mt-3 text-xs text-gray-400">{{ statusRaw }}</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-3">
|
||||
已连接客户端 ({{ clients.length }})
|
||||
</h3>
|
||||
<div v-if="clients.length === 0" class="text-gray-400 text-sm">暂无客户端</div>
|
||||
<div v-else class="space-y-2">
|
||||
<div
|
||||
v-for="client in clients"
|
||||
:key="client.clientid"
|
||||
class="flex items-center justify-between py-1.5 border-b border-gray-50 last:border-0 text-sm"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="w-2 h-2 rounded-full"
|
||||
:class="client.connected ? 'bg-green-500' : 'bg-gray-300'"
|
||||
></span>
|
||||
<span class="font-medium text-gray-900">{{ client.clientid }}</span>
|
||||
</div>
|
||||
<span class="text-gray-500 text-xs">{{ client.ip_address }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-3">
|
||||
活跃主题 ({{ topics.length }})
|
||||
</h3>
|
||||
<div v-if="topics.length === 0" class="text-gray-400 text-sm">暂无活跃主题</div>
|
||||
<div v-else class="space-y-1">
|
||||
<div
|
||||
v-for="topic in topics"
|
||||
:key="topic.topic"
|
||||
class="py-1 border-b border-gray-50 last:border-0 font-mono text-sm text-gray-700"
|
||||
>
|
||||
{{ topic.topic }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { getBrokerStatus, getBrokerClients, getBrokerTopics } from '../api'
|
||||
|
||||
const metrics = ref({})
|
||||
const statusRaw = ref('')
|
||||
const clients = ref([])
|
||||
const topics = ref([])
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const status = await getBrokerStatus()
|
||||
metrics.value = status.metrics || {}
|
||||
statusRaw.value = status.status?.raw || ''
|
||||
} catch {
|
||||
// silent
|
||||
}
|
||||
try {
|
||||
clients.value = await getBrokerClients()
|
||||
} catch {
|
||||
// silent
|
||||
}
|
||||
try {
|
||||
topics.value = await getBrokerTopics()
|
||||
} catch {
|
||||
// silent
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1 +1,72 @@
|
||||
<template><div>Device Detail</div></template>
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="!device" class="text-gray-400">加载中...</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="flex items-center gap-2 mb-6">
|
||||
<router-link to="/devices" class="text-primary-600 hover:text-primary-700 text-sm">← 返回设备列表</router-link>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5 mb-4">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<h2 class="text-xl font-bold text-gray-900">{{ device.name }}</h2>
|
||||
<span
|
||||
class="w-2.5 h-2.5 rounded-full"
|
||||
:class="device.is_online ? 'bg-green-500' : 'bg-gray-300'"
|
||||
></span>
|
||||
</div>
|
||||
<div class="text-sm text-gray-500 space-x-3">
|
||||
<span>{{ device.type }}</span>
|
||||
<span>{{ device.protocol === 'ha_discovery' ? 'HA Discovery' : '自定义' }}</span>
|
||||
<span>ID: {{ device.id.slice(0, 8) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="handleDelete"
|
||||
class="px-3 py-1.5 text-sm font-medium text-red-600 bg-red-50 rounded-lg hover:bg-red-100"
|
||||
>
|
||||
删除设备
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid grid-cols-2 gap-x-8 gap-y-2 text-sm">
|
||||
<div><span class="text-gray-500">状态主题:</span><span class="font-mono text-gray-700">{{ device.mqtt_topic }}</span></div>
|
||||
<div><span class="text-gray-500">命令主题:</span><span class="font-mono text-gray-700">{{ device.command_topic || '无' }}</span></div>
|
||||
<div><span class="text-gray-500">当前状态:</span><span class="font-mono text-gray-700">{{ device.state || '无' }}</span></div>
|
||||
<div><span class="text-gray-500">最后活跃:</span><span class="text-gray-700">{{ device.last_seen || '从未' }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<DeviceControl :device="device" />
|
||||
<DeviceLogList :device-id="device.id" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useDeviceStore } from '../stores/devices'
|
||||
import DeviceControl from '../components/DeviceControl.vue'
|
||||
import DeviceLogList from '../components/DeviceLogList.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const store = useDeviceStore()
|
||||
|
||||
const device = computed(() => store.devices.find((d) => d.id === route.params.id))
|
||||
|
||||
onMounted(() => {
|
||||
if (!device.value) store.fetchDevices()
|
||||
})
|
||||
|
||||
async function handleDelete() {
|
||||
if (!confirm('确定要删除此设备吗?')) return
|
||||
await store.removeDevice(route.params.id)
|
||||
router.push('/devices')
|
||||
}
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user