This commit is contained in:
fuenmao
2026-10-08 10:16:36 +08:00
parent 65eff42cc8
commit 4925c4c74d
5 changed files with 150 additions and 39 deletions
+9
View File
@@ -219,6 +219,13 @@ export type HomeAssistantLightTransition = {
message: string
}
export type TcmMusicTransition = {
running: boolean
hasVolume: boolean
volumePercent: number
message: string
}
export type HomeAssistantStatus = {
connected: boolean
message: string
@@ -285,6 +292,8 @@ export const healthApi = {
http.get<never, HomeAssistantStatus>('/api/health/home-assistant/status'),
lightTransitionStatus: () =>
http.get<never, HomeAssistantLightTransition>('/api/health/home-assistant/lights/transition'),
musicTransitionStatus: () =>
http.get<never, TcmMusicTransition>('/api/health/home-assistant/music/transition'),
startSleepLight: () =>
http.post<never, HomeAssistantLightTransition>('/api/health/home-assistant/lights/sleep'),
startWakeLight: () =>
+1 -1
View File
@@ -19,7 +19,7 @@ async function submit() {
: await healthApi.register(form)
saveTokens(output.accessToken, output.refreshToken)
showToast({ type: 'success', message: mode.value === 'login' ? '登录成功' : '注册成功' })
await router.replace(mode.value === 'login' ? '/dashboard' : '/assessment')
await router.replace(mode.value === 'login' ? '/dashboard' : '/tcm-assessment')
} catch (error) {
showToast(error instanceof Error ? error.message : '操作失败')
} finally {
+15 -3
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Button as VanButton, showToast } from 'vant'
import AppTabBar from '../components/AppTabBar.vue'
@@ -15,6 +15,7 @@ const loading = ref(true)
const sceneRunning = ref<'sleep' | 'all-off'>()
const dashboard = ref<Dashboard>()
const homeAssistant = ref<HomeAssistantStatus>()
const sceneRefreshTimers: ReturnType<typeof setTimeout>[] = []
const greeting = computed(() => {
const hour = new Date().getHours()
if (hour < 11) return '早上好'
@@ -51,12 +52,20 @@ async function executeScene(scene: 'sleep' | 'all-off') {
const result = scene === 'sleep'
? await healthApi.executeSleepScene()
: await healthApi.executeAllOffScene()
showToast({ type: result.success ? 'success' : 'fail', message: result.message })
showToast({ type: result.status === 'Succeeded' ? 'success' : 'fail', message: result.message })
homeAssistant.value = await healthApi.homeAssistantStatus()
} catch (error) {
showToast(error instanceof Error ? error.message : '场景执行失败')
} finally {
sceneRunning.value = undefined
for (const timer of sceneRefreshTimers) clearTimeout(timer)
sceneRefreshTimers.length = 0
for (const delay of [2000, 5000, 10000]) {
sceneRefreshTimers.push(setTimeout(async () => {
try { homeAssistant.value = await healthApi.homeAssistantStatus() }
catch { /* 下次刷新继续读取设备状态。 */ }
}, delay))
}
}
}
@@ -66,6 +75,9 @@ function logout() {
}
onMounted(load)
onUnmounted(() => {
for (const timer of sceneRefreshTimers) clearTimeout(timer)
})
</script>
<template>
@@ -134,7 +146,7 @@ onMounted(load)
</div>
<div class="startup-param-badge">
<span class="lbl">助眠音箱</span>
<strong>体质音乐循环播放 · 60% 起每 5 秒降 1% 至停止 · {{ dashboard?.sceneConfig?.enableSpeakerSleepMode ? '开启睡眠模式' : '关闭睡眠模式' }}</strong>
<strong>体质音乐循环播放 · 50% 起每 5 秒降 1% 至停止 · {{ dashboard?.sceneConfig?.enableSpeakerSleepMode ? '开启睡眠模式' : '关闭睡眠模式' }}</strong>
</div>
<div class="startup-param-badge">
<span class="lbl">8H 床垫</span>
+124 -34
View File
@@ -15,6 +15,7 @@ import {
type HomeAssistantLightTransition,
type HomeAssistantStatus,
type MattressComfort,
type TcmMusicTransition,
type TcmAssessmentResult
} from '../services/health'
@@ -29,9 +30,15 @@ const switchingDeviceId = ref<string | null>(null)
const lastUpdatedAt = ref('')
const actionFeedback = ref<{ type: 'success' | 'fail'; message: string }>()
const lightTransition = ref<HomeAssistantLightTransition>()
const musicTransition = ref<TcmMusicTransition>()
const lightActionBusy = ref(false)
let lightProgressTimer: ReturnType<typeof setInterval> | undefined
let lightPolling = false
let musicProgressTimer: ReturnType<typeof setInterval> | undefined
let musicPolling = false
const sceneRefreshTimers: ReturnType<typeof setTimeout>[] = []
const currentUserId = ref<number>()
const mattressActiveSide = ref<'left' | 'right' | null>(null)
const parameterLabels: Record<string, string> = {
humidity: '目标湿度',
@@ -55,6 +62,7 @@ const parameterLabels: Record<string, string> = {
mattress_right_mode: '床垫右侧承托模式'
}
const mattressFirmnessOptions = ['最软', '很软', '稍软', '稍硬', '很硬', '最硬']
const mattressModeOptions = ['无', '瑜伽助眠', '智能适配', '智能腰托']
const mattressFirmnessZones = [
{ key: 'left_shoulder', label: '左肩' }, { key: 'right_shoulder', label: '右肩' },
{ key: 'left_back', label: '左背' }, { key: 'right_back', label: '右背' },
@@ -100,7 +108,7 @@ const localParams = reactive({
lightColorTemp: 2700,
// 音箱
speakerVolume: 15,
speakerVolume: 50,
speakerSleepMode: true,
// 香薰机
@@ -135,8 +143,37 @@ function numberValue(value: unknown): number | undefined {
return undefined
}
async function loadStatus(notify = false) {
loading.value = true
function mattressModeStorageKey(): string | undefined {
const deviceId = mattressDevice.value?.deviceId
return currentUserId.value && deviceId
? `evergreen-mattress-modes-${currentUserId.value}-${deviceId}`
: undefined
}
function readMattressModePreference(): { left: string; right: string } | undefined {
const key = mattressModeStorageKey()
if (!key) return undefined
try {
const saved = JSON.parse(localStorage.getItem(key) || 'null')
return mattressModeOptions.includes(saved?.left) && mattressModeOptions.includes(saved?.right) ? saved : undefined
} catch {
return undefined
}
}
function saveMattressModePreference() {
const key = mattressModeStorageKey()
if (!key) return
try {
localStorage.setItem(key, JSON.stringify({
left: localParams.mattressLeftMode,
right: localParams.mattressRightMode
}))
} catch { /* 浏览器禁止本地存储时,本次页面内的预选仍可使用。 */ }
}
async function loadStatus(notify = false, background = false) {
if (!background) loading.value = true
try {
const [status, dashboard] = await Promise.all([
healthApi.homeAssistantStatus(),
@@ -144,6 +181,7 @@ async function loadStatus(notify = false) {
])
haStatus.value = status
if (dashboard) {
currentUserId.value = dashboard.user.id
tcmResult.value = dashboard.tcmResult
mattressComfort.value = dashboard.mattressComfort
}
@@ -173,8 +211,16 @@ async function loadStatus(notify = false) {
localParams.mattressLeftHeating = clamp(numberValue(controls.mattress_left_heating?.state) ?? 1, 0, 4)
localParams.mattressRightHeating = clamp(numberValue(controls.mattress_right_heating?.state) ?? 1, 0, 4)
if (controls.mattress_left_mode?.state) localParams.mattressLeftMode = controls.mattress_left_mode.state
if (controls.mattress_right_mode?.state) localParams.mattressRightMode = controls.mattress_right_mode.state
const actualLeftMode = controls.mattress_left_mode?.state
const actualRightMode = controls.mattress_right_mode?.state
const preferredModes = readMattressModePreference()
mattressActiveSide.value = actualRightMode && mattressModeOptions.includes(actualRightMode) && actualRightMode !== '无'
? 'right'
: actualLeftMode && mattressModeOptions.includes(actualLeftMode) && actualLeftMode !== '无' ? 'left' : null
localParams.mattressLeftMode = mattressActiveSide.value === 'left'
? actualLeftMode! : preferredModes?.left ?? '智能适配'
localParams.mattressRightMode = mattressActiveSide.value === 'right'
? actualRightMode! : preferredModes?.right ?? '智能适配'
for (const zone of mattressFirmnessZones) {
const state = controls[`mattress_firmness_${zone.key}`]?.state
if (state && state !== 'unavailable' && state !== 'unknown')
@@ -184,6 +230,7 @@ async function loadStatus(notify = false) {
if (controls.mattress_massage_right_part?.state) localParams.mattressMassageRightPart = controls.mattress_massage_right_part.state
if (controls.mattress_massage_level?.state) localParams.mattressMassageLevel = controls.mattress_massage_level.state
await syncLightTransitionStatus()
await syncMusicTransitionStatus().catch(() => undefined)
lastUpdatedAt.value = new Intl.DateTimeFormat('zh-CN', {
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false
}).format(new Date())
@@ -193,10 +240,42 @@ async function loadStatus(notify = false) {
}
} catch (err) {
const message = err instanceof Error ? err.message : '获取设备状态失败'
setFeedback('fail', message)
showFailToast(message)
if (!background) {
setFeedback('fail', message)
showFailToast(message)
}
} finally {
loading.value = false
if (!background) loading.value = false
}
}
async function syncMusicTransitionStatus() {
const status = await healthApi.musicTransitionStatus()
musicTransition.value = status
if (status.hasVolume) localParams.speakerVolume = clamp(status.volumePercent, 0, 100)
if (status.running && !musicProgressTimer)
musicProgressTimer = setInterval(pollMusicTransition, 5000)
else if (!status.running && musicProgressTimer) {
clearInterval(musicProgressTimer)
musicProgressTimer = undefined
}
}
async function pollMusicTransition() {
if (musicPolling) return
musicPolling = true
try { await syncMusicTransitionStatus() }
catch { /* 网络短暂波动时保留最后一次已下发的音量,下个周期继续读取。 */ }
finally { musicPolling = false }
}
function scheduleSceneStatusRefresh() {
for (const timer of sceneRefreshTimers) clearTimeout(timer)
sceneRefreshTimers.length = 0
for (const delay of [2000, 5000, 10000]) {
sceneRefreshTimers.push(setTimeout(() => {
if (!operationBusy.value) void loadStatus(false, true)
}, delay))
}
}
@@ -372,7 +451,7 @@ async function controlSpeaker(device: HomeAssistantDiscoveredDevice, play: boole
}
// 调节参数 API
async function applyParam(deviceId: string, paramType: string, val: unknown, entityId?: string) {
async function applyParam(deviceId: string, paramType: string, val: unknown, entityId?: string): Promise<boolean> {
const key = `${deviceId}_${paramType}`
settingParam.value = key
const label = parameterLabels[paramType]
@@ -391,14 +470,17 @@ async function applyParam(deviceId: string, paramType: string, val: unknown, ent
})
const message = res.message || `${label}设置成功`
if (!res.success) throw new Error(message)
if (paramType !== 'volume') await loadStatus()
if (paramType === 'volume') await syncMusicTransitionStatus().catch(() => undefined)
else await loadStatus()
setFeedback('success', message)
showSuccessToast(message)
return true
} catch (err) {
const message = err instanceof Error ? err.message : `${label}设置失败`
await loadStatus()
setFeedback('fail', message)
showFailToast(message)
return false
} finally {
loadingToast.close()
settingParam.value = null
@@ -408,17 +490,13 @@ async function applyParam(deviceId: string, paramType: string, val: unknown, ent
async function setMattressMode(side: 'left' | 'right', mode: string) {
const device = mattressDevice.value
if (!device) return
if (side === 'left') {
localParams.mattressLeftMode = mode
if (mode !== '无') localParams.mattressRightMode = '无'
await applyParam(device.deviceId, 'mattress_left_mode', mode)
return
}
localParams.mattressRightMode = mode
if (mode !== '无') localParams.mattressLeftMode = '无'
await applyParam(device.deviceId, 'mattress_right_mode', mode)
const succeeded = await applyParam(device.deviceId, `mattress_${side}_mode`, mode)
if (!succeeded) return
if (side === 'left') localParams.mattressLeftMode = mode
else localParams.mattressRightMode = mode
if (mode !== '无') mattressActiveSide.value = side
else if (mattressActiveSide.value === side) mattressActiveSide.value = null
saveMattressModePreference()
}
async function setMattressSelect(parameterType: string, event: Event) {
@@ -458,8 +536,9 @@ async function executeScene(scene: 'sleep' | 'all-off') {
: await healthApi.executeAllOffScene()
if (!res.success) throw new Error(res.message || '场景执行失败')
await loadStatus()
setFeedback('success', res.message)
showSuccessToast(res.message)
setFeedback(res.status === 'Partial' ? 'fail' : 'success', res.message)
if (res.status === 'Partial') showFailToast(res.message)
else showSuccessToast(res.message)
} catch (err) {
const message = err instanceof Error ? err.message : '场景执行失败'
await loadStatus()
@@ -468,6 +547,7 @@ async function executeScene(scene: 'sleep' | 'all-off') {
} finally {
loadingToast.close()
sceneRunning.value = undefined
scheduleSceneStatusRefresh()
}
}
@@ -485,6 +565,8 @@ onMounted(() => {
onUnmounted(() => {
if (lightProgressTimer) clearInterval(lightProgressTimer)
if (musicProgressTimer) clearInterval(musicProgressTimer)
for (const timer of sceneRefreshTimers) clearTimeout(timer)
})
</script>
@@ -778,7 +860,7 @@ onUnmounted(() => {
<!-- 音量滑块 -->
<div class="param-row">
<div class="param-header">
<span class="p-title">手动播放音量</span>
<span class="p-title">音箱音量(MiAirX 已下发)</span>
<strong class="p-val">{{ localParams.speakerVolume }}%</strong>
</div>
<VanSlider
@@ -794,7 +876,7 @@ onUnmounted(() => {
<span>50% 适中聆听</span>
<span>100% 满格音量</span>
</div>
<small class="p-tip">手动调整音量会停止本次自动渐弱</small>
<small class="p-tip">{{ musicTransition?.running ? '自动渐弱中,每 5 秒更新一次显示' : '手动调整音量会停止本次自动渐弱' }}</small>
</div>
<div class="param-row inline-row">
@@ -837,6 +919,8 @@ onUnmounted(() => {
</VanButton>
</div>
<small class="p-tip">上方“释放中/已关闭”只表示香薰主开关;感应夜灯在下方单独显示和控制。</small>
<div class="params-body">
<div class="param-row">
<div class="param-header">
@@ -1008,17 +1092,17 @@ onUnmounted(() => {
<div class="param-row">
<p class="mattress-mode-note">
左右承托共用气泵,同一时间仅能运行一侧;启动一侧后,另一侧会自动停止。
左右可分别预选模式,但共用气泵,同一时间仅最近启动的一侧实际运行;另一侧保留预选,处于待机状态。
</p>
</div>
<div class="param-row">
<span class="p-title">左侧承托模式</span>
<div class="btn-group">
<span class="p-title">左侧承托模式 · {{ mattressActiveSide === 'left' ? '运行中' : '预选待机' }}</span>
<div class="btn-group mattress-mode-buttons">
<button
v-for="mode in ['无', '瑜伽助眠', '智能适配', '智能腰托']"
v-for="mode in mattressModeOptions"
:key="`left-${mode}`"
:class="{ active: localParams.mattressLeftMode === mode }"
:class="{ active: localParams.mattressLeftMode === mode, running: mattressActiveSide === 'left' && localParams.mattressLeftMode === mode }"
:disabled="operationBusy"
@click="setMattressMode('left', mode)"
>{{ mode }}</button>
@@ -1026,12 +1110,12 @@ onUnmounted(() => {
</div>
<div class="param-row">
<span class="p-title">右侧承托模式</span>
<div class="btn-group">
<span class="p-title">右侧承托模式 · {{ mattressActiveSide === 'right' ? '运行中' : '预选待机' }}</span>
<div class="btn-group mattress-mode-buttons">
<button
v-for="mode in ['无', '瑜伽助眠', '智能适配', '智能腰托']"
v-for="mode in mattressModeOptions"
:key="`right-${mode}`"
:class="{ active: localParams.mattressRightMode === mode }"
:class="{ active: localParams.mattressRightMode === mode, running: mattressActiveSide === 'right' && localParams.mattressRightMode === mode }"
:disabled="operationBusy"
@click="setMattressMode('right', mode)"
>{{ mode }}</button>
@@ -1598,6 +1682,12 @@ onUnmounted(() => {
font-weight: 700;
}
.mattress-mode-buttons button.active:not(.running) {
background: #e4f1ec;
color: #0c5045;
border-color: #87b9a7;
}
.btn-group button:disabled,
.mini-toggle:disabled {
opacity: 0.55;
+1 -1
View File
@@ -138,7 +138,7 @@ async function submit() {
<div class="parameter-preview">
<span>一键启动预设</span>
<p>湿度 {{ result.sceneConfig.targetHumidity }}% · 灯光 {{ result.sceneConfig.lightColorTemperatureKelvin }}K</p>
<p>香氛{{ result.sceneConfig.enableFragrance ? '开启' : '关闭' }} · 体质音乐循环播放,从 60% 每 5 秒降 1% 至停止</p>
<p>香氛{{ result.sceneConfig.enableFragrance ? '开启' : '关闭' }} · 体质音乐循环播放,从 50% 每 5 秒降 1% 至停止</p>
<p>床垫加热按重庆冬季天气决定;软硬度按健康档案 BMI 给出可手动修改的演示起点。</p>
</div>
</section>