diff --git a/.playwright-mcp/console-2026-03-29T15-40-07-523Z.log b/.playwright-mcp/console-2026-03-29T15-40-07-523Z.log new file mode 100644 index 0000000..e217284 --- /dev/null +++ b/.playwright-mcp/console-2026-03-29T15-40-07-523Z.log @@ -0,0 +1 @@ +[ 71057ms] [ERROR] WebSocket connection to 'ws://localhost:8000/ws/devices' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:8000/assets/devices-D8vTPJoB.js:0 diff --git a/dashboard-final.png b/dashboard-final.png new file mode 100644 index 0000000..70b3809 Binary files /dev/null and b/dashboard-final.png differ diff --git a/docs/superpowers/plans/2026-03-29-mqtt-home-frontend.md b/docs/superpowers/plans/2026-03-29-mqtt-home-frontend.md new file mode 100644 index 0000000..1578d1c --- /dev/null +++ b/docs/superpowers/plans/2026-03-29-mqtt-home-frontend.md @@ -0,0 +1,1367 @@ +# 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 + + + + + + MQTT Home + + +
+ + + +``` + +- [ ] **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 + + + +``` + +- [ ] **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 + +``` + +`frontend/src/views/DevicesView.vue`: +```vue + +``` + +`frontend/src/views/DeviceDetailView.vue`: +```vue + +``` + +`frontend/src/views/BrokerView.vue`: +```vue + +``` + +创建 `frontend/src/components/Sidebar.vue`: +```vue + + + +``` + +- [ ] **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 + + + +``` + +- [ ] **Step 2: 替换 `frontend/src/views/DashboardView.vue`** + +```vue + + + +``` + +- [ ] **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 + + + +``` + +- [ ] **Step 2: 创建 `frontend/src/components/AddDeviceModal.vue`** + +```vue + + + +``` + +- [ ] **Step 3: 替换 `frontend/src/views/DevicesView.vue`** + +```vue + + + +``` + +- [ ] **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 + + + +``` + +- [ ] **Step 2: 创建 `frontend/src/components/DeviceLogList.vue`** + +```vue + + + +``` + +- [ ] **Step 3: 替换 `frontend/src/views/DeviceDetailView.vue`** + +```vue + + + +``` + +- [ ] **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 + + + +``` + +- [ ] **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) | diff --git a/frontend/device-detail-screenshot.png b/frontend/device-detail-screenshot.png new file mode 100644 index 0000000..a82009f Binary files /dev/null and b/frontend/device-detail-screenshot.png differ diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index 6931bcc..e7f4310 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -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' }) diff --git a/frontend/src/components/DeviceCard.vue b/frontend/src/components/DeviceCard.vue index 6c5ef0e..d898f31 100644 --- a/frontend/src/components/DeviceCard.vue +++ b/frontend/src/components/DeviceCard.vue @@ -22,7 +22,7 @@
协议 - {{ device.protocol === 'ha_discovery' ? 'HA' : '自定义' }} + {{ device.protocol === 'ha_discovery' ? 'HA' : device.protocol === 'topic_rule' ? '规则' : '手动' }}
+ + + + + + + diff --git a/frontend/src/components/RuleModal.vue b/frontend/src/components/RuleModal.vue new file mode 100644 index 0000000..7369728 --- /dev/null +++ b/frontend/src/components/RuleModal.vue @@ -0,0 +1,146 @@ + + + diff --git a/frontend/src/components/Sidebar.vue b/frontend/src/components/Sidebar.vue index 6217a69..046f50f 100644 --- a/frontend/src/components/Sidebar.vue +++ b/frontend/src/components/Sidebar.vue @@ -27,7 +27,7 @@ diff --git a/frontend/src/views/DeviceDetailView.vue b/frontend/src/views/DeviceDetailView.vue index 663765a..890556d 100644 --- a/frontend/src/views/DeviceDetailView.vue +++ b/frontend/src/views/DeviceDetailView.vue @@ -19,11 +19,12 @@
{{ device.type }} - {{ device.protocol === 'ha_discovery' ? 'HA Discovery' : '自定义' }} + {{ device.protocol === 'ha_discovery' ? 'HA Discovery' : device.protocol === 'topic_rule' ? '主题规则' : '自定义' }} ID: {{ device.id.slice(0, 8) }}