This commit is contained in:
fuenmao
2026-09-22 14:16:58 +08:00
parent 0f97d8cba3
commit 426eaca97f
3 changed files with 381 additions and 41 deletions
+24 -1
View File
@@ -75,6 +75,7 @@ export type HomeAssistantDiscoveredDevice = {
areaName?: string
category: HealthDeviceCategory | string
online: boolean
primaryControlEntityId?: string | null
entities: HomeAssistantEntity[]
metrics?: Record<string, unknown>
}
@@ -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<string, unknown>
}
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<never, HomeAssistantSceneResult>('/api/health/home-assistant/scenes/sleep'),
executeAllOffScene: () =>
http.post<never, HomeAssistantSceneResult>('/api/health/home-assistant/scenes/all-off')
http.post<never, HomeAssistantSceneResult>('/api/health/home-assistant/scenes/all-off'),
switchDevice: (payload: { deviceId: string; turnOn: boolean; entityId?: string }) =>
http.post<never, HomeAssistantDeviceSwitchOutput>('/api/health/home-assistant/devices/switch', payload)
}
+199 -9
View File
@@ -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;
+158 -31
View File
@@ -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<string | null>(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)
</div>
</div>
<!-- 1. 场景控制板块 -->
<!-- 1. 睡眠场景控制 -->
<div class="smart-subcard">
<div class="subcard-header">
<div class="subcard-title">
<span class="subcard-icon">🌙</span>
<strong>睡眠场景控制</strong>
<span class="subcard-model">({{ sceneAvailable }}/{{ sceneTotal }} 项可用)</span>
</div>
<span class="scene-status-badge">场景 {{ sceneAvailable }}/{{ sceneTotal }} 就绪</span>
<button
class="preset-toggle-btn"
type="button"
@click="showScenePresets = !showScenePresets"
>
{{ showScenePresets ? '收起参数 ▲' : '控制参数详情 ▼' }}
</button>
</div>
<div class="scene-row">
<div class="scene-info">
<strong>舒睡模式</strong>
<p>除湿 55% · 微风睡眠 · 暖光助眠 · 草本香氛</p>
<strong>一键舒睡</strong>
<p>仅执行已配置且当前可用的恒湿、香氛与暖光动作</p>
</div>
<div class="scene-actions">
<button
@@ -311,12 +377,12 @@ onMounted(load)
:disabled="!homeAssistant?.connected || sceneAvailable === 0 || !!sceneRunning"
@click="executeScene('all-off')"
>
{{ sceneRunning === 'all-off' ? '执行中…' : '全部关闭' }}
{{ sceneRunning === 'all-off' ? '执行中…' : '关闭场景设备' }}
</button>
</div>
</div>
<!-- 场景关联的4个设备微卡片 -->
<!-- 场景设备实时可用状态 -->
<div v-if="sceneDevices.length" class="scene-device-list">
<div
v-for="d in sceneDevices"
@@ -326,7 +392,37 @@ onMounted(load)
>
<span class="dot"></span>
<span class="name">{{ getSceneDeviceShortName(d) }}</span>
<span class="desc">{{ getSceneDeviceStatus(d) }}</span>
<span class="device-mini-status" :class="getDeviceStatusType(d)">
{{ getSceneDeviceStatus(d) }}
</span>
</div>
</div>
<!-- 后台真实配置的场景动作 -->
<div v-if="showScenePresets && homeAssistant?.scenePresets?.length" class="scene-presets-panel">
<div class="preset-panel-header">
<span>场景执行参数</span>
<small>仅展示后台实际下发的动作</small>
</div>
<div class="preset-items-grid">
<div
v-for="item in homeAssistant.scenePresets"
:key="item.deviceName"
class="preset-card-item"
:class="{ unavailable: !item.available }"
>
<div class="preset-card-top">
<span class="preset-icon">
{{ item.category === 'EnvironmentControl' ? '💧' : item.category === 'Fragrance' ? '🌸' : item.category === 'Lighting' ? '💡' : item.category === 'Media' ? '🔊' : item.category === 'Sleep' ? '🛏️' : item.category === 'EnvironmentSensor' ? '🌿' : '⚖️' }}
</span>
<strong class="preset-name">{{ item.deviceName }}</strong>
<span class="preset-action">{{ item.action }}</span>
</div>
<p class="preset-detail">{{ item.detail }}</p>
<p class="preset-availability" :class="{ unavailable: !item.available }">
{{ item.availabilityMessage }}
</p>
</div>
</div>
</div>
</div>
@@ -339,9 +435,14 @@ onMounted(load)
<strong>环境健康</strong>
<span class="subcard-model">{{ getDeviceSubModel(envDevice, '罗威亚检测仪') }}</span>
</div>
<span v-if="envDevice" class="device-mini-status" :class="getDeviceStatusType(envDevice)">
{{ getDeviceShortBadgeText(envDevice) }}
</span>
<button
class="sensor-mini-sync-btn"
:disabled="syncingSensor"
type="button"
@click="syncSensorData"
>
{{ syncingSensor ? '刷新中…' : '立即刷新' }}
</button>
</div>
<div class="env-container">
@@ -408,7 +509,11 @@ onMounted(load)
<strong>睡眠健康</strong>
<span class="subcard-model">{{ getDeviceSubModel(sleepDevice, '8H 睡眠床垫') }}</span>
</div>
<span v-if="sleepDevice" class="device-mini-status" :class="getDeviceStatusType(sleepDevice)">
<span
v-if="sleepDevice"
class="device-mini-status"
:class="getDeviceStatusType(sleepDevice)"
>
{{ getDeviceShortBadgeText(sleepDevice) }}
</span>
</div>
@@ -419,15 +524,15 @@ onMounted(load)
</div>
<div class="feature-item">
<small>左侧软硬度</small>
<strong>{{ sleepDevice?.metrics?.leftFirmness || '5 档 · 适中' }}</strong>
<strong>{{ sleepDevice?.metrics?.leftFirmness || '暂无数据' }}</strong>
</div>
<div class="feature-item">
<small>右侧软硬度</small>
<strong>{{ sleepDevice?.metrics?.rightFirmness || '6 档 · 偏硬' }}</strong>
<strong>{{ sleepDevice?.metrics?.rightFirmness || '暂无数据' }}</strong>
</div>
<div class="feature-item">
<small>智能调温</small>
<strong>{{ sleepDevice?.online ? '温控已就绪' : '待接入' }}</strong>
<strong>{{ sleepDevice?.online ? '状态待读取' : '待接入' }}</strong>
</div>
</div>
</div>
@@ -440,22 +545,27 @@ onMounted(load)
<strong>身体健康</strong>
<span class="subcard-model">{{ getDeviceSubModel(bodyDevice, '米家体脂秤') }}</span>
</div>
<span v-if="bodyDevice" class="device-mini-status" :class="getDeviceStatusType(bodyDevice)">
{{ getDeviceShortBadgeText(bodyDevice) }}
</span>
<button
class="sensor-mini-sync-btn"
:disabled="syncingSensor"
type="button"
@click="syncSensorData"
>
{{ syncingSensor ? '刷新中…' : '刷新状态' }}
</button>
</div>
<div class="device-feature-row">
<div class="feature-item">
<small>体脂秤状态</small>
<strong>{{ bodyDevice?.online ? '称重即同步' : '待同步' }}</strong>
<strong>{{ bodyDevice?.online ? '设备在线' : '待同步' }}</strong>
</div>
<div class="feature-item">
<small>最新体重</small>
<strong>{{ bodyDevice?.metrics?.weight || '68.5' }}<em>kg</em></strong>
<strong>{{ bodyDevice?.metrics?.weight ?? '—' }}<em v-if="bodyDevice?.metrics?.weight != null">kg</em></strong>
</div>
<div class="feature-item">
<small>体脂率</small>
<strong>{{ bodyDevice?.metrics?.bodyFatRate || '18.2' }}<em>%</em></strong>
<strong>{{ bodyDevice?.metrics?.bodyFatRate ?? '—' }}<em v-if="bodyDevice?.metrics?.bodyFatRate != null">%</em></strong>
</div>
<div class="feature-item">
<small>测量算法</small>
@@ -472,7 +582,7 @@ onMounted(load)
<strong>全部智能设备</strong>
<span class="subcard-model">({{ totalDeviceCount }} 台)</span>
</div>
<small style="color: #82908c; font-size: 11px;">自动发现实时同步</small>
<small style="color: #82908c; font-size: 11px;">已确认主控能力的设备可操作</small>
</div>
<div v-if="homeAssistant?.devices?.length" class="all-device-list">
@@ -490,15 +600,32 @@ onMounted(load)
<span class="category-tag">{{ categoryNames[item.category] || item.category }}</span>
</div>
<p class="device-card-meta">
<span>{{ item.areaName || '睡眠空间' }}</span>
<span>{{ item.areaName || '未分区' }}</span>
<span>· {{ item.model }}</span>
</p>
</div>
</div>
<div class="device-card-extra">
<span class="status-pill" :class="getDeviceStatusType(item)">
{{ getDeviceListBadgeText(item) }}
</span>
<button
v-if="isControllable(item)"
class="device-switch-pill"
:class="{ on: isDeviceTurnedOn(item), disabled: !isPrimaryControlAvailable(item) }"
:disabled="!isPrimaryControlAvailable(item) || switchingDeviceId === item.deviceId"
type="button"
@click="toggleDeviceSwitch(item)"
>
<span class="switch-knob"></span>
<span class="switch-text">{{ switchingDeviceId === item.deviceId ? '…' : (isDeviceTurnedOn(item) ? '已开启' : '已关闭') }}</span>
</button>
<button
v-else
class="sensor-sync-pill"
:disabled="syncingSensor"
type="button"
@click="syncSensorData"
>
{{ syncingSensor ? '同步中' : '刷新' }}
</button>
<small class="entity-count">{{ item.entities.filter(e => e.available).length }}/{{ item.entities.length }} 实体</small>
</div>
</article>