111
This commit is contained in:
+54
-8
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user