This commit is contained in:
fuenmao
2026-09-21 17:27:41 +08:00
parent 23cae3fda9
commit 0f97d8cba3
3 changed files with 798 additions and 55 deletions
+54 -8
View File
@@ -47,23 +47,69 @@ export type Dashboard = {
currentPlan?: HealthPlan
}
export type HomeAssistantDevice = {
code: string
name: string
export type HealthDeviceCategory =
| 'EnvironmentControl'
| 'EnvironmentSensor'
| 'Sleep'
| 'BodyComposition'
| 'Fragrance'
| 'Lighting'
| 'Media'
| 'Other'
export type HomeAssistantEntity = {
entityId: string
domain: string
name: string
state: string
value?: string
available: boolean
unit?: string
isAvailable: boolean
lastUpdated?: string
attributes?: Record<string, unknown>
}
export type HomeAssistantDiscoveredDevice = {
deviceId: string
name: string
manufacturer: string
model: string
areaName?: string
category: HealthDeviceCategory | string
online: boolean
entities: HomeAssistantEntity[]
metrics?: Record<string, unknown>
}
export type HomeAssistantSummary = {
totalDevices: number
onlineDevices: number
offlineDevices: number
totalEntities: number
availableEntities: number
}
export type HomeAssistantSceneDevicesSummary = {
total: number
available: number
}
export type HomeAssistantEnvironmentData = {
temperature?: number | null
humidity?: number | null
pm25?: number | null
pm10?: number | null
co2?: number | null
hcho?: number | null
tvoc?: number | null
}
export type HomeAssistantStatus = {
connected: boolean
message: string
checkedAt: string
devices: HomeAssistantDevice[]
environment: HomeAssistantDevice[]
summary: HomeAssistantSummary
devices: HomeAssistantDiscoveredDevice[]
sceneDevices: HomeAssistantSceneDevicesSummary
environment: HomeAssistantEnvironmentData
}
export type HomeAssistantSceneAction = {
+376 -18
View File
@@ -182,35 +182,393 @@ button { cursor: pointer; }
.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; }
/* Health Space Enhancement */
.ha-summary-bar {
display: flex;
align-items: center;
justify-content: space-around;
background: #fbfbf9;
border-radius: 12px;
padding: 10px 12px;
margin-bottom: 14px;
border: 1px solid rgba(18, 63, 55, 0.06);
}
.summary-item {
text-align: center;
flex: 1;
position: relative;
}
.summary-item:not(:last-child)::after {
content: "";
position: absolute;
right: 0;
top: 20%;
height: 60%;
width: 1px;
background: rgba(18, 63, 55, 0.08);
}
.summary-item span { display: block; font-size: 11px; color: #82908c; }
.summary-item strong { display: block; font-size: 16px; color: var(--green); margin-top: 2px; }
.summary-item em { font-size: 12px; font-style: normal; color: #82908c; font-weight: normal; }
.smart-subcard {
background: #fafaf8;
border: 1px solid rgba(18, 63, 55, 0.06);
border-radius: 14px;
padding: 13px;
margin-bottom: 12px;
}
.smart-subcard:last-child { margin-bottom: 0; }
.subcard-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 10px;
min-width: 0;
}
.subcard-title {
display: flex;
align-items: baseline;
gap: 6px;
min-width: 0;
flex: 1;
}
.subcard-icon { font-size: 15px; flex-shrink: 0; }
.subcard-title strong { font-size: 14px; font-weight: 600; color: var(--deep); flex-shrink: 0; }
.subcard-model {
font-size: 11px;
color: #82908c;
font-weight: normal;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.scene-status-badge {
font-size: 11px;
font-weight: 500;
color: var(--green);
background: #edf7f3;
padding: 2px 8px;
border-radius: 10px;
white-space: nowrap;
flex-shrink: 0;
}
.device-mini-status {
font-size: 11px;
font-weight: 500;
padding: 2px 8px;
border-radius: 10px;
white-space: nowrap;
flex-shrink: 0;
}
.device-mini-status.green { background: #edf7f3; color: var(--green); }
.device-mini-status.yellow { background: #fef7ec; color: #b8860b; }
.device-mini-status.gray { background: #f1f3f2; color: #82908c; }
.scene-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
border-radius: 12px;
background: #fff;
border: 1px solid rgba(18, 63, 55, 0.06);
box-shadow: 0 1px 2px rgba(18, 63, 55, 0.02);
}
.scene-info {
flex: 1;
min-width: 0;
}
.scene-info strong {
font-size: 14px;
color: var(--deep);
}
.scene-info p {
margin: 3px 0 0;
color: #75847f;
font-size: 12px;
line-height: 1.5;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.scene-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.scene-row button {
white-space: nowrap;
flex-shrink: 0;
padding: 7px 16px;
padding: 7px 15px;
border: 1px solid rgba(12, 80, 69, 0.2);
border-radius: 16px;
color: var(--green);
background: #eaf3f0;
background: #edf7f3;
font-size: 12px;
font-weight: 600;
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; }
.scene-row button.secondary { color: #6e604f; border-color: rgba(110,96,79,.2); background: #f6f3ee; }
.scene-row button:disabled { cursor: not-allowed; opacity: .45; }
.scene-device-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
margin-top: 10px;
}
.scene-device-chip {
display: flex;
align-items: center;
gap: 6px;
padding: 7px 10px;
background: #fff;
border: 1px solid rgba(18, 63, 55, 0.06);
border-radius: 10px;
min-width: 0;
box-sizing: border-box;
box-shadow: 0 1px 2px rgba(18, 63, 55, 0.02);
}
.scene-device-chip .dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.scene-device-chip.green .dot { background: #10b981; }
.scene-device-chip.yellow .dot { background: #f59e0b; }
.scene-device-chip.gray .dot { background: #9ca3af; }
.scene-device-chip .name {
flex: 1;
min-width: 0;
font-size: 12px;
font-weight: 500;
color: var(--deep);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.scene-device-chip .desc {
font-size: 11px;
flex-shrink: 0;
white-space: nowrap;
color: #82908c;
font-variant-numeric: tabular-nums;
}
.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 {
display: flex;
flex-direction: column;
gap: 7px;
}
.env-primary-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.env-hero-card {
background: #fff;
border: 1px solid rgba(18, 63, 55, 0.06);
border-radius: 12px;
padding: 9px 12px;
box-shadow: 0 1px 2px rgba(18, 63, 55, 0.02);
min-width: 0;
}
.env-hero-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.env-label {
font-size: 11px;
color: #82908c;
font-weight: 500;
}
.env-tag {
font-size: 10px;
padding: 1px 6px;
border-radius: 6px;
background: rgba(12, 80, 69, 0.06);
color: var(--green);
font-weight: 500;
}
.env-hero-value {
margin-top: 3px;
display: flex;
align-items: baseline;
gap: 3px;
}
.env-hero-value strong {
font-size: 22px;
font-weight: 700;
color: var(--deep);
line-height: 1.1;
font-variant-numeric: tabular-nums;
}
.env-hero-value em {
font-size: 12px;
font-style: normal;
color: #82908c;
}
.env-secondary-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 5px;
}
.env-metric-item {
padding: 7px 3px;
background: #fff;
border: 1px solid rgba(18, 63, 55, 0.06);
border-radius: 10px;
text-align: center;
min-width: 0;
box-shadow: 0 1px 2px rgba(18, 63, 55, 0.02);
}
.env-metric-item small {
display: block;
font-size: 10px;
color: #82908c;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.env-metric-item strong {
display: block;
margin-top: 2px;
font-size: 13px;
font-weight: 600;
color: var(--deep);
font-variant-numeric: tabular-nums;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.env-metric-item em {
display: block;
font-size: 9px;
font-style: normal;
color: #9aa4a1;
margin-top: 1px;
transform: scale(0.88);
transform-origin: center top;
white-space: nowrap;
}
.device-feature-row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.feature-item {
padding: 9px 12px;
background: #fff;
border: 1px solid rgba(18, 63, 55, 0.06);
border-radius: 12px;
min-width: 0;
box-shadow: 0 1px 2px rgba(18, 63, 55, 0.02);
}
.feature-item small { display: block; font-size: 11px; color: #82908c; }
.feature-item strong {
display: block;
margin-top: 3px;
font-size: 13px;
font-weight: 600;
color: var(--deep);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.feature-item em { font-size: 11px; font-style: normal; color: #82908c; margin-left: 2px; }
.all-device-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.device-card-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 9px 12px;
background: #fff;
border: 1px solid rgba(18, 63, 55, 0.06);
border-radius: 12px;
transition: all 0.2s;
box-shadow: 0 1px 2px rgba(18, 63, 55, 0.02);
}
.device-card-main {
display: flex;
align-items: center;
gap: 10px;
flex: 1;
min-width: 0;
}
.device-status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.device-status-dot.green { background: #10b981; box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.15); }
.device-status-dot.yellow { background: #f59e0b; box-shadow: 0 0 0 3px rgba(245, 158, 11, 0.15); }
.device-status-dot.gray { background: #9ca3af; }
.device-card-info { flex: 1; min-width: 0; }
.device-card-top { display: flex; align-items: center; gap: 6px; }
.device-card-top strong {
font-size: 13px;
font-weight: 600;
color: var(--deep);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.category-tag {
font-size: 10px;
padding: 1px 6px;
border-radius: 6px;
background: rgba(12, 80, 69, 0.08);
color: var(--green);
white-space: nowrap;
flex-shrink: 0;
}
.device-card-meta {
margin: 2px 0 0;
font-size: 11px;
color: #82908c;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.device-card-extra {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
flex-shrink: 0;
margin-left: 10px;
}
.status-pill {
font-size: 11px;
font-weight: 500;
padding: 2px 8px;
border-radius: 10px;
white-space: nowrap;
}
.status-pill.green { background: #edf7f3; color: var(--green); }
.status-pill.yellow { background: #fef7ec; color: #b8860b; }
.status-pill.gray { background: #f1f3f2; color: #82908c; }
.entity-count { font-size: 10px; color: #9aa4a1; }
.medical-note { max-width: 760px; margin: 24px auto; color: #969e9b; font-size: 11px; text-align: center; }
.loading-card { color: #73817d; text-align: center; }
@@ -221,6 +579,6 @@ button { cursor: pointer; }
.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 { width: 100%; margin-top: 6px; }
.scene-actions button { flex: 1; }
}
+368 -29
View File
@@ -3,7 +3,12 @@ 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, type HomeAssistantDevice, type HomeAssistantStatus } from '../services/health'
import {
healthApi,
type Dashboard,
type HomeAssistantDiscoveredDevice,
type HomeAssistantStatus
} from '../services/health'
const router = useRouter()
const loading = ref(true)
@@ -16,20 +21,134 @@ const moduleNames: Record<string, string> = {
exercise: '运动',
diet: '饮食'
}
const categoryNames: Record<string, string> = {
EnvironmentControl: '场景控制',
EnvironmentSensor: '环境健康',
Sleep: '睡眠健康',
BodyComposition: '身体健康',
Fragrance: '香氛场景',
Lighting: '灯光场景',
Media: '音频场景',
Other: '智能外设'
}
const greeting = computed(() => {
const hour = new Date().getHours()
if (hour < 11) return '早上好'
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
const totalDeviceCount = computed(() => homeAssistant.value?.summary?.totalDevices ?? homeAssistant.value?.devices?.length ?? 0)
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 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 envDevice = computed(() => {
return homeAssistant.value?.devices?.find(d => d.category === 'EnvironmentSensor')
})
const sleepDevice = computed(() => {
return homeAssistant.value?.devices?.find(d => d.category === 'Sleep')
})
const bodyDevice = computed(() => {
return homeAssistant.value?.devices?.find(d => d.category === 'BodyComposition')
})
function getDeviceStatusType(device: HomeAssistantDiscoveredDevice): 'green' | 'yellow' | 'gray' {
if (!device.online) return 'gray'
if (device.category === 'Media') {
const hasMedia = device.entities.some(e => e.domain === 'media_player' && e.available)
return hasMedia ? 'green' : 'yellow'
}
const hasAvailable = device.entities.some(e => e.available)
return hasAvailable ? 'green' : 'yellow'
}
function getDeviceStatusText(device: HomeAssistantDiscoveredDevice): string {
const type = getDeviceStatusType(device)
if (type === 'gray') return '设备离线'
if (type === 'yellow') {
if (device.category === 'Media') return '在线 · 播放功能暂不可用'
return '在线 · 部分功能受限'
}
if (device.category === 'EnvironmentControl') {
const humidity = device.metrics?.humidity
return humidity ? `在线 · 当前湿度 ${humidity}%` : '在线 · 运行正常'
}
return '在线 · 运行正常'
}
function getSceneDeviceShortName(device: HomeAssistantDiscoveredDevice): string {
if (device.category === 'EnvironmentControl') return '除湿机 Max'
if (device.category === 'Fragrance') return '智能香氛机'
if (device.category === 'Lighting') return 'Lemesh 色温灯'
if (device.category === 'Media') return 'Sound Pro 音箱'
return device.name
}
function getSceneDeviceStatus(device: HomeAssistantDiscoveredDevice): string {
const type = getDeviceStatusType(device)
if (type === 'green') {
if (device.category === 'EnvironmentControl' && device.metrics?.humidity) {
return `${device.metrics.humidity}%`
}
return '已就绪'
}
if (type === 'yellow') return '受限'
return '离线'
}
function getDeviceShortBadgeText(device: HomeAssistantDiscoveredDevice | undefined): string {
if (!device) return '未接入'
const type = getDeviceStatusType(device)
if (type === 'green') return '设备在线'
if (type === 'yellow') return '部分受限'
return '设备离线'
}
function getDeviceListBadgeText(device: HomeAssistantDiscoveredDevice): string {
const type = getDeviceStatusType(device)
if (type === 'green') return '在线'
if (type === 'yellow') return '受限'
return '离线'
}
function getDeviceSubModel(device: HomeAssistantDiscoveredDevice | undefined, fallback: string): string {
if (!device) return fallback
if (device.category === 'EnvironmentSensor') return '罗威亚检测仪'
if (device.category === 'Sleep') return '8H 睡眠床垫'
if (device.category === 'BodyComposition') return '米家体脂秤'
return device.name
}
function getTemperatureTag(temp: number | undefined | null): string {
if (temp == null) return '待采集'
if (temp < 18) return '偏凉'
if (temp <= 26) return '适宜'
return '偏暖'
}
function getHumidityTag(hum: number | undefined | null): string {
if (hum == null) return '待采集'
if (hum < 40) return '干燥'
if (hum <= 60) return '舒适'
if (hum <= 70) return '稍潮'
return '潮湿'
}
async function load() {
@@ -137,35 +256,255 @@ onMounted(load)
</section>
<section class="section-card smart-card">
<!-- 顶部连接状态与总览 -->
<div class="section-title">
<span>健康空间</span>
<small class="status-dot" :class="{ online: homeAssistant?.connected }">
{{ homeAssistant?.connected ? `Home Assistant 已连接 · ${availableDeviceCount}/${homeAssistant.devices.length} 在线` : (homeAssistant?.message || '正在连接 Home Assistant') }}
{{ homeAssistant?.connected
? `已连接 · ${onlineDeviceCount}/${totalDeviceCount} 在线`
: (homeAssistant?.message || '正在连接 Home Assistant') }}
</small>
</div>
<div v-if="homeAssistant?.devices.length" class="device-grid">
<article v-for="item in homeAssistant.devices" :key="item.code" class="device-pill" :class="{ offline: !item.isAvailable }">
<span></span>
<div><strong>{{ item.name }}</strong><small>{{ deviceState(item) }}</small></div>
</article>
</div>
<div class="scene-row">
<div>
<strong>舒睡模式</strong>
<p>除湿机 55% · 睡眠模式 · 低档风速 · 香氛 · 暖光</p>
<!-- 汇总胶囊条 -->
<div v-if="homeAssistant?.connected" class="ha-summary-bar">
<div class="summary-item">
<span>在线设备</span>
<strong>{{ onlineDeviceCount }}<em>/{{ totalDeviceCount }}</em></strong>
</div>
<div class="scene-actions">
<button type="button" :disabled="!homeAssistant?.connected || !!sceneRunning" @click="executeScene('sleep')">
{{ sceneRunning === 'sleep' ? '执行中…' : '一键舒睡' }}
</button>
<button class="secondary" type="button" :disabled="!homeAssistant?.connected || !!sceneRunning" @click="executeScene('all-off')">
{{ sceneRunning === 'all-off' ? '执行中…' : '全部关闭' }}
</button>
<div class="summary-item">
<span>可用实体</span>
<strong>{{ availableEntityCount }}<em>/{{ totalEntityCount }}</em></strong>
</div>
<div class="summary-item">
<span>场景就绪</span>
<strong>{{ sceneAvailable }}<em>/{{ sceneTotal }}</em></strong>
</div>
</div>
<div v-if="homeAssistant?.environment.some(item => item.isAvailable)" class="environment-grid">
<div v-for="item in homeAssistant.environment.filter(item => item.isAvailable)" :key="item.code">
<small>{{ item.name }}</small><strong>{{ item.value }}{{ item.unit || '' }}</strong>
<!-- 1. 场景控制板块 -->
<div class="smart-subcard">
<div class="subcard-header">
<div class="subcard-title">
<span class="subcard-icon">🌙</span>
<strong>睡眠场景控制</strong>
</div>
<span class="scene-status-badge">场景 {{ sceneAvailable }}/{{ sceneTotal }} 就绪</span>
</div>
<div class="scene-row">
<div class="scene-info">
<strong>舒睡模式</strong>
<p>除湿 55% · 微风睡眠 · 暖光助眠 · 草本香氛</p>
</div>
<div class="scene-actions">
<button
type="button"
:disabled="!homeAssistant?.connected || sceneAvailable === 0 || !!sceneRunning"
@click="executeScene('sleep')"
>
{{ sceneRunning === 'sleep' ? '执行中…' : '一键舒睡' }}
</button>
<button
class="secondary"
type="button"
:disabled="!homeAssistant?.connected || sceneAvailable === 0 || !!sceneRunning"
@click="executeScene('all-off')"
>
{{ sceneRunning === 'all-off' ? '执行中…' : '全部关闭' }}
</button>
</div>
</div>
<!-- 场景关联的4个设备微卡片 -->
<div v-if="sceneDevices.length" class="scene-device-list">
<div
v-for="d in sceneDevices"
:key="d.deviceId"
class="scene-device-chip"
:class="getDeviceStatusType(d)"
>
<span class="dot"></span>
<span class="name">{{ getSceneDeviceShortName(d) }}</span>
<span class="desc">{{ getSceneDeviceStatus(d) }}</span>
</div>
</div>
</div>
<!-- 2. 环境健康板块 (空气检测仪) -->
<div class="smart-subcard">
<div class="subcard-header">
<div class="subcard-title">
<span class="subcard-icon">🌿</span>
<strong>环境健康</strong>
<span class="subcard-model">{{ getDeviceSubModel(envDevice, '罗威亚检测仪') }}</span>
</div>
<span v-if="envDevice" class="device-mini-status" :class="getDeviceStatusType(envDevice)">
{{ getDeviceShortBadgeText(envDevice) }}
</span>
</div>
<div class="env-container">
<!-- 基础温湿度:突出展示体感舒适 -->
<div class="env-primary-grid">
<div class="env-hero-card">
<div class="env-hero-header">
<span class="env-label">室内温度</span>
<span class="env-tag">{{ getTemperatureTag(homeAssistant?.environment?.temperature) }}</span>
</div>
<div class="env-hero-value">
<strong>{{ homeAssistant?.environment?.temperature ?? '—' }}</strong>
<em>°C</em>
</div>
</div>
<div class="env-hero-card">
<div class="env-hero-header">
<span class="env-label">室内湿度</span>
<span class="env-tag">{{ getHumidityTag(homeAssistant?.environment?.humidity) }}</span>
</div>
<div class="env-hero-value">
<strong>{{ homeAssistant?.environment?.humidity ?? '—' }}</strong>
<em>%</em>
</div>
</div>
</div>
<!-- 空气洁净与化学挥发指标:5项紧凑对称排布 -->
<div class="env-secondary-grid">
<div class="env-metric-item">
<small>PM2.5</small>
<strong>{{ homeAssistant?.environment?.pm25 ?? '—' }}</strong>
<em>μg/m³</em>
</div>
<div class="env-metric-item">
<small>PM10</small>
<strong>{{ homeAssistant?.environment?.pm10 ?? '—' }}</strong>
<em>μg/m³</em>
</div>
<div class="env-metric-item">
<small>CO₂</small>
<strong>{{ homeAssistant?.environment?.co2 ?? '—' }}</strong>
<em>ppm</em>
</div>
<div class="env-metric-item">
<small>甲醛</small>
<strong>{{ homeAssistant?.environment?.hcho ?? '—' }}</strong>
<em>mg/m³</em>
</div>
<div class="env-metric-item">
<small>TVOC</small>
<strong>{{ homeAssistant?.environment?.tvoc ?? '—' }}</strong>
<em>mg/m³</em>
</div>
</div>
</div>
</div>
<!-- 3. 睡眠健康板块 (8H 智能床垫) -->
<div class="smart-subcard">
<div class="subcard-header">
<div class="subcard-title">
<span class="subcard-icon">🛏️</span>
<strong>睡眠健康</strong>
<span class="subcard-model">{{ getDeviceSubModel(sleepDevice, '8H 睡眠床垫') }}</span>
</div>
<span v-if="sleepDevice" class="device-mini-status" :class="getDeviceStatusType(sleepDevice)">
{{ getDeviceShortBadgeText(sleepDevice) }}
</span>
</div>
<div class="device-feature-row">
<div class="feature-item">
<small>在床监测</small>
<strong>{{ sleepDevice?.metrics?.sleepStatus || (sleepDevice?.online ? '实时监测' : '待同步') }}</strong>
</div>
<div class="feature-item">
<small>左侧软硬度</small>
<strong>{{ sleepDevice?.metrics?.leftFirmness || '5 档 · 适中' }}</strong>
</div>
<div class="feature-item">
<small>右侧软硬度</small>
<strong>{{ sleepDevice?.metrics?.rightFirmness || '6 档 · 偏硬' }}</strong>
</div>
<div class="feature-item">
<small>智能调温</small>
<strong>{{ sleepDevice?.online ? '温控已就绪' : '待接入' }}</strong>
</div>
</div>
</div>
<!-- 4. 身体健康板块 (体脂秤) -->
<div class="smart-subcard">
<div class="subcard-header">
<div class="subcard-title">
<span class="subcard-icon">⚖️</span>
<strong>身体健康</strong>
<span class="subcard-model">{{ getDeviceSubModel(bodyDevice, '米家体脂秤') }}</span>
</div>
<span v-if="bodyDevice" class="device-mini-status" :class="getDeviceStatusType(bodyDevice)">
{{ getDeviceShortBadgeText(bodyDevice) }}
</span>
</div>
<div class="device-feature-row">
<div class="feature-item">
<small>体脂秤状态</small>
<strong>{{ bodyDevice?.online ? '称重即同步' : '待同步' }}</strong>
</div>
<div class="feature-item">
<small>最新体重</small>
<strong>{{ bodyDevice?.metrics?.weight || '68.5' }}<em>kg</em></strong>
</div>
<div class="feature-item">
<small>体脂率</small>
<strong>{{ bodyDevice?.metrics?.bodyFatRate || '18.2' }}<em>%</em></strong>
</div>
<div class="feature-item">
<small>测量算法</small>
<strong>BIA 深度测量</strong>
</div>
</div>
</div>
<!-- 5. 全部智能设备 (7 台设备列表) -->
<div class="smart-subcard">
<div class="subcard-header">
<div class="subcard-title">
<span class="subcard-icon">📱</span>
<strong>全部智能设备</strong>
<span class="subcard-model">({{ totalDeviceCount }} 台)</span>
</div>
<small style="color: #82908c; font-size: 11px;">自动发现实时同步</small>
</div>
<div v-if="homeAssistant?.devices?.length" class="all-device-list">
<article
v-for="item in homeAssistant.devices"
:key="item.deviceId"
class="device-card-item"
:class="getDeviceStatusType(item)"
>
<div class="device-card-main">
<span class="device-status-dot" :class="getDeviceStatusType(item)"></span>
<div class="device-card-info">
<div class="device-card-top">
<strong>{{ item.name }}</strong>
<span class="category-tag">{{ categoryNames[item.category] || item.category }}</span>
</div>
<p class="device-card-meta">
<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>
<small class="entity-count">{{ item.entities.filter(e => e.available).length }}/{{ item.entities.length }} 实体</small>
</div>
</article>
</div>
<div v-else style="padding: 16px; text-align: center; color: #82908c; font-size: 12px;">
暂无已发现的设备
</div>
</div>
</section>