111
This commit is contained in:
@@ -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: () =>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user