From 426eaca97f5d8082f1892436eea8606ba8accbf7 Mon Sep 17 00:00:00 2001 From: fuenmao <980740792@qq.com> Date: Tue, 22 Sep 2026 14:16:58 +0800 Subject: [PATCH] 11 --- src/services/health.ts | 25 ++++- src/styles.css | 208 ++++++++++++++++++++++++++++++++++-- src/views/DashboardView.vue | 189 ++++++++++++++++++++++++++------ 3 files changed, 381 insertions(+), 41 deletions(-) diff --git a/src/services/health.ts b/src/services/health.ts index 7d6f27d..640beac 100644 --- a/src/services/health.ts +++ b/src/services/health.ts @@ -75,6 +75,7 @@ export type HomeAssistantDiscoveredDevice = { areaName?: string category: HealthDeviceCategory | string online: boolean + primaryControlEntityId?: string | null entities: HomeAssistantEntity[] metrics?: Record } @@ -102,6 +103,25 @@ export type HomeAssistantEnvironmentData = { tvoc?: number | null } +export type HomeAssistantScenePreset = { + deviceName: string + category: string + entityId: string + available: boolean + availabilityMessage: string + action: string + detail: string + parameters: Record +} + +export type HomeAssistantDeviceSwitchOutput = { + success: boolean + deviceId: string + entityId: string + state: string + message: string +} + export type HomeAssistantStatus = { connected: boolean message: string @@ -110,6 +130,7 @@ export type HomeAssistantStatus = { devices: HomeAssistantDiscoveredDevice[] sceneDevices: HomeAssistantSceneDevicesSummary environment: HomeAssistantEnvironmentData + scenePresets?: HomeAssistantScenePreset[] } export type HomeAssistantSceneAction = { @@ -145,5 +166,7 @@ export const healthApi = { executeSleepScene: () => http.post('/api/health/home-assistant/scenes/sleep'), executeAllOffScene: () => - http.post('/api/health/home-assistant/scenes/all-off') + http.post('/api/health/home-assistant/scenes/all-off'), + switchDevice: (payload: { deviceId: string; turnOn: boolean; entityId?: string }) => + http.post('/api/health/home-assistant/devices/switch', payload) } diff --git a/src/styles.css b/src/styles.css index c2088f1..1e8508b 100644 --- a/src/styles.css +++ b/src/styles.css @@ -245,6 +245,23 @@ button { cursor: pointer; } text-overflow: ellipsis; min-width: 0; } +.preset-toggle-btn { + font-size: 11px; + font-weight: 500; + color: var(--green); + background: #edf7f3; + padding: 3px 9px; + border-radius: 12px; + border: 1px solid rgba(12, 80, 69, 0.15); + cursor: pointer; + transition: all 0.2s; + white-space: nowrap; + flex-shrink: 0; +} +.preset-toggle-btn:active { + background: var(--green); + color: #fff; +} .scene-status-badge { font-size: 11px; font-weight: 500; @@ -267,6 +284,27 @@ button { cursor: pointer; } .device-mini-status.yellow { background: #fef7ec; color: #b8860b; } .device-mini-status.gray { background: #f1f3f2; color: #82908c; } +.sensor-mini-sync-btn { + font-size: 11px; + font-weight: 500; + padding: 3px 9px; + border-radius: 10px; + border: 1px solid rgba(18, 63, 55, 0.15); + background: #fff; + color: var(--green); + cursor: pointer; + white-space: nowrap; + flex-shrink: 0; + transition: all 0.2s; +} +.sensor-mini-sync-btn:active { + background: #edf7f3; +} +.sensor-mini-sync-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + .scene-row { display: flex; align-items: center; @@ -327,7 +365,7 @@ button { cursor: pointer; } display: flex; align-items: center; gap: 6px; - padding: 7px 10px; + padding: 6px 9px; background: #fff; border: 1px solid rgba(18, 63, 55, 0.06); border-radius: 10px; @@ -354,16 +392,112 @@ button { cursor: pointer; } overflow: hidden; text-overflow: ellipsis; } -.scene-device-chip .desc { - font-size: 11px; - flex-shrink: 0; +.chip-toggle-btn { + font-size: 10px; + font-weight: 600; + padding: 2px 7px; + border-radius: 6px; + border: 1px solid rgba(12, 80, 69, 0.2); + background: #f1f3f2; + color: #73817d; + cursor: pointer; white-space: nowrap; + flex-shrink: 0; + transition: all 0.2s; +} +.chip-toggle-btn.on { + background: #0c5045; + color: #ffffff; + border-color: #0c5045; +} +.chip-toggle-btn.disabled { + opacity: 0.45; + cursor: not-allowed; + background: #f1f3f2; + color: #9aa4a1; + border-color: #e2e5e3; +} + +/* 场景联动执行参数详情面板 */ +.scene-presets-panel { + margin-top: 12px; + padding-top: 10px; + border-top: 1px dashed rgba(18, 63, 55, 0.12); +} +.preset-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; +} +.preset-panel-header span { + font-size: 12px; + font-weight: 600; + color: var(--deep); +} +.preset-panel-header small { + font-size: 10px; color: #82908c; - font-variant-numeric: tabular-nums; +} +.preset-items-grid { + display: flex; + flex-direction: column; + gap: 6px; +} +.preset-card-item { + background: #ffffff; + border: 1px solid rgba(18, 63, 55, 0.06); + border-radius: 10px; + padding: 8px 10px; + box-shadow: 0 1px 2px rgba(18, 63, 55, 0.02); +} +.preset-card-item.unavailable { + background: #f7f8f7; + opacity: 0.72; +} +.preset-card-top { + display: flex; + align-items: center; + gap: 6px; +} +.preset-icon { + font-size: 13px; + flex-shrink: 0; +} +.preset-name { + font-size: 12px; + font-weight: 600; + color: var(--deep); + flex: 1; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.preset-action { + font-size: 10px; + font-weight: 500; + padding: 1px 6px; + border-radius: 6px; + background: #edf7f3; + color: var(--green); + white-space: nowrap; + flex-shrink: 0; +} +.preset-detail { + margin: 3px 0 0 20px; + font-size: 11px; + color: #657470; + line-height: 1.4; +} +.preset-availability { + margin: 4px 0 0 20px; + font-size: 10px; + color: var(--green); +} +.preset-availability.unavailable { + color: #9a6a38; } -.scene-device-chip.green .desc { color: var(--green); font-weight: 500; } -.scene-device-chip.yellow .desc { color: #b8860b; font-weight: 500; } -.scene-device-chip.gray .desc { color: #9aa4a1; } /* Environment Health */ .env-container { @@ -554,10 +688,66 @@ button { cursor: pointer; } display: flex; flex-direction: column; align-items: flex-end; - gap: 2px; + gap: 3px; flex-shrink: 0; margin-left: 10px; } +.device-switch-pill { + display: flex; + align-items: center; + gap: 4px; + padding: 2px 8px 2px 5px; + border-radius: 12px; + border: 1px solid rgba(18, 63, 55, 0.15); + background: #f1f3f2; + color: #6b7774; + cursor: pointer; + transition: all 0.2s; + font-size: 11px; + font-weight: 600; + white-space: nowrap; +} +.device-switch-pill .switch-knob { + width: 9px; + height: 9px; + border-radius: 50%; + background: #9aa4a1; + transition: all 0.2s; +} +.device-switch-pill.on { + background: #edf7f3; + border-color: rgba(16, 185, 129, 0.4); + color: var(--green); +} +.device-switch-pill.on .switch-knob { + background: #10b981; + box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.2); +} +.device-switch-pill.disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.sensor-sync-pill { + font-size: 11px; + font-weight: 500; + padding: 2px 8px; + border-radius: 10px; + border: 1px solid rgba(18, 63, 55, 0.15); + background: #fff; + color: var(--green); + cursor: pointer; + white-space: nowrap; + transition: all 0.2s; +} +.sensor-sync-pill:active { + background: #edf7f3; +} +.sensor-sync-pill:disabled { + opacity: 0.5; + cursor: not-allowed; +} + .status-pill { font-size: 11px; font-weight: 500; diff --git a/src/views/DashboardView.vue b/src/views/DashboardView.vue index b0c5665..c9241bc 100644 --- a/src/views/DashboardView.vue +++ b/src/views/DashboardView.vue @@ -44,17 +44,15 @@ const totalDeviceCount = computed(() => homeAssistant.value?.summary?.totalDevic const onlineDeviceCount = computed(() => homeAssistant.value?.summary?.onlineDevices ?? homeAssistant.value?.devices?.filter(d => d.online).length ?? 0) const totalEntityCount = computed(() => homeAssistant.value?.summary?.totalEntities ?? 0) const availableEntityCount = computed(() => homeAssistant.value?.summary?.availableEntities ?? 0) -const sceneTotal = computed(() => homeAssistant.value?.sceneDevices?.total ?? 4) +const sceneTotal = computed(() => homeAssistant.value?.sceneDevices?.total ?? 0) const sceneAvailable = computed(() => homeAssistant.value?.sceneDevices?.available ?? 0) const sceneDevices = computed(() => { if (!homeAssistant.value?.devices) return [] - return homeAssistant.value.devices.filter(d => - d.category === 'EnvironmentControl' || - d.category === 'Fragrance' || - d.category === 'Lighting' || - d.category === 'Media' + const configuredCategories = new Set( + (homeAssistant.value.scenePresets || []).map(item => item.category) ) + return homeAssistant.value.devices.filter(d => configuredCategories.has(d.category)) }) const envDevice = computed(() => { @@ -151,6 +149,67 @@ function getHumidityTag(hum: number | undefined | null): string { return '潮湿' } +const switchingDeviceId = ref(null) +const syncingSensor = ref(false) +const showScenePresets = ref(true) + +function isControllable(device: HomeAssistantDiscoveredDevice): boolean { + return !!device.primaryControlEntityId +} + +function getPrimaryControlEntity(device: HomeAssistantDiscoveredDevice) { + if (!device.primaryControlEntityId) return undefined + return device.entities.find(e => e.entityId === device.primaryControlEntityId) +} + +function isPrimaryControlAvailable(device: HomeAssistantDiscoveredDevice): boolean { + return !!getPrimaryControlEntity(device)?.available +} + +function isDeviceTurnedOn(device: HomeAssistantDiscoveredDevice): boolean { + const target = getPrimaryControlEntity(device) + if (!target) return false + return target.state === 'on' || target.state === 'playing' +} + +async function toggleDeviceSwitch(device: HomeAssistantDiscoveredDevice) { + if (!isPrimaryControlAvailable(device)) { + showToast('设备主控功能当前不可用') + return + } + const currentOn = isDeviceTurnedOn(device) + const nextOn = !currentOn + switchingDeviceId.value = device.deviceId + try { + const res = await healthApi.switchDevice({ + deviceId: device.deviceId, + turnOn: nextOn, + entityId: device.primaryControlEntityId || undefined + }) + showToast({ + type: res.success ? 'success' : 'fail', + message: res.message || (nextOn ? '已开启设备' : '已关闭设备') + }) + homeAssistant.value = await healthApi.homeAssistantStatus() + } catch (error) { + showToast(error instanceof Error ? error.message : '控制失败') + } finally { + switchingDeviceId.value = null + } +} + +async function syncSensorData() { + syncingSensor.value = true + try { + homeAssistant.value = await healthApi.homeAssistantStatus() + showToast({ type: 'success', message: '状态已刷新' }) + } catch (error) { + showToast(error instanceof Error ? error.message : '刷新失败') + } finally { + syncingSensor.value = false + } +} + async function load() { loading.value = true try { @@ -282,20 +341,27 @@ onMounted(load) - +
🌙 睡眠场景控制 + ({{ sceneAvailable }}/{{ sceneTotal }} 项可用)
- 场景 {{ sceneAvailable }}/{{ sceneTotal }} 就绪 +
- 舒睡模式 -

除湿 55% · 微风睡眠 · 暖光助眠 · 草本香氛

+ 一键舒睡 +

仅执行已配置且当前可用的恒湿、香氛与暖光动作

- +
{{ getSceneDeviceShortName(d) }} - {{ getSceneDeviceStatus(d) }} + + {{ getSceneDeviceStatus(d) }} + +
+
+ + +
+
+ 场景执行参数 + 仅展示后台实际下发的动作 +
+
+
+
+ + {{ item.category === 'EnvironmentControl' ? '💧' : item.category === 'Fragrance' ? '🌸' : item.category === 'Lighting' ? '💡' : item.category === 'Media' ? '🔊' : item.category === 'Sleep' ? '🛏️' : item.category === 'EnvironmentSensor' ? '🌿' : '⚖️' }} + + {{ item.deviceName }} + {{ item.action }} +
+

{{ item.detail }}

+

+ {{ item.availabilityMessage }} +

+
@@ -339,9 +435,14 @@ onMounted(load) 环境健康 {{ getDeviceSubModel(envDevice, '罗威亚检测仪') }} - - {{ getDeviceShortBadgeText(envDevice) }} - +
@@ -408,7 +509,11 @@ onMounted(load) 睡眠健康 {{ getDeviceSubModel(sleepDevice, '8H 睡眠床垫') }}
- + {{ getDeviceShortBadgeText(sleepDevice) }} @@ -419,15 +524,15 @@ onMounted(load)
左侧软硬度 - {{ sleepDevice?.metrics?.leftFirmness || '5 档 · 适中' }} + {{ sleepDevice?.metrics?.leftFirmness || '暂无数据' }}
右侧软硬度 - {{ sleepDevice?.metrics?.rightFirmness || '6 档 · 偏硬' }} + {{ sleepDevice?.metrics?.rightFirmness || '暂无数据' }}
智能调温 - {{ sleepDevice?.online ? '温控已就绪' : '待接入' }} + {{ sleepDevice?.online ? '状态待读取' : '待接入' }}
@@ -440,22 +545,27 @@ onMounted(load) 身体健康 {{ getDeviceSubModel(bodyDevice, '米家体脂秤') }} - - {{ getDeviceShortBadgeText(bodyDevice) }} - +
体脂秤状态 - {{ bodyDevice?.online ? '称重即同步' : '待同步' }} + {{ bodyDevice?.online ? '设备在线' : '待同步' }}
最新体重 - {{ bodyDevice?.metrics?.weight || '68.5' }}kg + {{ bodyDevice?.metrics?.weight ?? '—' }}kg
体脂率 - {{ bodyDevice?.metrics?.bodyFatRate || '18.2' }}% + {{ bodyDevice?.metrics?.bodyFatRate ?? '—' }}%
测量算法 @@ -472,7 +582,7 @@ onMounted(load) 全部智能设备 ({{ totalDeviceCount }} 台)
- 自动发现实时同步 + 已确认主控能力的设备可操作
@@ -490,15 +600,32 @@ onMounted(load) {{ categoryNames[item.category] || item.category }}

- {{ item.areaName || '睡眠空间' }} + {{ item.areaName || '未分区' }} · {{ item.model }}

- - {{ getDeviceListBadgeText(item) }} - + + {{ item.entities.filter(e => e.available).length }}/{{ item.entities.length }} 实体