This commit is contained in:
fuenmao
2026-09-21 16:52:32 +08:00
parent 8ce6bec02d
commit 23cae3fda9
3 changed files with 123 additions and 7 deletions
+43 -1
View File
@@ -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<never, AuthOutput>('/api/health/auth/register', payload),
@@ -57,5 +93,11 @@ export const healthApi = {
http.put<never, HealthProfile>('/api/health/profile', payload),
saveModules: (moduleCodes: string[]) =>
http.put<never, string[]>('/api/health/modules', { moduleCodes }),
generatePlan: () => http.post<never, HealthPlan>('/api/health/plans/generate')
generatePlan: () => http.post<never, HealthPlan>('/api/health/plans/generate'),
homeAssistantStatus: () =>
http.get<never, HomeAssistantStatus>('/api/health/home-assistant/status'),
executeSleepScene: () =>
http.post<never, HomeAssistantSceneResult>('/api/health/home-assistant/scenes/sleep'),
executeAllOffScene: () =>
http.post<never, HomeAssistantSceneResult>('/api/health/home-assistant/scenes/all-off')
}
+21 -1
View File
@@ -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; }
}
+59 -5
View File
@@ -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<Dashboard>()
const homeAssistant = ref<HomeAssistantStatus>()
const moduleNames: Record<string, string> = {
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)
</section>
<section class="section-card smart-card">
<div class="section-title"><span>健康空间</span><small class="status-dot">Home Assistant 待接入</small></div>
<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') }}
</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>灯光 · 空调 · 香氛 · 除湿机</p>
<p>除湿机 55% · 睡眠模式 · 低档风速 · 香氛 · 暖光</p>
</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>
</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>
</div>
<button type="button">准备就绪</button>
</div>
</section>