This commit is contained in:
fuenmao
2026-09-23 15:30:46 +08:00
parent 8c2d262066
commit efd71f99f3
3 changed files with 350 additions and 7 deletions
+29
View File
@@ -175,6 +175,23 @@ export type HomeAssistantDeviceSwitchOutput = {
message: string
}
export type HomeAssistantLightTransition = {
success: boolean
running: boolean
mode: 'none' | 'sleep' | 'wake'
brightnessPercent: number
targetPercent: number
message: string
}
export type HomeAssistantSpeakerDemo = {
audioUrl: string
speakerOnline: boolean
directPlaybackSupported: boolean
commandAccepted: boolean
message: string
}
export type HomeAssistantStatus = {
connected: boolean
message: string
@@ -236,6 +253,18 @@ export const healthApi = {
http.post<never, HealthAssessmentOutput>('/api/health/assessment', payload),
homeAssistantStatus: () =>
http.get<never, HomeAssistantStatus>('/api/health/home-assistant/status'),
lightTransitionStatus: () =>
http.get<never, HomeAssistantLightTransition>('/api/health/home-assistant/lights/transition'),
startSleepLight: () =>
http.post<never, HomeAssistantLightTransition>('/api/health/home-assistant/lights/sleep'),
startWakeLight: () =>
http.post<never, HomeAssistantLightTransition>('/api/health/home-assistant/lights/wake'),
stopLightTransition: () =>
http.post<never, HomeAssistantLightTransition>('/api/health/home-assistant/lights/stop'),
speakerDemoStatus: () =>
http.get<never, HomeAssistantSpeakerDemo>('/api/health/home-assistant/speaker/demo'),
playSpeakerDemo: () =>
http.post<never, HomeAssistantSpeakerDemo>('/api/health/home-assistant/speaker/play-demo'),
executeSleepScene: () =>
http.post<never, HomeAssistantSceneResult>('/api/health/home-assistant/scenes/sleep'),
executeAllOffScene: () =>
+1 -1
View File
@@ -120,7 +120,7 @@ onMounted(load)
</div>
<div class="startup-param-badge">
<span class="lbl">助眠灯光</span>
<strong>{{ dashboard?.sceneConfig?.lightBrightnessPercent || 20 }}% · {{ dashboard?.sceneConfig?.lightColorTemperatureKelvin || 2700 }}K</strong>
<strong>50%渐暗至关闭 · {{ dashboard?.sceneConfig?.lightColorTemperatureKelvin || 2700 }}K</strong>
</div>
<div class="startup-param-badge">
<span class="lbl">助眠音箱</span>
+320 -6
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import {
Button as VanButton,
@@ -11,6 +11,8 @@ import {
import {
healthApi,
type HomeAssistantDiscoveredDevice,
type HomeAssistantLightTransition,
type HomeAssistantSpeakerDemo,
type HomeAssistantStatus
} from '../services/health'
@@ -22,6 +24,12 @@ const settingParam = ref<string | null>(null)
const switchingDeviceId = ref<string | null>(null)
const lastUpdatedAt = ref('')
const actionFeedback = ref<{ type: 'success' | 'fail'; message: string }>()
const lightTransition = ref<HomeAssistantLightTransition>()
const lightActionBusy = ref(false)
const speakerDemo = ref<HomeAssistantSpeakerDemo>()
const speakerDemoBusy = ref(false)
let lightProgressTimer: ReturnType<typeof setInterval> | undefined
let lightPolling = false
const parameterLabels: Record<string, string> = {
humidity: '目标湿度',
@@ -32,6 +40,13 @@ const parameterLabels: Record<string, string> = {
color_temp: '灯光色温',
volume: '音箱音量',
speaker_sleep_mode: '音箱睡眠模式',
fragrance_duration: '出香时长',
fragrance_interval: '出香间隔',
fragrance_light: '香薰氛围灯',
fragrance_quick_release: '快速出香',
fragrance_auto_release: '人来增香',
fragrance_auto_light: '感应夜灯',
fragrance_light_auto_off: '氛围灯自动关闭',
mattress_left_heating: '床垫左侧加热',
mattress_right_heating: '床垫右侧加热',
mattress_left_mode: '床垫左侧承托模式',
@@ -43,10 +58,12 @@ function setFeedback(type: 'success' | 'fail', message: string) {
}
const operationBusy = computed(() =>
loading.value || Boolean(sceneRunning.value) || Boolean(settingParam.value) || Boolean(switchingDeviceId.value)
loading.value || lightActionBusy.value || speakerDemoBusy.value || Boolean(sceneRunning.value) || Boolean(settingParam.value) || Boolean(switchingDeviceId.value)
)
const currentOperationText = computed(() => {
if (speakerDemoBusy.value) return '正在尝试播放示例音频…'
if (lightActionBusy.value) return '正在切换灯光渐变模式…'
if (sceneRunning.value === 'sleep') return '正在启动全屋舒睡模式…'
if (sceneRunning.value === 'all-off') return '正在关闭场景设备…'
if (switchingDeviceId.value) return '正在同步设备开关状态…'
@@ -68,13 +85,21 @@ const localParams = reactive({
dehumidifierUv: false,
// 氛围灯
lightBrightness: 20,
lightBrightness: 50,
lightColorTemp: 2700,
// 音箱
speakerVolume: 15,
speakerSleepMode: true,
// 香薰机
fragranceDuration: 2,
fragranceInterval: 20,
fragranceLight: false,
fragranceAutoRelease: false,
fragranceAutoLight: false,
fragranceLightAutoOff: false,
// 床垫
mattressLeftHeating: 1,
mattressRightHeating: 1,
@@ -116,10 +141,26 @@ async function loadStatus(notify = false) {
if (volume !== undefined) localParams.speakerVolume = clamp(Math.round(volume * 100), 0, 100)
localParams.speakerSleepMode = controls.speaker_sleep_mode?.state === 'on'
localParams.fragranceDuration = clamp(numberValue(controls.fragrance_duration?.state) ?? 2, 2, 6)
localParams.fragranceInterval = clamp(numberValue(controls.fragrance_interval?.state) ?? 20, 10, 20)
localParams.fragranceLight = controls.fragrance_light?.state === 'on'
localParams.fragranceAutoRelease = controls.fragrance_auto_release?.state === 'on'
localParams.fragranceAutoLight = controls.fragrance_auto_light?.state === 'on'
localParams.fragranceLightAutoOff = controls.fragrance_light_auto_off?.state === 'on'
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
await syncLightTransitionStatus()
try {
speakerDemo.value = await healthApi.speakerDemoStatus()
} catch (err) {
speakerDemo.value = {
audioUrl: '', speakerOnline: false, directPlaybackSupported: false, commandAccepted: false,
message: err instanceof Error ? err.message : '音箱能力检测失败'
}
}
lastUpdatedAt.value = new Intl.DateTimeFormat('zh-CN', {
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false
}).format(new Date())
@@ -136,6 +177,85 @@ async function loadStatus(notify = false) {
}
}
async function syncLightTransitionStatus() {
const status = await healthApi.lightTransitionStatus()
lightTransition.value = status
if (status.running) {
localParams.lightBrightness = Math.max(1, status.brightnessPercent)
if (!lightProgressTimer) lightProgressTimer = setInterval(pollLightTransition, 5000)
} else if (lightProgressTimer) {
clearInterval(lightProgressTimer)
lightProgressTimer = undefined
}
}
async function pollLightTransition() {
if (lightPolling) return
lightPolling = true
try {
const wasRunning = lightTransition.value?.running
await syncLightTransitionStatus()
if (wasRunning && !lightTransition.value?.running) {
await loadStatus()
if (lightTransition.value?.message) {
setFeedback(lightTransition.value.success ? 'success' : 'fail', lightTransition.value.message)
}
}
} catch (err) {
setFeedback('fail', err instanceof Error ? err.message : '灯光状态刷新失败')
} finally {
lightPolling = false
}
}
async function controlLightTransition(action: 'sleep' | 'wake' | 'stop') {
lightActionBusy.value = true
const loadingToast = showLoadingToast({
message: action === 'sleep' ? '正在启动助眠渐暗…' : action === 'wake' ? '正在启动晨起唤醒…' : '正在停止灯光渐变…',
forbidClick: true,
duration: 0
})
try {
const result = action === 'sleep'
? await healthApi.startSleepLight()
: action === 'wake'
? await healthApi.startWakeLight()
: await healthApi.stopLightTransition()
if (!result.success) throw new Error(result.message)
lightTransition.value = result
await loadStatus()
setFeedback('success', result.message)
showSuccessToast(result.message)
} catch (err) {
const message = err instanceof Error ? err.message : '灯光操作失败'
setFeedback('fail', message)
showFailToast(message)
} finally {
loadingToast.close()
lightActionBusy.value = false
}
}
async function tryPlaySpeakerDemo() {
if (!speakerDemo.value?.directPlaybackSupported || operationBusy.value) return
speakerDemoBusy.value = true
const loadingToast = showLoadingToast({ message: '正在尝试投放到音箱…', forbidClick: true, duration: 0 })
try {
const result = await healthApi.playSpeakerDemo()
speakerDemo.value = result
if (!result.commandAccepted) throw new Error(result.message)
setFeedback('success', result.message)
showSuccessToast('指令已发送,请确认音箱实际出声')
} catch (err) {
const message = err instanceof Error ? err.message : '投放失败'
setFeedback('fail', message)
showFailToast(message)
} finally {
loadingToast.close()
speakerDemoBusy.value = false
}
}
// 查找具体设备
const dehumidifierDevice = computed(() =>
haStatus.value?.devices.find(d => d.category === 'EnvironmentControl')
@@ -313,6 +433,10 @@ function handleBack() {
onMounted(() => {
loadStatus()
})
onUnmounted(() => {
if (lightProgressTimer) clearInterval(lightProgressTimer)
})
</script>
<template>
@@ -509,10 +633,24 @@ onMounted(() => {
</div>
<div class="params-body">
<div class="light-mode-panel">
<div>
<strong v-if="lightTransition?.running">
{{ lightTransition.mode === 'wake' ? '晨起唤醒中' : '助眠渐暗中' }} · {{ lightTransition.brightnessPercent }}%
</strong>
<strong v-else>渐变灯光</strong>
<small>{{ lightTransition?.message || '开灯从50%逐渐变暗;晨起从当前亮度逐渐变亮至70%' }}</small>
</div>
<div class="light-mode-actions">
<button type="button" :disabled="operationBusy || !lightDevice.online" @click="controlLightTransition('sleep')">🌙 开始助眠渐暗</button>
<button type="button" :disabled="operationBusy || !lightDevice.online" @click="controlLightTransition('wake')">🌅 晨起唤醒</button>
<button v-if="lightTransition?.running" type="button" :disabled="operationBusy" @click="controlLightTransition('stop')">停止渐变</button>
</div>
</div>
<!-- 亮度滑块 -->
<div class="param-row">
<div class="param-header">
<span class="p-title">助眠灯光亮度</span>
<span class="p-title">灯光亮度</span>
<strong class="p-val">{{ localParams.lightBrightness }}%</strong>
</div>
<VanSlider
@@ -577,6 +715,17 @@ onMounted(() => {
</div>
<div class="params-body">
<div class="speaker-demo-panel">
<strong>OSS 音频播放 Demo</strong>
<small>先在手机试听,再检测能否投放到 Sound Pro。</small>
<audio v-if="speakerDemo?.audioUrl" controls preload="none" :src="speakerDemo.audioUrl">
当前浏览器不支持播放音频
</audio>
<p>{{ speakerDemo?.message || '正在检测音箱播放能力…' }}</p>
<button type="button" :disabled="operationBusy || !speakerDemo?.directPlaybackSupported" @click="tryPlaySpeakerDemo">
{{ speakerDemo?.directPlaybackSupported ? '尝试音箱播放' : '当前音箱集成暂不支持投放' }}
</button>
</div>
<!-- 音量滑块 -->
<div class="param-row">
<div class="param-header">
@@ -640,8 +789,103 @@ onMounted(() => {
<div class="params-body">
<div class="param-row">
<span class="p-title">当前可控能力</span>
<p class="p-tip">当前 Home Assistant 集成仅提供香氛机开关与氛围灯控制;浓度请在米家设备端预设。</p>
<div class="param-header">
<span class="p-title">单次出香时长</span>
<strong class="p-val">{{ localParams.fragranceDuration }} 秒</strong>
</div>
<VanSlider
v-model="localParams.fragranceDuration"
:min="2"
:max="6"
:step="1"
:disabled="operationBusy"
@change="applyParam(fragranceDevice.deviceId, 'fragrance_duration', localParams.fragranceDuration)"
/>
<div class="range-labels"><span>2 秒</span><span>4 秒</span><span>6 秒</span></div>
</div>
<div class="param-row">
<div class="param-header">
<span class="p-title">自动出香间隔</span>
<strong class="p-val">{{ localParams.fragranceInterval }} 分钟</strong>
</div>
<VanSlider
v-model="localParams.fragranceInterval"
:min="10"
:max="20"
:step="5"
:disabled="operationBusy"
@change="applyParam(fragranceDevice.deviceId, 'fragrance_interval', localParams.fragranceInterval)"
/>
<div class="range-labels"><span>10 分钟</span><span>15 分钟</span><span>20 分钟</span></div>
</div>
<div class="param-row inline-row">
<div>
<div class="p-title">香薰氛围灯</div>
<small class="p-tip">与香薰释放独立控制</small>
</div>
<button
class="mini-toggle"
:class="{ active: localParams.fragranceLight }"
:disabled="operationBusy"
@click="localParams.fragranceLight = !localParams.fragranceLight; applyParam(fragranceDevice.deviceId, 'fragrance_light', localParams.fragranceLight)"
>{{ localParams.fragranceLight ? '已开启' : '已关闭' }}</button>
</div>
<div class="param-row inline-row">
<div>
<div class="p-title">人来增香</div>
<small class="p-tip">检测到有人活动时自动增香</small>
</div>
<button
class="mini-toggle"
:class="{ active: localParams.fragranceAutoRelease }"
:disabled="operationBusy"
@click="localParams.fragranceAutoRelease = !localParams.fragranceAutoRelease; applyParam(fragranceDevice.deviceId, 'fragrance_auto_release', localParams.fragranceAutoRelease)"
>{{ localParams.fragranceAutoRelease ? '已开启' : '已关闭' }}</button>
</div>
<div class="param-row inline-row">
<div>
<div class="p-title">感应夜灯</div>
<small class="p-tip">有人移动时自动开启柔光</small>
</div>
<button
class="mini-toggle"
:class="{ active: localParams.fragranceAutoLight }"
:disabled="operationBusy"
@click="localParams.fragranceAutoLight = !localParams.fragranceAutoLight; applyParam(fragranceDevice.deviceId, 'fragrance_auto_light', localParams.fragranceAutoLight)"
>{{ localParams.fragranceAutoLight ? '已开启' : '已关闭' }}</button>
</div>
<div class="param-row inline-row">
<div>
<div class="p-title">无人时自动关灯</div>
<small class="p-tip">关闭时氛围灯保持常亮</small>
</div>
<button
class="mini-toggle"
:class="{ active: localParams.fragranceLightAutoOff }"
:disabled="operationBusy"
@click="localParams.fragranceLightAutoOff = !localParams.fragranceLightAutoOff; applyParam(fragranceDevice.deviceId, 'fragrance_light_auto_off', localParams.fragranceLightAutoOff)"
>{{ localParams.fragranceLightAutoOff ? '已开启' : '已关闭' }}</button>
</div>
<div class="param-row inline-row">
<div>
<div class="p-title">立即出香</div>
<small class="p-tip">手动触发一次当前时长的香氛释放</small>
</div>
<VanButton
size="small"
round
plain
color="#0c5045"
:loading="settingParam === `${fragranceDevice.deviceId}_fragrance_quick_release`"
:disabled="operationBusy"
@click="applyParam(fragranceDevice.deviceId, 'fragrance_quick_release', true)"
>快速出香</VanButton>
</div>
</div>
</section>
@@ -1123,6 +1367,76 @@ onMounted(() => {
line-height: 1.55;
}
.light-mode-panel {
padding: 12px;
border-radius: 12px;
background: #fff8ec;
border: 1px solid #f2e3c7;
}
.light-mode-panel strong,
.light-mode-panel small {
display: block;
}
.light-mode-panel strong {
color: #684b24;
font-size: 13px;
}
.light-mode-panel small {
margin-top: 4px;
color: #8b7759;
font-size: 11px;
line-height: 1.5;
}
.light-mode-actions {
display: flex;
gap: 8px;
margin-top: 11px;
}
.light-mode-actions button {
flex: 1;
padding: 8px 10px;
border: 1px solid #dab989;
border-radius: 9px;
background: #fff;
color: #795320;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.light-mode-actions button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.speaker-demo-panel {
padding: 13px;
border-radius: 12px;
background: #f3f8f7;
border: 1px solid #d9e9e5;
}
.speaker-demo-panel strong,
.speaker-demo-panel small,
.speaker-demo-panel audio {
display: block;
}
.speaker-demo-panel strong { font-size: 13px; color: #174d44; }
.speaker-demo-panel small { margin-top: 4px; color: #647b75; font-size: 11px; }
.speaker-demo-panel audio { width: 100%; margin-top: 10px; height: 38px; }
.speaker-demo-panel p { margin: 10px 0; color: #526a64; font-size: 11px; line-height: 1.5; }
.speaker-demo-panel button {
width: 100%; padding: 9px 10px; border: 0; border-radius: 9px;
background: #0c5045; color: #fff; font-size: 12px; font-weight: 600;
}
.speaker-demo-panel button:disabled { background: #b8c5c1; }
/* 滑块样式:左右各预留 10px 间距,使滑块在 0% 和 100% 端点时,外凸的圆形按钮完整居中且不被裁剪 */
.param-row :deep(.van-slider) {
margin: 10px 10px 14px 10px;