diff --git a/src/services/health.ts b/src/services/health.ts index 7b6d672..98eeb4a 100644 --- a/src/services/health.ts +++ b/src/services/health.ts @@ -47,6 +47,42 @@ export type Dashboard = { currentPlan?: HealthPlan } +export type HomeAssistantDevice = { + code: string + name: string + entityId: string + state: string + value?: string + unit?: string + isAvailable: boolean + lastUpdated?: string +} + +export type HomeAssistantStatus = { + connected: boolean + message: string + checkedAt: string + devices: HomeAssistantDevice[] + environment: HomeAssistantDevice[] +} + +export type HomeAssistantSceneAction = { + name: string + entityId: string + status: string + message: string +} + +export type HomeAssistantSceneResult = { + sceneCode: string + sceneName: string + success: boolean + status: string + message: string + executedAt: string + actions: HomeAssistantSceneAction[] +} + export const healthApi = { register: (payload: { phone: string; password: string; displayName: string }) => http.post('/api/health/auth/register', payload), @@ -57,5 +93,11 @@ export const healthApi = { http.put('/api/health/profile', payload), saveModules: (moduleCodes: string[]) => http.put('/api/health/modules', { moduleCodes }), - generatePlan: () => http.post('/api/health/plans/generate') + generatePlan: () => http.post('/api/health/plans/generate'), + homeAssistantStatus: () => + http.get('/api/health/home-assistant/status'), + executeSleepScene: () => + http.post('/api/health/home-assistant/scenes/sleep'), + executeAllOffScene: () => + http.post('/api/health/home-assistant/scenes/all-off') } diff --git a/src/styles.css b/src/styles.css index f6a8cbc..49028d1 100644 --- a/src/styles.css +++ b/src/styles.css @@ -179,8 +179,17 @@ button { cursor: pointer; } .plan-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; } .plan-heading span { color: #b1813f; font-size: 12px; text-align: right; } .plan-item p, .scene-row p { margin: 7px 0 0; color: #75847f; font-size: 13px; line-height: 1.6; } -.status-dot { color: #a27639 !important; } +.status-dot { color: #a27639 !important; text-align: right; } +.status-dot.online { color: #1c806a !important; } +.device-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; margin-bottom: 12px; } +.device-pill { display: flex; align-items: center; gap: 9px; padding: 11px 12px; border: 1px solid #e4ebe7; border-radius: 13px; background: #fbfcfa; } +.device-pill > span { width: 8px; height: 8px; flex: 0 0 auto; border-radius: 50%; background: #2c9a7d; box-shadow: 0 0 0 4px rgba(44,154,125,.12); } +.device-pill.offline > span { background: #b5bdb9; box-shadow: none; } +.device-pill strong, .device-pill small { display: block; } +.device-pill strong { font-size: 13px; } +.device-pill small { margin-top: 3px; color: #87928e; font-size: 11px; } .scene-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 15px; border-radius: 14px; background: #f6f7f3; } +.scene-actions { display: flex; gap: 8px; } .scene-row button { white-space: nowrap; flex-shrink: 0; @@ -195,6 +204,13 @@ button { cursor: pointer; } transition: .2s; } .scene-row button:active { background: var(--green); color: #fff; } +.scene-row button.secondary { color: #7d684b; border-color: rgba(125,104,75,.2); background: #f4eee4; } +.scene-row button:disabled { cursor: not-allowed; opacity: .5; } +.environment-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-top: 12px; } +.environment-grid div { padding: 10px; border-radius: 12px; background: #f8f8f4; text-align: center; } +.environment-grid small, .environment-grid strong { display: block; } +.environment-grid small { color: #89938f; font-size: 10px; } +.environment-grid strong { margin-top: 4px; color: var(--deep); font-size: 14px; } .medical-note { max-width: 760px; margin: 24px auto; color: #969e9b; font-size: 11px; text-align: center; } .loading-card { color: #73817d; text-align: center; } @@ -203,4 +219,8 @@ button { cursor: pointer; } .auth-card { margin-top: -54px; } .plan-heading { display: block; } .plan-heading span { display: block; margin-top: 5px; text-align: left; } + .device-grid { grid-template-columns: 1fr; } + .scene-row { align-items: flex-start; flex-direction: column; } + .scene-actions { width: 100%; } + .scene-actions button { flex: 1; } } diff --git a/src/views/DashboardView.vue b/src/views/DashboardView.vue index e1d5025..9cb3866 100644 --- a/src/views/DashboardView.vue +++ b/src/views/DashboardView.vue @@ -3,12 +3,14 @@ import { computed, onMounted, ref } from 'vue' import { useRouter } from 'vue-router' import { Button as VanButton, showToast } from 'vant' import { clearTokens } from '../services/auth' -import { healthApi, type Dashboard } from '../services/health' +import { healthApi, type Dashboard, type HomeAssistantDevice, type HomeAssistantStatus } from '../services/health' const router = useRouter() const loading = ref(true) const generating = ref(false) +const sceneRunning = ref<'sleep' | 'all-off'>() const dashboard = ref() +const homeAssistant = ref() const moduleNames: Record = { sleep: '睡眠', exercise: '运动', @@ -20,11 +22,25 @@ const greeting = computed(() => { if (hour < 18) return '下午好' return '晚上好' }) +const availableDeviceCount = computed(() => homeAssistant.value?.devices.filter(item => item.isAvailable).length || 0) + +function deviceState(item: HomeAssistantDevice) { + if (!item.isAvailable) return '离线' + if (item.code === 'dehumidifier' && item.value) return `${item.state === 'on' ? '运行中' : '已关闭'} · 当前湿度 ${item.value}${item.unit || ''}` + if (item.state === 'on' || item.state === 'playing') return '已开启' + if (item.state === 'off' || item.state === 'idle') return '已关闭' + return item.value ? `${item.value}${item.unit || ''}` : item.state +} async function load() { loading.value = true try { - dashboard.value = await healthApi.dashboard() + const [dashboardResult, homeAssistantResult] = await Promise.all([ + healthApi.dashboard(), + healthApi.homeAssistantStatus().catch(() => undefined) + ]) + dashboard.value = dashboardResult + homeAssistant.value = homeAssistantResult } catch (error) { showToast(error instanceof Error ? error.message : '加载失败') } finally { @@ -32,6 +48,21 @@ async function load() { } } +async function executeScene(scene: 'sleep' | 'all-off') { + sceneRunning.value = scene + try { + const result = scene === 'sleep' + ? await healthApi.executeSleepScene() + : await healthApi.executeAllOffScene() + showToast({ type: result.success ? 'success' : 'fail', message: result.message }) + homeAssistant.value = await healthApi.homeAssistantStatus() + } catch (error) { + showToast(error instanceof Error ? error.message : '场景执行失败') + } finally { + sceneRunning.value = undefined + } +} + async function generate() { generating.value = true try { @@ -106,13 +137,36 @@ onMounted(load)
-
健康空间Home Assistant 待接入
+
+ 健康空间 + + {{ homeAssistant?.connected ? `Home Assistant 已连接 · ${availableDeviceCount}/${homeAssistant.devices.length} 在线` : (homeAssistant?.message || '正在连接 Home Assistant') }} + +
+
+
+ +
{{ item.name }}{{ deviceState(item) }}
+
+
舒睡模式 -

灯光 · 空调 · 香氛 · 除湿机

+

除湿机 55% · 睡眠模式 · 低档风速 · 香氛 · 暖光

+
+
+ + +
+
+
+
+ {{ item.name }}{{ item.value }}{{ item.unit || '' }}
-