11111
This commit is contained in:
+43
-1
@@ -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
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user