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:
walkpan
2026-03-29 22:04:49 +08:00
parent 4c16e5c0c9
commit 5f09c6f144
4 changed files with 299 additions and 2 deletions

View 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>

View 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>

View File

@@ -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>

View File

@@ -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">&larr; 返回设备列表</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>