feat: API client, WebSocket composable, and Pinia device store

🤖 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 21:59:50 +08:00
parent f02ff5db0c
commit 52fbb1a15a
3 changed files with 154 additions and 0 deletions

46
frontend/src/api/index.js Normal file
View File

@@ -0,0 +1,46 @@
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')

View File

@@ -0,0 +1,48 @@
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 }
}

View File

@@ -0,0 +1,60 @@
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)
return {
devices,
loading,
wsConnected,
fetchDevices,
addDevice,
removeDevice,
sendCommand,
}
})