Files
HomeOS/docs/superpowers/plans/2026-03-29-mqtt-home-frontend.md
walkpan 3ea47d471e feat(规则管理): 添加主题规则自动发现设备功能
实现设备自动发现规则管理系统,包含以下主要功能:
1. 新增规则管理页面和API,支持创建、编辑和删除主题匹配规则
2. 添加规则匹配引擎,支持+和#通配符匹配设备主题
3. 实现Broker设备注册表,自动发现并管理符合规则的设备
4. 扩展仪表盘显示Broker信息和活跃主题
5. 修改设备卡片和详情页以区分规则发现的设备
6. 添加相关测试用例确保功能稳定性
2026-03-30 19:28:22 +08:00

1368 lines
39 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# MQTT 智能家居管理系统 - 前端实现计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 使用 Vue 3 + Tailwind CSS 构建响应式 SPA 前端,连接后端 REST API 和 WebSocket实现设备管理、仪表盘、Broker 管理三大页面。
**Architecture:** Vue 3 Composition API + Vite 构建工具Pinia 状态管理WebSocket 实时更新设备状态),通过 API 模块与后端 FastAPI 通信Vite 配置代理解决开发跨域。
**Tech Stack:** Vue 3, Vite, Tailwind CSS 3, Pinia, Vue Router 4, @heroicons/vue
---
## 后端 API 接口参考
前端需要对接的后端 API已在后端实现中就绪
| 方法 | 路径 | 返回类型 |
|------|------|----------|
| GET | `/api/devices` | `DeviceResponse[]` |
| POST | `/api/devices` | `DeviceResponse` (body: `{name, type, mqtt_topic, command_topic?}`) |
| GET | `/api/devices/{id}` | `DeviceResponse` |
| PUT | `/api/devices/{id}` | `DeviceResponse` (body: `{name?, type?, command_topic?}`) |
| DELETE | `/api/devices/{id}` | 204 |
| POST | `/api/devices/{id}/command` | `DeviceLogResponse` (body: `{payload: string}`) |
| GET | `/api/devices/{id}/logs` | `DeviceLogResponse[]` |
| GET | `/api/broker/status` | `{status, metrics}` |
| GET | `/api/broker/clients` | `BrokerClient[]` |
| GET | `/api/broker/topics` | `BrokerTopic[]` |
| GET | `/api/dashboard` | `DashboardStats` |
| GET | `/health` | `{status, mqtt_connected}` |
| WS | `/ws/devices` | `{type:"device_update", device_id, state, is_online, last_seen}` |
---
## 文件结构总览
```
frontend/
├── index.html
├── package.json
├── vite.config.js
├── tailwind.config.js
├── postcss.config.js
├── src/
│ ├── main.js # 入口:创建 app、router、pinia
│ ├── App.vue # 根组件:布局 + 侧边栏
│ ├── style.css # Tailwind 导入
│ ├── api/
│ │ └── index.js # HTTP 请求封装fetch
│ ├── composables/
│ │ └── useWebSocket.js # WebSocket 连接与自动重连
│ ├── stores/
│ │ └── devices.js # Pinia store设备列表 + 实时更新
│ ├── router/
│ │ └── index.js # Vue Router 路由配置
│ ├── components/
│ │ ├── DeviceCard.vue # 设备卡片(列表页用)
│ │ ├── DeviceControl.vue # 设备控制面板(详情页用,按类型自适应)
│ │ ├── DeviceLogList.vue # 消息日志列表
│ │ ├── StatsCard.vue # 统计数字卡片
│ │ ├── AddDeviceModal.vue # 添加设备弹窗
│ │ └── Sidebar.vue # 侧边导航栏
│ └── views/
│ ├── DashboardView.vue # 仪表盘页面
│ ├── DevicesView.vue # 设备列表页面
│ ├── DeviceDetailView.vue # 设备详情页面
│ └── BrokerView.vue # Broker 管理页面
```
---
### Task 1: Vue 项目初始化与配置
**Files:**
- Create: `frontend/package.json`
- Create: `frontend/vite.config.js`
- Create: `frontend/tailwind.config.js`
- Create: `frontend/postcss.config.js`
- Create: `frontend/index.html`
- Create: `frontend/src/main.js`
- Create: `frontend/src/App.vue`
- Create: `frontend/src/style.css`
- [ ] **Step 1: 创建 `frontend/package.json`**
```json
{
"name": "mqtt-home-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.5.0",
"vue-router": "^4.4.0",
"pinia": "^2.2.0",
"@heroicons/vue": "^2.2.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.1.0",
"vite": "^5.4.0",
"tailwindcss": "^3.4.0",
"postcss": "^8.4.0",
"autoprefixer": "^10.4.0"
}
}
```
- [ ] **Step 2: 创建 `frontend/vite.config.js`**
```javascript
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
port: 3000,
proxy: {
'/api': 'http://localhost:8000',
'/ws': {
target: 'http://localhost:8000',
ws: true,
},
'/health': 'http://localhost:8000',
},
},
build: {
outDir: 'dist',
},
})
```
- [ ] **Step 3: 创建 `frontend/tailwind.config.js`**
```javascript
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{vue,js,ts}'],
theme: {
extend: {
colors: {
primary: {
50: '#eff6ff',
100: '#dbeafe',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
},
},
},
},
plugins: [],
}
```
- [ ] **Step 4: 创建 `frontend/postcss.config.js`**
```javascript
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
```
- [ ] **Step 5: 创建 `frontend/index.html`**
```html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MQTT Home</title>
</head>
<body class="bg-gray-50 min-h-screen">
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
```
- [ ] **Step 6: 创建 `frontend/src/style.css`**
```css
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
```
- [ ] **Step 7: 创建 `frontend/src/main.js`**
```javascript
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import './style.css'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')
```
- [ ] **Step 8: 创建 `frontend/src/App.vue`**
```vue
<template>
<div class="flex min-h-screen">
<Sidebar />
<main class="flex-1 p-6 overflow-auto">
<router-view />
</main>
</div>
</template>
<script setup>
import Sidebar from './components/Sidebar.vue'
</script>
```
- [ ] **Step 9: 创建占位 `frontend/src/router/index.js`**
```javascript
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', redirect: '/dashboard' },
{
path: '/dashboard',
name: 'Dashboard',
component: () => import('../views/DashboardView.vue'),
},
{
path: '/devices',
name: 'Devices',
component: () => import('../views/DevicesView.vue'),
},
{
path: '/devices/:id',
name: 'DeviceDetail',
component: () => import('../views/DeviceDetailView.vue'),
},
{
path: '/broker',
name: 'Broker',
component: () => import('../views/BrokerView.vue'),
},
],
})
export default router
```
- [ ] **Step 10: 创建占位页面文件**
创建以下 4 个最小占位文件:
`frontend/src/views/DashboardView.vue`:
```vue
<template><div>Dashboard</div></template>
```
`frontend/src/views/DevicesView.vue`:
```vue
<template><div>Devices</div></template>
```
`frontend/src/views/DeviceDetailView.vue`:
```vue
<template><div>Device Detail</div></template>
```
`frontend/src/views/BrokerView.vue`:
```vue
<template><div>Broker</div></template>
```
创建 `frontend/src/components/Sidebar.vue`:
```vue
<template>
<aside class="w-56 bg-white shadow-sm border-r border-gray-200 flex flex-col">
<div class="p-4 border-b border-gray-200">
<h1 class="text-xl font-bold text-primary-700">MQTT Home</h1>
</div>
<nav class="flex-1 p-2 space-y-1">
<router-link
v-for="item in navItems"
:key="item.path"
:to="item.path"
class="flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-colors"
:class="isActive(item.path) ? 'bg-primary-50 text-primary-700' : 'text-gray-600 hover:bg-gray-100'"
>
<component :is="item.icon" class="w-5 h-5" />
{{ item.label }}
</router-link>
</nav>
<div class="p-3 border-t border-gray-200">
<div class="flex items-center gap-2 text-xs text-gray-500">
<span class="w-2 h-2 rounded-full" :class="connected ? 'bg-green-500' : 'bg-red-500'"></span>
{{ connected ? 'MQTT 已连接' : 'MQTT 未连接' }}
</div>
</div>
</aside>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { HomeIcon, CpuChipIcon, ServerIcon } from '@heroicons/vue/24/outline'
const route = useRoute()
const connected = ref(false)
const navItems = [
{ path: '/dashboard', label: '仪表盘', icon: HomeIcon },
{ path: '/devices', label: '设备管理', icon: CpuChipIcon },
{ path: '/broker', label: 'Broker', icon: ServerIcon },
]
function isActive(path) {
return route.path.startsWith(path) && path !== '/'
}
onMounted(async () => {
try {
const res = await fetch('/health')
const data = await res.json()
connected.value = data.mqtt_connected
} catch {
connected.value = false
}
})
</script>
```
- [ ] **Step 11: 安装依赖并验证构建**
Run: `cd frontend && npm install`
Run: `cd frontend && npm run dev` (后台启动验证无报错)
Expected: Vite 启动成功,浏览器访问 localhost:3000 显示侧边栏和路由页面
- [ ] **Step 12: 提交**
```bash
git add frontend/
git commit -m "feat: Vue 3 project scaffolding with Tailwind, Router, Pinia, and Sidebar"
```
---
### Task 2: API 客户端模块
**Files:**
- Create: `frontend/src/api/index.js`
- [ ] **Step 1: 创建 `frontend/src/api/index.js`**
```javascript
const BASE_URL = '/api'
async function request(path, options = {}) {
const url = `${BASE_URL}${path}`
const config = {
headers: {
'Content-Type': 'application/json',
...options.headers,
},
...options,
}
if (config.body && typeof config.body === 'object') {
config.body = JSON.stringify(config.body)
}
const res = await fetch(url, config)
if (!res.ok) {
const error = new Error(`API error: ${res.status}`)
error.status = res.status
try {
error.data = await res.json()
} catch {
error.data = await res.text()
}
throw error
}
if (res.status === 204) return null
return res.json()
}
// Devices
export const getDevices = () => request('/devices')
export const createDevice = (data) => request('/devices', { method: 'POST', body: data })
export const getDevice = (id) => request(`/devices/${id}`)
export const updateDevice = (id, data) => request(`/devices/${id}`, { method: 'PUT', body: data })
export const deleteDevice = (id) => request(`/devices/${id}`, { method: 'DELETE' })
export const sendCommand = (id, payload) =>
request(`/devices/${id}/command`, { method: 'POST', body: { payload } })
export const getDeviceLogs = (id, limit = 20) => request(`/devices/${id}/logs?limit=${limit}`)
// Broker
export const getBrokerStatus = () => request('/broker/status')
export const getBrokerClients = () => request('/broker/clients')
export const getBrokerTopics = () => request('/broker/topics')
// Dashboard
export const getDashboardStats = () => request('/dashboard')
```
- [ ] **Step 2: 提交**
```bash
git add frontend/src/api/
git commit -m "feat: API client module for all backend endpoints"
```
---
### Task 3: WebSocket 组合式函数
**Files:**
- Create: `frontend/src/composables/useWebSocket.js`
- [ ] **Step 1: 创建 `frontend/src/composables/useWebSocket.js`**
```javascript
import { ref, onMounted, onUnmounted } from 'vue'
export function useWebSocket(url, onMessage) {
const connected = ref(false)
let ws = null
let reconnectTimer = null
let retryDelay = 1000
function connect() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const fullUrl = `${protocol}//${window.location.host}${url}`
ws = new WebSocket(fullUrl)
ws.onopen = () => {
connected.value = true
retryDelay = 1000
}
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data)
onMessage(data)
} catch {
// ignore non-JSON messages
}
}
ws.onclose = () => {
connected.value = false
reconnectTimer = setTimeout(connect, retryDelay)
retryDelay = Math.min(retryDelay * 2, 30000)
}
ws.onerror = () => {
ws.close()
}
}
onMounted(() => connect())
onUnmounted(() => {
clearTimeout(reconnectTimer)
if (ws) ws.close()
})
return { connected }
}
```
- [ ] **Step 2: 提交**
```bash
git add frontend/src/composables/
git commit -m "feat: WebSocket composable with auto-reconnect"
```
---
### Task 4: Pinia 设备 Store
**Files:**
- Create: `frontend/src/stores/devices.js`
- [ ] **Step 1: 创建 `frontend/src/stores/devices.js`**
```javascript
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { getDevices, createDevice, deleteDevice, sendCommand as apiSendCommand } from '../api'
import { useWebSocket } from '../composables/useWebSocket'
export const useDeviceStore = defineStore('devices', () => {
const devices = ref([])
const loading = ref(false)
const wsConnected = ref(false)
async function fetchDevices() {
loading.value = true
try {
devices.value = await getDevices()
} finally {
loading.value = false
}
}
async function addDevice(data) {
const device = await createDevice(data)
devices.value.unshift(device)
return device
}
async function removeDevice(id) {
await deleteDevice(id)
devices.value = devices.value.filter((d) => d.id !== id)
}
async function sendCommand(id, payload) {
return await apiSendCommand(id, payload)
}
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,
})
}
// Initialize WebSocket
const { connected } = useWebSocket('/ws/devices', handleWsMessage)
wsConnected.value = connected
return {
devices,
loading,
wsConnected,
fetchDevices,
addDevice,
removeDevice,
sendCommand,
}
})
```
- [ ] **Step 2: 提交**
```bash
git add frontend/src/stores/
git commit -m "feat: Pinia device store with WebSocket real-time updates"
```
---
### Task 5: 仪表盘页面
**Files:**
- Create: `frontend/src/components/StatsCard.vue`
- Modify: `frontend/src/views/DashboardView.vue`
- [ ] **Step 1: 创建 `frontend/src/components/StatsCard.vue`**
```vue
<template>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500">{{ label }}</p>
<p class="mt-1 text-3xl font-semibold" :class="color">{{ value }}</p>
</div>
<div class="w-12 h-12 rounded-lg flex items-center justify-center" :class="bgColor">
<component :is="icon" class="w-6 h-6" :class="iconColor" />
</div>
</div>
</div>
</template>
<script setup>
defineProps({
label: { type: String, required: true },
value: { type: [Number, String], required: true },
icon: { type: Object, required: true },
color: { type: String, default: 'text-gray-900' },
bgColor: { type: String, default: 'bg-gray-100' },
iconColor: { type: String, default: 'text-gray-600' },
})
</script>
```
- [ ] **Step 2: 替换 `frontend/src/views/DashboardView.vue`**
```vue
<template>
<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">
<StatsCard
label="设备总数"
:value="stats.total_devices"
:icon="CpuChipIcon"
color="text-gray-900"
bg-color="bg-gray-100"
icon-color="text-gray-600"
/>
<StatsCard
label="在线设备"
:value="stats.online_devices"
:icon="SignalIcon"
color="text-green-600"
bg-color="bg-green-50"
icon-color="text-green-500"
/>
<StatsCard
label="离线设备"
:value="stats.offline_devices"
:icon="NoSignalIcon"
color="text-red-600"
bg-color="bg-red-50"
icon-color="text-red-500"
/>
</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-4">最近活动</h3>
<div v-if="stats.recent_logs.length === 0" class="text-gray-400 text-sm">
暂无活动
</div>
<div v-else class="space-y-2">
<div
v-for="log in stats.recent_logs"
:key="log.id"
class="flex items-center gap-3 text-sm py-1.5 border-b border-gray-100 last:border-0"
>
<span
class="px-2 py-0.5 rounded text-xs font-medium"
:class="log.direction === 'rx' ? 'bg-blue-50 text-blue-700' : 'bg-orange-50 text-orange-700'"
>
{{ log.direction === 'rx' ? '接收' : '发送' }}
</span>
<span class="text-gray-500 font-mono text-xs truncate max-w-[200px]">{{ log.topic }}</span>
<span class="text-gray-700 truncate flex-1">{{ log.payload }}</span>
<span class="text-gray-400 text-xs whitespace-nowrap">{{ formatTime(log.timestamp) }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { getDashboardStats } from '../api'
import StatsCard from '../components/StatsCard.vue'
import { CpuChipIcon, WifiIcon as SignalIcon, SignalSlashIcon as NoSignalIcon } from '@heroicons/vue/24/outline'
const stats = ref({
total_devices: 0,
online_devices: 0,
offline_devices: 0,
recent_logs: [],
})
function formatTime(ts) {
if (!ts) return ''
const d = new Date(ts)
return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
}
onMounted(async () => {
try {
stats.value = await getDashboardStats()
} catch {
// silently fail
}
})
</script>
```
- [ ] **Step 3: 验证**
Run: `cd frontend && npm run dev`
Expected: 访问 `/dashboard` 显示三个统计卡片和最近活动列表
- [ ] **Step 4: 提交**
```bash
git add frontend/
git commit -m "feat: dashboard page with stats cards and recent activity timeline"
```
---
### Task 6: 设备列表页面
**Files:**
- Create: `frontend/src/components/DeviceCard.vue`
- Create: `frontend/src/components/AddDeviceModal.vue`
- Modify: `frontend/src/views/DevicesView.vue`
- [ ] **Step 1: 创建 `frontend/src/components/DeviceCard.vue`**
```vue
<template>
<div
class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 hover:shadow-md transition-shadow cursor-pointer"
@click="$router.push(`/devices/${device.id}`)"
>
<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="device.is_online ? 'bg-green-500' : 'bg-gray-300'"
></span>
<span class="text-sm font-medium text-gray-900 truncate max-w-[160px]">{{ device.name }}</span>
</div>
<span class="text-xs px-2 py-0.5 rounded-full bg-gray-100 text-gray-600">{{ device.type }}</span>
</div>
<div class="text-xs text-gray-500 space-y-1">
<div class="flex justify-between">
<span>状态</span>
<span class="font-medium" :class="device.state ? 'text-gray-900' : 'text-gray-400'">
{{ displayState || '未知' }}
</span>
</div>
<div class="flex justify-between">
<span>协议</span>
<span>{{ device.protocol === 'ha_discovery' ? 'HA' : '自定义' }}</span>
</div>
</div>
<!-- Quick toggle for switch/light types with command_topic -->
<button
v-if="device.command_topic && canToggle"
class="mt-3 w-full py-1.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'"
@click.stop="toggle"
>
{{ isOn ? '关闭' : '开启' }}
</button>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useDeviceStore } from '../stores/devices'
const props = defineProps({
device: { type: Object, required: true },
})
const store = useDeviceStore()
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'
}
})
const displayState = computed(() => {
if (!props.device.state) return null
try {
const s = JSON.parse(props.device.state)
return s.state || props.device.state
} catch {
return props.device.state
}
})
async function toggle() {
const newState = isOn.value ? 'off' : 'on'
try {
await store.sendCommand(props.device.id, JSON.stringify({ state: newState }))
} catch {
// silent fail, WebSocket will update state
}
}
</script>
```
- [ ] **Step 2: 创建 `frontend/src/components/AddDeviceModal.vue`**
```vue
<template>
<div v-if="show" class="fixed inset-0 bg-black/40 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">添加设备</h3>
<form @submit.prevent="submit" 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" required class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-primary-500 focus:border-primary-500" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">设备类型</label>
<select v-model="form.type" class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-primary-500 focus:border-primary-500">
<option value="switch">Switch 开关</option>
<option value="light">Light 灯光</option>
<option value="sensor">Sensor 传感器</option>
<option value="binary_sensor">Binary Sensor</option>
<option value="climate">Climate 温控</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">状态主题 (State Topic)</label>
<input v-model="form.mqtt_topic" type="text" required placeholder="home/living/light" class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-primary-500 focus:border-primary-500" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">命令主题 (Command Topic)</label>
<input v-model="form.command_topic" type="text" placeholder="home/living/light/set" class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-primary-500 focus:border-primary-500" />
</div>
<div class="flex justify-end gap-3 pt-2">
<button type="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 type="submit" :disabled="submitting" 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>
</form>
</div>
</div>
</template>
<script setup>
import { reactive, ref } from 'vue'
import { useDeviceStore } from '../stores/devices'
defineProps({ show: { type: Boolean, default: false } })
defineEmits(['close'])
const store = useDeviceStore()
const submitting = ref(false)
const form = reactive({
name: '',
type: 'switch',
mqtt_topic: '',
command_topic: '',
})
async function submit() {
submitting.value = true
try {
await store.addDevice(form)
form.name = ''
form.mqtt_topic = ''
form.command_topic = ''
} finally {
submitting.value = false
}
}
</script>
```
- [ ] **Step 3: 替换 `frontend/src/views/DevicesView.vue`**
```vue
<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="store.loading" class="text-gray-400">加载中...</div>
<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>
</div>
<div v-else class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
<DeviceCard v-for="device in store.devices" :key="device.id" :device="device" />
</div>
<AddDeviceModal :show="showAdd" @close="showAdd = false" />
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useDeviceStore } from '../stores/devices'
import DeviceCard from '../components/DeviceCard.vue'
import AddDeviceModal from '../components/AddDeviceModal.vue'
const store = useDeviceStore()
const showAdd = ref(false)
onMounted(() => store.fetchDevices())
</script>
```
- [ ] **Step 4: 提交**
```bash
git add frontend/
git commit -m "feat: devices list page with cards, quick toggle, and add device modal"
```
---
### Task 7: 设备详情页面
**Files:**
- Create: `frontend/src/components/DeviceControl.vue`
- Create: `frontend/src/components/DeviceLogList.vue`
- Modify: `frontend/src/views/DeviceDetailView.vue`
- [ ] **Step 1: 创建 `frontend/src/components/DeviceControl.vue`**
```vue
<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>
<!-- Switch / Light: toggle button -->
<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>
<!-- Custom JSON command input -->
<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>
```
- [ ] **Step 2: 创建 `frontend/src/components/DeviceLogList.vue`**
```vue
<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>
```
- [ ] **Step 3: 替换 `frontend/src/views/DeviceDetailView.vue`**
```vue
<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>
<!-- Device info header -->
<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>
```
- [ ] **Step 4: 提交**
```bash
git add frontend/
git commit -m "feat: device detail page with control panel, custom commands, and message log"
```
---
### Task 8: Broker 管理页面
**Files:**
- Modify: `frontend/src/views/BrokerView.vue`
- [ ] **Step 1: 替换 `frontend/src/views/BrokerView.vue`**
```vue
<template>
<div>
<h2 class="text-2xl font-bold text-gray-900 mb-6">Broker 管理</h2>
<!-- Status -->
<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">
<!-- Clients -->
<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>
<!-- 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-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>
```
- [ ] **Step 2: 提交**
```bash
git add frontend/
git commit -m "feat: broker management page with status, clients, and topics"
```
---
### Task 9: 生产构建与后端静态文件服务
**Files:**
- Modify: `src/mqtt_home/main.py`
- [ ] **Step 1: 构建前端**
Run: `cd frontend && npm run build`
Expected: `frontend/dist/` 目录生成,包含 index.html 和 JS/CSS 资源
- [ ] **Step 2: 修改 `src/mqtt_home/main.py`,添加静态文件服务**
在文件顶部 import 区域添加:
```python
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
import pathlib
```
`app.include_router(api_router)` 之前添加:
```python
# Serve frontend static files in production
frontend_dist = pathlib.Path(__file__).parent.parent.parent / "frontend" / "dist"
if frontend_dist.exists():
app.mount("/assets", StaticFiles(directory=str(frontend_dist / "assets")), name="assets")
@app.get("/{full_path:path}")
async def serve_spa(full_path: str):
file = frontend_dist / full_path
if file.exists() and file.is_file():
return FileResponse(str(file))
return FileResponse(str(frontend_dist / "index.html"))
```
- [ ] **Step 3: 验证生产模式**
Run: `cd D:\home\mqtt && python -m mqtt_home serve`
Expected: 访问 `http://localhost:8000/` 显示前端页面API 正常工作
- [ ] **Step 4: 提交**
```bash
git add frontend/ src/
git commit -m "feat: production build with FastAPI static file serving for SPA"
```
---
### Task 10: 端到端验证与最终提交
- [ ] **Step 1: 启动服务**
Run: `cd D:\home\mqtt && python -m mqtt_home serve`
- [ ] **Step 2: 浏览器验证以下功能**
1. `http://localhost:8000/` — 仪表盘显示设备统计
2. `http://localhost:8000/devices` — 设备列表(网格布局)
3. 点击"添加设备"弹窗 — 填写表单创建设备
4. 点击设备卡片 — 进入详情页,显示控制面板和日志
5. `http://localhost:8000/broker` — Broker 状态、客户端、主题
6. 侧边栏导航切换正常
7. MQTT 连接状态指示灯正确
- [ ] **Step 3: 提交**
```bash
git add -A
git commit -m "feat: frontend complete - dashboard, devices, device detail, broker pages"
```
---
## 自检结果
| 设计文档章节 | 对应 Task |
|---|---|
| 仪表盘 — 设备数量、在线/离线、最近活动 | Task 5 |
| 设备列表 — 网格视图、类型图标、状态指示、快捷切换 | Task 6 |
| 设备详情 — 状态展示、控制面板、消息日志 | Task 7 |
| Broker 管理 — 客户端列表、活跃主题、健康状态 | Task 8 |
| WebSocket 实时推送 | Task 3 + Task 4 |
| 响应式布局 | Task 1 (Tailwind responsive classes) |
| 控制面板按类型自适应 | Task 7 (DeviceControl.vue) |
| 添加设备弹窗 | Task 6 (AddDeviceModal.vue) |