1
This commit is contained in:
@@ -27,10 +27,11 @@ function navigate(target: string) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="app-tab-bar">
|
||||
<div
|
||||
<nav class="app-tab-bar" aria-label="主导航">
|
||||
<button type="button"
|
||||
class="tab-item"
|
||||
:class="{ active: currentTab === 'home' }"
|
||||
:aria-current="currentTab === 'home' ? 'page' : undefined"
|
||||
@click="navigate('home')"
|
||||
>
|
||||
<div class="tab-icon">
|
||||
@@ -40,11 +41,12 @@ function navigate(target: string) {
|
||||
</svg>
|
||||
</div>
|
||||
<span class="tab-label">首页</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div
|
||||
<button type="button"
|
||||
class="tab-item"
|
||||
:class="{ active: currentTab === 'tcm' }"
|
||||
:aria-current="currentTab === 'tcm' ? 'page' : undefined"
|
||||
@click="navigate('tcm')"
|
||||
>
|
||||
<div class="tab-icon">
|
||||
@@ -56,11 +58,12 @@ function navigate(target: string) {
|
||||
</svg>
|
||||
</div>
|
||||
<span class="tab-label">中医四诊</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div
|
||||
<button type="button"
|
||||
class="tab-item"
|
||||
:class="{ active: currentTab === 'assessment' }"
|
||||
:aria-current="currentTab === 'assessment' ? 'page' : undefined"
|
||||
@click="navigate('assessment')"
|
||||
>
|
||||
<div class="tab-icon">
|
||||
@@ -73,11 +76,12 @@ function navigate(target: string) {
|
||||
</svg>
|
||||
</div>
|
||||
<span class="tab-label">健康评估</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div
|
||||
<button type="button"
|
||||
class="tab-item"
|
||||
:class="{ active: currentTab === 'profile' }"
|
||||
:aria-current="currentTab === 'profile' ? 'page' : undefined"
|
||||
@click="navigate('profile')"
|
||||
>
|
||||
<div class="tab-icon">
|
||||
@@ -87,7 +91,7 @@ function navigate(target: string) {
|
||||
</svg>
|
||||
</div>
|
||||
<span class="tab-label">我的</span>
|
||||
</div>
|
||||
</button>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
@@ -116,6 +120,9 @@ function navigate(target: string) {
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, toRefs } from 'vue'
|
||||
import { Button as VanButton, Slider as VanSlider } from 'vant'
|
||||
import type { HomeAssistantDiscoveredDevice } from '../../services/health'
|
||||
import { useDeviceControlContext } from '../../devices/controlContext'
|
||||
import { useDeviceCardState } from '../../devices/useDeviceCardState'
|
||||
import { primaryDeviceEntity } from '../../devices/deviceSummary'
|
||||
const props = defineProps<{ device: HomeAssistantDiscoveredDevice }>()
|
||||
const { device: speakerDevice } = toRefs(props)
|
||||
const { localParams, operationBusy, switchingDeviceId, applyParam, controlSpeaker, musicTransition, loading } = useDeviceControlContext()
|
||||
const state = useDeviceCardState(() => props.device)
|
||||
// MiAirX 独立于 HA 实体在线状态,但仍需已确认属于该音箱的主控实体。
|
||||
const gatewayAvailable = computed(() => primaryDeviceEntity(props.device)?.domain === 'media_player')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="device-card" v-if="speakerDevice">
|
||||
<div class="device-head">
|
||||
<div class="dev-info">
|
||||
<span class="category-tag">睡眠音频</span>
|
||||
<h3>{{ speakerDevice.name }}</h3>
|
||||
<span class="status-dot" :class="{ online: speakerDevice.online }">
|
||||
{{ speakerDevice.online ? 'HA 实体在线' : 'HA 实体离线' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="speaker-actions">
|
||||
<VanButton
|
||||
size="small"
|
||||
round
|
||||
type="primary"
|
||||
color="#0c5045"
|
||||
:loading="switchingDeviceId === speakerDevice.deviceId"
|
||||
:disabled="operationBusy || !gatewayAvailable"
|
||||
@click="controlSpeaker(speakerDevice, true)"
|
||||
>播放测试音乐</VanButton>
|
||||
<VanButton
|
||||
size="small"
|
||||
round
|
||||
:disabled="operationBusy || !gatewayAvailable"
|
||||
@click="controlSpeaker(speakerDevice, false)"
|
||||
>停止播放</VanButton>
|
||||
</div>
|
||||
<small class="p-tip">手动按钮播放测试音频;一键开启按体质选曲循环播放,音量自动渐弱至停止</small>
|
||||
|
||||
<div class="params-body">
|
||||
<!-- 音量滑块 -->
|
||||
<div class="param-row">
|
||||
<div class="param-header">
|
||||
<span class="p-title">音箱音量(MiAirX 已下发)</span>
|
||||
<strong class="p-val">{{ musicTransition?.hasVolume ? `${localParams.speakerVolume}%` : state.display('volume', localParams.speakerVolume, '%') }}</strong>
|
||||
</div>
|
||||
<VanSlider
|
||||
v-model="localParams.speakerVolume"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:disabled="operationBusy || !gatewayAvailable"
|
||||
active-color="#0c5045"
|
||||
@change="applyParam(speakerDevice.deviceId, 'volume', localParams.speakerVolume)"
|
||||
/>
|
||||
<div class="slider-scale">
|
||||
<span>0% 静音</span>
|
||||
<span>50% 适中聆听</span>
|
||||
<span>100% 满格音量</span>
|
||||
</div>
|
||||
<small class="p-tip">{{ musicTransition?.running ? '自动渐弱中,每 5 秒更新一次显示' : '手动调整音量会停止本次自动渐弱' }}</small>
|
||||
</div>
|
||||
|
||||
<div class="param-row inline-row">
|
||||
<div>
|
||||
<div class="p-title">音箱睡眠模式</div>
|
||||
<small class="p-tip">睡眠模式由 Home Assistant 单独控制</small>
|
||||
</div>
|
||||
<button
|
||||
class="mini-toggle"
|
||||
:class="{ active: state.readable('speaker_sleep_mode') && localParams.speakerSleepMode }"
|
||||
:disabled="operationBusy || !state.available('speaker_sleep_mode')"
|
||||
@click="localParams.speakerSleepMode = !localParams.speakerSleepMode; applyParam(speakerDevice.deviceId, 'speaker_sleep_mode', localParams.speakerSleepMode)"
|
||||
>
|
||||
{{ state.display('speaker_sleep_mode', localParams.speakerSleepMode ? '已开启' : '已关闭') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,85 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Button as VanButton, Slider as VanSlider } from 'vant'
|
||||
import type { HomeAssistantDiscoveredDevice } from '../../services/health'
|
||||
import { useDeviceControlContext } from '../../devices/controlContext'
|
||||
import { useDeviceCardState } from '../../devices/useDeviceCardState'
|
||||
import { climateCapabilities, climateModeLabels } from '../../devices/climate/capabilities'
|
||||
const props = defineProps<{ device: HomeAssistantDiscoveredDevice }>()
|
||||
const { localParams, operationBusy, switchingDeviceId, isDeviceTurnedOn, toggleDevice, applyParam } = useDeviceControlContext()
|
||||
const state = useDeviceCardState(() => props.device)
|
||||
const capability = computed(() => climateCapabilities(props.device))
|
||||
const temperatureValue = computed({
|
||||
get: () => localParams.climateTemperature ?? capability.value.targetTemperature ?? 0,
|
||||
set: (value: number) => { localParams.climateTemperature = value },
|
||||
})
|
||||
const temperatureReadable = computed(() => capability.value.available && state.readable('climate_temperature') && capability.value.targetTemperature !== undefined)
|
||||
function setMode(mode: string) {
|
||||
if (operationBusy.value || !capability.value.available || !state.available('climate_mode') || !capability.value.modes.includes(mode)) return
|
||||
void applyParam(props.device.deviceId, 'climate_mode', mode)
|
||||
}
|
||||
function setTemperature() {
|
||||
const range = capability.value.range, value = temperatureValue.value
|
||||
if (operationBusy.value || !capability.value.canSetTemperature || !state.available('climate_temperature') || !range || !Number.isFinite(value) || value < range.min || value > range.max) return
|
||||
const steps = (value - range.min) / range.step
|
||||
if (Math.abs(steps - Math.round(steps)) > 0.000001) return
|
||||
void applyParam(props.device.deviceId, 'climate_temperature', value)
|
||||
}
|
||||
function setFan(mode: string) {
|
||||
if (operationBusy.value || !capability.value.canSetFan || !state.available('climate_fan_mode') || !capability.value.fans.includes(mode)) return
|
||||
void applyParam(props.device.deviceId, 'climate_fan_mode', mode)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="device-card">
|
||||
<div class="device-head">
|
||||
<div class="dev-info">
|
||||
<span class="category-tag">室温调节</span>
|
||||
<h3>{{ device.name }}</h3>
|
||||
<span class="status-dot" :class="{ online: capability.available }">{{ capability.available ? '空调可用' : '空调当前不可用' }}</span>
|
||||
</div>
|
||||
<VanButton size="small" round :type="isDeviceTurnedOn(device) ? 'primary' : 'default'"
|
||||
:loading="switchingDeviceId === device.deviceId" :disabled="operationBusy || !capability.canToggle"
|
||||
@click="toggleDevice(device)">
|
||||
{{ capability.available ? isDeviceTurnedOn(device) ? '已开启' : '已关闭' : '暂不可用' }}
|
||||
</VanButton>
|
||||
</div>
|
||||
<div class="params-body">
|
||||
<div class="sensor-grid climate-readings">
|
||||
<div class="sensor-box"><span class="s-label">当前室温</span><strong>{{ capability.available && capability.currentTemperature !== undefined ? `${capability.currentTemperature}${capability.temperatureUnit}` : '--' }}</strong></div>
|
||||
<div class="sensor-box"><span class="s-label">目标温度</span><strong>{{ temperatureReadable ? `${capability.targetTemperature}${capability.temperatureUnit}` : '--' }}</strong></div>
|
||||
</div>
|
||||
<div class="param-row">
|
||||
<div class="param-header"><span class="p-title">空调运行模式</span><strong class="p-val">{{ capability.available && state.readable('climate_mode') ? climateModeLabels[capability.entity!.state] : '--' }}</strong></div>
|
||||
<div v-if="capability.modes.length" class="btn-group device-mode-buttons">
|
||||
<button v-for="mode in capability.modes" :key="mode" type="button"
|
||||
:class="{ active: capability.available && state.readable('climate_mode') && capability.entity?.state === mode }"
|
||||
:aria-pressed="capability.available && state.readable('climate_mode') && capability.entity?.state === mode"
|
||||
:disabled="operationBusy || !capability.available || !state.available('climate_mode')" @click="setMode(mode)">{{ climateModeLabels[mode] }}</button>
|
||||
</div>
|
||||
<p v-else class="scale-note">运行模式暂未回读,请刷新后再试。</p>
|
||||
</div>
|
||||
<div v-if="capability.supports(1)" class="param-row">
|
||||
<div class="param-header"><span class="p-title">空调目标温度</span><strong class="p-val">{{ temperatureReadable ? `${capability.targetTemperature}${capability.temperatureUnit}` : '--' }}</strong></div>
|
||||
<template v-if="capability.range && temperatureReadable">
|
||||
<VanSlider v-model="temperatureValue" :min="capability.range.min" :max="capability.range.max" :step="capability.range.step"
|
||||
:disabled="operationBusy || !capability.canSetTemperature || !state.available('climate_temperature')" active-color="#0c5045" @change="setTemperature" />
|
||||
<div class="slider-scale"><span>{{ capability.range.min }}{{ capability.temperatureUnit }}</span><span>每档 {{ capability.range.step }}{{ capability.temperatureUnit }}</span><span>{{ capability.range.max }}{{ capability.temperatureUnit }}</span></div>
|
||||
<p v-if="capability.temperatureUnit === '℉'" class="scale-note">当前回读为华氏温度,此版仅支持摄氏温度调节。</p>
|
||||
</template>
|
||||
<p v-else class="scale-note">目标温度、温度单位或调节范围暂未回读,不显示默认温度。</p>
|
||||
</div>
|
||||
<div v-if="capability.supports(8) && capability.fans.length" class="param-row">
|
||||
<div class="param-header"><span class="p-title">空调风速</span><strong class="p-val">{{ capability.available && state.readable('climate_fan_mode') ? capability.fanMode || '--' : '--' }}</strong></div>
|
||||
<div class="btn-group device-mode-buttons">
|
||||
<button v-for="fan in capability.fans" :key="fan" type="button"
|
||||
:class="{ active: capability.available && state.readable('climate_fan_mode') && capability.fanMode === fan }"
|
||||
:aria-pressed="capability.available && state.readable('climate_fan_mode') && capability.fanMode === fan"
|
||||
:disabled="operationBusy || !capability.canSetFan || !state.available('climate_fan_mode')" @click="setFan(fan)">{{ fan }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="scale-note">仅显示设备实际支持的模式与范围;空调一键场景按室内温度建议执行。</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Slider as VanSlider } from 'vant'
|
||||
import type { HomeAssistantDiscoveredDevice } from '../../services/health'
|
||||
import { useDeviceControlContext } from '../../devices/controlContext'
|
||||
import { useDeviceCardState } from '../../devices/useDeviceCardState'
|
||||
import { curtainCapabilities, curtainStateLabels, type CurtainAction } from '../../devices/curtain/capabilities'
|
||||
const props = defineProps<{ device: HomeAssistantDiscoveredDevice }>()
|
||||
const { localParams, operationBusy, applyParam } = useDeviceControlContext()
|
||||
const state = useDeviceCardState(() => props.device)
|
||||
const capability = computed(() => curtainCapabilities(props.device))
|
||||
const actions: { value: CurtainAction; label: string }[] = [{ value: 'open', label: '打开' }, { value: 'stop', label: '停止' }, { value: 'close', label: '关闭' }]
|
||||
const positionReadable = computed(() => capability.value.available && state.readable('curtain_position') && capability.value.position !== undefined)
|
||||
const positionValue = computed({ get: () => localParams.curtainPosition ?? capability.value.position ?? 0,
|
||||
set: (value: number) => { localParams.curtainPosition = value } })
|
||||
function setAction(action: CurtainAction) {
|
||||
if (operationBusy.value || !capability.value.canAction(action) || !state.available('curtain_action')) return
|
||||
void applyParam(props.device.deviceId, 'curtain_action', action)
|
||||
}
|
||||
function setPosition() {
|
||||
const value = positionValue.value
|
||||
if (operationBusy.value || !capability.value.canSetPosition || !state.available('curtain_position') || !Number.isInteger(value) || value < 0 || value > 100) return
|
||||
void applyParam(props.device.deviceId, 'curtain_position', value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="device-card">
|
||||
<div class="device-head">
|
||||
<div class="dev-info">
|
||||
<span class="category-tag">窗帘控制</span><h3>{{ device.name }}</h3>
|
||||
<span class="status-dot" :class="{ online: capability.available }">{{ capability.available ? curtainStateLabels[capability.entity!.state] : '窗帘当前不可用' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="params-body">
|
||||
<div class="param-row">
|
||||
<span class="p-title">窗帘开合</span>
|
||||
<div class="btn-group device-mode-buttons curtain-actions">
|
||||
<button v-for="action in actions" :key="action.value" type="button"
|
||||
:disabled="operationBusy || !capability.canAction(action.value) || !state.available('curtain_action')"
|
||||
@click="setAction(action.value)">{{ action.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="capability.supports(4)" class="param-row">
|
||||
<div class="param-header"><span class="p-title">窗帘开度</span><strong class="p-val">{{ positionReadable ? `${capability.position}%` : '--' }}</strong></div>
|
||||
<template v-if="positionReadable">
|
||||
<VanSlider v-model="positionValue" :min="0" :max="100" :step="1"
|
||||
:disabled="operationBusy || !capability.canSetPosition || !state.available('curtain_position')" active-color="#0c5045" @change="setPosition" />
|
||||
<div class="slider-scale"><span>0% 完全关闭</span><span>100% 完全打开</span></div>
|
||||
</template>
|
||||
<p v-else class="scale-note">开度暂未回读,不显示默认位置。设备恢复可用后可使用打开、停止、关闭。</p>
|
||||
</div>
|
||||
<p v-if="!capability.available" class="scale-note">Home Assistant 未能回读窗帘主控状态。请先确认米家中可操作,再检查集成连接;这里不会把未知状态当作已关闭。</p>
|
||||
<p class="scale-note">此版先提供单独控制,暂不随一键场景自动开合。不包含反向、校准或清除行程操作。</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,106 @@
|
||||
<script setup lang="ts">
|
||||
import { toRefs } from 'vue'
|
||||
import { Button as VanButton, Slider as VanSlider } from 'vant'
|
||||
import type { HomeAssistantDiscoveredDevice } from '../../services/health'
|
||||
import { useDeviceControlContext } from '../../devices/controlContext'
|
||||
import { useDeviceCardState } from '../../devices/useDeviceCardState'
|
||||
const props = defineProps<{ device: HomeAssistantDiscoveredDevice }>()
|
||||
const { device: dehumidifierDevice } = toRefs(props)
|
||||
const { localParams, operationBusy, switchingDeviceId, isDeviceTurnedOn, toggleDevice, applyParam, loading } = useDeviceControlContext()
|
||||
const state = useDeviceCardState(() => props.device)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="device-card" v-if="dehumidifierDevice">
|
||||
<div class="device-head">
|
||||
<div class="dev-info">
|
||||
<span class="category-tag">恒湿净化</span>
|
||||
<h3>{{ dehumidifierDevice.name }}</h3>
|
||||
<span class="status-dot" :class="{ online: dehumidifierDevice.online }">
|
||||
{{ dehumidifierDevice.online ? '设备在线' : '设备离线' }}
|
||||
</span>
|
||||
</div>
|
||||
<VanButton
|
||||
size="small"
|
||||
round
|
||||
:type="isDeviceTurnedOn(dehumidifierDevice) ? 'primary' : 'default'"
|
||||
:color="isDeviceTurnedOn(dehumidifierDevice) ? '#0c5045' : ''"
|
||||
:loading="switchingDeviceId === dehumidifierDevice.deviceId"
|
||||
:disabled="operationBusy || !state.powerAvailable()"
|
||||
@click="toggleDevice(dehumidifierDevice)"
|
||||
>
|
||||
{{ state.powerLabel('已开启', '已关闭') }}
|
||||
</VanButton>
|
||||
</div>
|
||||
|
||||
<div class="params-body">
|
||||
<!-- 目标湿度滑块 -->
|
||||
<div class="param-row">
|
||||
<div class="param-header">
|
||||
<span class="p-title">恒湿目标湿度</span>
|
||||
<strong class="p-val">{{ state.display('humidity', localParams.dehumidifierHumidity, '%') }}</strong>
|
||||
</div>
|
||||
<VanSlider
|
||||
v-model="localParams.dehumidifierHumidity"
|
||||
:min="40"
|
||||
:max="70"
|
||||
:step="1"
|
||||
:disabled="operationBusy || !state.available('humidity')"
|
||||
active-color="#0c5045"
|
||||
@change="applyParam(dehumidifierDevice.deviceId, 'humidity', localParams.dehumidifierHumidity)"
|
||||
/>
|
||||
<div class="slider-scale">
|
||||
<span>40% 干爽</span>
|
||||
<span>55% 舒适</span>
|
||||
<span>70% 润泽</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 运行模式切换 -->
|
||||
<div class="param-row">
|
||||
<span class="p-title">除湿运行模式</span>
|
||||
<div class="btn-group">
|
||||
<button
|
||||
v-for="mode in ['睡眠', '智能']"
|
||||
:key="mode"
|
||||
:class="{ active: state.readable('dehumidifier_mode') && localParams.dehumidifierMode === mode }"
|
||||
:disabled="operationBusy || !state.available('dehumidifier_mode')"
|
||||
@click="localParams.dehumidifierMode = mode; applyParam(dehumidifierDevice.deviceId, 'dehumidifier_mode', mode)"
|
||||
>{{ mode }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 风速切换 -->
|
||||
<div class="param-row">
|
||||
<span class="p-title">除湿机风速档位</span>
|
||||
<div class="btn-group">
|
||||
<button
|
||||
v-for="fan in ['1挡', '2挡', '3挡']"
|
||||
:key="fan"
|
||||
:class="{ active: state.readable('fan_level') && localParams.dehumidifierFan === fan }"
|
||||
:disabled="operationBusy || !state.available('fan_level')"
|
||||
@click="localParams.dehumidifierFan = fan; applyParam(dehumidifierDevice.deviceId, 'fan_level', fan)"
|
||||
>
|
||||
{{ fan }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- UV 杀菌开关 -->
|
||||
<div class="param-row inline-row">
|
||||
<div>
|
||||
<div class="p-title">UV 深度紫外消杀</div>
|
||||
<small class="p-tip">夜间杀菌净化,除湿同时清新空气</small>
|
||||
</div>
|
||||
<button
|
||||
class="mini-toggle"
|
||||
:class="{ active: state.readable('uv') && localParams.dehumidifierUv }"
|
||||
:disabled="operationBusy || !state.available('uv')"
|
||||
@click="localParams.dehumidifierUv = !localParams.dehumidifierUv; applyParam(dehumidifierDevice.deviceId, 'uv', localParams.dehumidifierUv)"
|
||||
>
|
||||
{{ state.display('uv', localParams.dehumidifierUv ? '已开启' : '已关闭') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import type { DeviceIconKind } from '../../devices/deviceSummary'
|
||||
defineProps<{ kind: DeviceIconKind }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg class="device-glyph" viewBox="0 0 80 80" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<g v-if="kind === 'dehumidifier'">
|
||||
<rect x="22" y="9" width="36" height="60" rx="9" fill="#edf3f0" />
|
||||
<path d="M28 22h24M28 28h24M28 34h24" opacity=".45" />
|
||||
<path d="M40 43c-3 4-6 7-6 10a6 6 0 0 0 12 0c0-3-3-6-6-10Z" fill="#bfd7cd" />
|
||||
<path d="M29 70v3m22-3v3" />
|
||||
</g>
|
||||
<g v-else-if="kind === 'lighting'">
|
||||
<circle cx="40" cy="35" r="20" fill="#f9edcf" stroke="#a98446" />
|
||||
<circle cx="40" cy="35" r="10" stroke="#a98446" /><path d="M33 56h14M35 61h10M37 66h6" stroke="#a98446" />
|
||||
<path d="M40 5v5M12 35h5M63 35h5M20 14l4 4m32-4-4 4" stroke="#d1b476" />
|
||||
</g>
|
||||
<g v-else-if="kind === 'audio'">
|
||||
<rect x="22" y="11" width="36" height="58" rx="12" fill="#263e3a" />
|
||||
<path d="M27 20h26" stroke="#7aafc2" stroke-width="3" />
|
||||
<circle cx="40" cy="38" r="8" stroke="#819b92" /><circle cx="40" cy="57" r="5" stroke="#819b92" />
|
||||
</g>
|
||||
<g v-else-if="kind === 'fragrance'">
|
||||
<path d="M29 25h22l6 39c0 7-34 7-34 0l6-39Z" fill="#f6f3eb" /><ellipse cx="40" cy="25" rx="11" ry="4" fill="#e3d9c3" />
|
||||
<path d="M37 18c-6-5 6-8 0-13m8 13c-6-5 6-8 0-13" stroke="#91b5a7" /><path d="M29 59h22" stroke="#bdcfc6" />
|
||||
</g>
|
||||
<g v-else-if="kind === 'mattress'">
|
||||
<path d="m11 44 27-23 32 20-28 25-31-22Z" fill="#d5e2dc" /><path d="M11 44v9l31 21 28-25v-8M42 66v8" />
|
||||
<path d="m23 35 31 20m-22-27 31 20" stroke="#a3bdb0" />
|
||||
</g>
|
||||
<g v-else-if="kind === 'environment' || kind === 'sleep-monitor'">
|
||||
<rect x="12" y="20" width="56" height="41" rx="9" fill="#e4ede8" /><rect x="18" y="26" width="44" height="28" rx="4" fill="#173e35" />
|
||||
<path d="m22 43 7-3 5 7 7-13 6 6h10" stroke="#a8d4bf" /><path d="M27 66h26" />
|
||||
</g>
|
||||
<g v-else-if="kind === 'scale'">
|
||||
<rect x="14" y="14" width="52" height="52" rx="13" fill="#edf2ee" /><rect x="30" y="23" width="20" height="10" rx="3" fill="#b3cbbf" />
|
||||
<circle cx="25" cy="46" r="5" stroke="#b3cbbf" /><circle cx="55" cy="46" r="5" stroke="#b3cbbf" />
|
||||
</g>
|
||||
<g v-else-if="kind === 'climate'">
|
||||
<rect x="8" y="20" width="64" height="30" rx="7" fill="#edf2ee" /><path d="M14 40h52m-52 5h52M27 55v9m13-9v14m13-14v9" /><circle cx="60" cy="28" r="2" fill="#87b29e" stroke="none" />
|
||||
</g>
|
||||
<g v-else-if="kind === 'curtain'">
|
||||
<path d="M10 15h60M15 16v50l20-6V16m30 0v50l-20-6V16" fill="#d9e6df" /><path d="M23 18v44m9-44v41m17-41v41m8-41v44" stroke="#a5c0b1" />
|
||||
</g>
|
||||
<g v-else-if="kind === 'outlet'">
|
||||
<rect x="17" y="12" width="46" height="56" rx="12" fill="#e6eee9" />
|
||||
<circle cx="40" cy="36" r="14" /><path d="M33 31v7m14-7v7m-7 3v5" />
|
||||
<circle cx="40" cy="59" r="2" fill="#87b29e" stroke="none" />
|
||||
</g>
|
||||
<g v-else>
|
||||
<rect x="17" y="15" width="46" height="50" rx="11" fill="#e6eee9" /><circle cx="40" cy="40" r="11" /><path d="M40 34v12m-6-6h12" />
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { useDeviceControlContext } from '../../devices/controlContext'
|
||||
const { actionFeedback, operationBusy, currentOperationText, lastUpdatedAt } = useDeviceControlContext()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="operation-status" :class="actionFeedback?.type" role="status" aria-live="polite">
|
||||
<span class="operation-message">
|
||||
<i v-if="operationBusy" class="operation-spinner" aria-hidden="true"></i>
|
||||
<i v-else-if="actionFeedback?.type === 'success'" class="operation-result" aria-hidden="true">✓</i>
|
||||
<i v-else-if="actionFeedback?.type === 'fail'" class="operation-result" aria-hidden="true">!</i>
|
||||
{{ currentOperationText || actionFeedback?.message || '设备状态会自动更新' }}
|
||||
</span>
|
||||
<small v-if="lastUpdatedAt">{{ lastUpdatedAt }}</small>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { HomeAssistantDiscoveredDevice } from '../../services/health'
|
||||
import type { DeviceSummary } from '../../devices/deviceSummary'
|
||||
import DeviceGlyph from './DeviceGlyph.vue'
|
||||
const props = defineProps<{ device: HomeAssistantDiscoveredDevice; summary: DeviceSummary; busy: boolean; switching: boolean }>()
|
||||
defineEmits<{ open: []; power: [] }>()
|
||||
const powerLabel = computed(() => props.switching ? '操作中' : props.summary.powerOn ? props.summary.audioControl ? '停止播放' : '关闭设备'
|
||||
: props.summary.audioControl ? '开始播放' : '开启设备')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="device-overview-tile" :class="{ offline: !device.online, powered: summary.powerOn === true }">
|
||||
<button class="device-tile-open" type="button" :aria-label="`查看${device.name}详情`" @click="$emit('open')">
|
||||
<span class="device-tile-category">{{ summary.categoryLabel }}</span>
|
||||
<DeviceGlyph :kind="summary.icon" />
|
||||
<strong class="device-tile-name">{{ device.name }}</strong>
|
||||
<span class="device-tile-status"><i :class="{ online: device.online }"></i>{{ summary.statusText }}</span>
|
||||
<span class="device-tile-metric">{{ summary.metricText }}</span>
|
||||
<span class="device-tile-footer">{{ device.areaName || '设备详情' }}<span aria-hidden="true">›</span></span>
|
||||
</button>
|
||||
<button v-if="summary.canToggle" class="device-tile-power" type="button" :class="{ on: summary.powerOn }"
|
||||
:disabled="busy || switching" :aria-label="`${device.name}:${powerLabel}`" :title="powerLabel"
|
||||
:aria-pressed="summary.powerOn === true" @click.stop="$emit('power')">
|
||||
<span v-if="switching" class="operation-spinner" aria-hidden="true"></span>
|
||||
<svg v-else viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
|
||||
<path d="M12 3v9M6.5 5.7a8 8 0 1 0 11 0" />
|
||||
</svg>
|
||||
</button>
|
||||
</article>
|
||||
</template>
|
||||
@@ -0,0 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
import { toRefs } from 'vue'
|
||||
import type { HomeAssistantDiscoveredDevice } from '../../services/health'
|
||||
import { useDeviceControlContext } from '../../devices/controlContext'
|
||||
import { numberValue } from '../../devices/parameterState'
|
||||
const props = defineProps<{ device: HomeAssistantDiscoveredDevice }>()
|
||||
const { device: sensorDevice } = toRefs(props)
|
||||
const { operationBusy, loadStatus, loading } = useDeviceControlContext()
|
||||
const metric = (key: string) => props.device.online ? numberValue(props.device.metrics?.[key]) ?? '--' : '--'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="device-card" v-if="sensorDevice">
|
||||
<div class="device-head">
|
||||
<div class="dev-info">
|
||||
<span class="category-tag">环境质量监控</span>
|
||||
<h3>{{ sensorDevice.name }}</h3>
|
||||
<span class="status-dot" :class="{ online: sensorDevice.online }">
|
||||
{{ sensorDevice.online ? '实时采集正常' : '设备离线' }}
|
||||
</span>
|
||||
</div>
|
||||
<button class="text-refresh" :disabled="operationBusy" @click="loadStatus(true)">
|
||||
{{ loading ? '刷新中…' : '手动刷新指标' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sensor-grid">
|
||||
<div class="sensor-box">
|
||||
<span class="s-label">温度</span>
|
||||
<strong>{{ metric('temperature') }} <small>°C</small></strong>
|
||||
</div>
|
||||
<div class="sensor-box">
|
||||
<span class="s-label">湿度</span>
|
||||
<strong>{{ metric('humidity') }} <small>%</small></strong>
|
||||
</div>
|
||||
<div class="sensor-box">
|
||||
<span class="s-label">PM2.5</span>
|
||||
<strong>{{ metric('pm25') }} <small>μg/m³</small></strong>
|
||||
</div>
|
||||
<div class="sensor-box">
|
||||
<span class="s-label">CO2 浓度</span>
|
||||
<strong>{{ metric('co2') }} <small>ppm</small></strong>
|
||||
</div>
|
||||
<div class="sensor-box">
|
||||
<span class="s-label">甲醛 HCHO</span>
|
||||
<strong>{{ metric('hcho') }} <small>mg/m³</small></strong>
|
||||
</div>
|
||||
<div class="sensor-box">
|
||||
<span class="s-label">TVOC</span>
|
||||
<strong>{{ metric('tvoc') }} <small>mg/m³</small></strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,140 @@
|
||||
<script setup lang="ts">
|
||||
import { toRefs } from 'vue'
|
||||
import { Button as VanButton, Slider as VanSlider } from 'vant'
|
||||
import type { HomeAssistantDiscoveredDevice } from '../../services/health'
|
||||
import { useDeviceControlContext } from '../../devices/controlContext'
|
||||
import { useDeviceCardState } from '../../devices/useDeviceCardState'
|
||||
const props = defineProps<{ device: HomeAssistantDiscoveredDevice }>()
|
||||
const { device: fragranceDevice } = toRefs(props)
|
||||
const { localParams, operationBusy, switchingDeviceId, isDeviceTurnedOn, toggleDevice, applyParam, settingParam } = useDeviceControlContext()
|
||||
const state = useDeviceCardState(() => props.device)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="device-card" v-if="fragranceDevice">
|
||||
<div class="device-head">
|
||||
<div class="dev-info">
|
||||
<span class="category-tag">香氛助眠</span>
|
||||
<h3>{{ fragranceDevice.name }}</h3>
|
||||
<span class="status-dot" :class="{ online: fragranceDevice.online }">
|
||||
{{ fragranceDevice.online ? '设备在线' : '设备离线' }}
|
||||
</span>
|
||||
</div>
|
||||
<VanButton
|
||||
size="small"
|
||||
round
|
||||
:type="isDeviceTurnedOn(fragranceDevice) ? 'primary' : 'default'"
|
||||
:color="isDeviceTurnedOn(fragranceDevice) ? '#0c5045' : ''"
|
||||
:loading="switchingDeviceId === fragranceDevice.deviceId"
|
||||
:disabled="operationBusy || !state.powerAvailable()"
|
||||
@click="toggleDevice(fragranceDevice)"
|
||||
>
|
||||
{{ state.powerLabel('释放中', '已关闭') }}
|
||||
</VanButton>
|
||||
</div>
|
||||
|
||||
<small class="p-tip">上方“释放中/已关闭”只表示香薰主开关;感应夜灯在下方单独显示和控制。</small>
|
||||
|
||||
<div class="params-body">
|
||||
<div class="param-row">
|
||||
<div class="param-header">
|
||||
<span class="p-title">单次出香时长</span>
|
||||
<strong class="p-val">{{ state.display('fragrance_duration', localParams.fragranceDuration, ' 秒') }}</strong>
|
||||
</div>
|
||||
<VanSlider
|
||||
v-model="localParams.fragranceDuration"
|
||||
:min="2"
|
||||
:max="6"
|
||||
:step="1"
|
||||
:disabled="operationBusy || !state.available('fragrance_duration')"
|
||||
@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">{{ state.display('fragrance_interval', localParams.fragranceInterval, ' 分钟') }}</strong>
|
||||
</div>
|
||||
<VanSlider
|
||||
v-model="localParams.fragranceInterval"
|
||||
:min="10"
|
||||
:max="20"
|
||||
:step="5"
|
||||
:disabled="operationBusy || !state.available('fragrance_interval')"
|
||||
@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: state.readable('fragrance_light') && localParams.fragranceLight }"
|
||||
:disabled="operationBusy || !state.available('fragrance_light')"
|
||||
@click="localParams.fragranceLight = !localParams.fragranceLight; applyParam(fragranceDevice.deviceId, 'fragrance_light', localParams.fragranceLight)"
|
||||
>{{ state.display('fragrance_light', 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: state.readable('fragrance_auto_release') && localParams.fragranceAutoRelease }"
|
||||
:disabled="operationBusy || !state.available('fragrance_auto_release')"
|
||||
@click="localParams.fragranceAutoRelease = !localParams.fragranceAutoRelease; applyParam(fragranceDevice.deviceId, 'fragrance_auto_release', localParams.fragranceAutoRelease)"
|
||||
>{{ state.display('fragrance_auto_release', 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: state.readable('fragrance_auto_light') && localParams.fragranceAutoLight }"
|
||||
:disabled="operationBusy || !state.available('fragrance_auto_light')"
|
||||
@click="localParams.fragranceAutoLight = !localParams.fragranceAutoLight; applyParam(fragranceDevice.deviceId, 'fragrance_auto_light', localParams.fragranceAutoLight)"
|
||||
>{{ state.display('fragrance_auto_light', 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: state.readable('fragrance_light_auto_off') && localParams.fragranceLightAutoOff }"
|
||||
:disabled="operationBusy || !state.available('fragrance_light_auto_off')"
|
||||
@click="localParams.fragranceLightAutoOff = !localParams.fragranceLightAutoOff; applyParam(fragranceDevice.deviceId, 'fragrance_light_auto_off', localParams.fragranceLightAutoOff)"
|
||||
>{{ state.display('fragrance_light_auto_off', localParams.fragranceLightAutoOff ? '已开启' : '已关闭') }}</button>
|
||||
</div>
|
||||
|
||||
<div class="param-row inline-row">
|
||||
<div>
|
||||
<div class="p-title">立即出香</div>
|
||||
<small class="p-tip">{{ state.available('fragrance_quick_release') ? '手动触发一次当前时长的香氛释放' : '快速出香能力当前不可用' }}</small>
|
||||
</div>
|
||||
<VanButton
|
||||
size="small"
|
||||
round
|
||||
plain
|
||||
color="#0c5045"
|
||||
:loading="settingParam === `${fragranceDevice.deviceId}_fragrance_quick_release`"
|
||||
:disabled="operationBusy || !state.available('fragrance_quick_release')"
|
||||
@click="applyParam(fragranceDevice.deviceId, 'fragrance_quick_release', true)"
|
||||
>{{ state.available('fragrance_quick_release') ? '快速出香' : '暂不可用' }}</VanButton>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import type { HomeAssistantDiscoveredDevice } from '../../services/health'
|
||||
import { useDeviceControlContext } from '../../devices/controlContext'
|
||||
const props = defineProps<{ device: HomeAssistantDiscoveredDevice }>()
|
||||
const { operationBusy, toggleDevice, isDeviceTurnedOn, isDeviceControlAvailable, switchingDeviceId } = useDeviceControlContext()
|
||||
const expanded = ref(false)
|
||||
const entities = computed(() => expanded.value ? props.device.entities : props.device.entities.slice(0, 6))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="device-card">
|
||||
<div class="device-head">
|
||||
<div class="dev-info">
|
||||
<span class="category-tag">设备能力</span>
|
||||
<h3>{{ device.name }}</h3>
|
||||
<span class="status-dot" :class="{ online: device.online }">{{ device.online ? '设备在线' : '设备离线' }}</span>
|
||||
</div>
|
||||
<button v-if="device.primaryControlEntityId" class="mini-toggle"
|
||||
:class="{ active: isDeviceTurnedOn(device) }"
|
||||
:disabled="operationBusy || !isDeviceControlAvailable(device) || switchingDeviceId === device.deviceId" @click="toggleDevice(device)">
|
||||
{{ !isDeviceControlAvailable(device) ? '暂不可用' : isDeviceTurnedOn(device) ? '关闭' : '开启' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="params-body">
|
||||
<div v-for="entity in entities" :key="entity.entityId" class="param-row">
|
||||
<span class="p-title">{{ entity.name }}</span>
|
||||
<span class="p-val">{{ entity.available && !['unknown', 'unavailable'].includes(entity.state) ? entity.state : '不可用' }} {{ entity.unit || '' }}</span>
|
||||
</div>
|
||||
<button v-if="device.entities.length > 6" class="text-refresh" @click="expanded = !expanded">
|
||||
{{ expanded ? '收起能力' : '查看全部能力' }}
|
||||
</button>
|
||||
<p class="scale-note">已发现此设备;专属参数和自动场景需由对应模块接入,不会自动执行未知能力。</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,94 @@
|
||||
<script setup lang="ts">
|
||||
import { toRefs } from 'vue'
|
||||
import { Button as VanButton, Slider as VanSlider } from 'vant'
|
||||
import type { HomeAssistantDiscoveredDevice } from '../../services/health'
|
||||
import { useDeviceControlContext } from '../../devices/controlContext'
|
||||
import { useDeviceCardState } from '../../devices/useDeviceCardState'
|
||||
const props = defineProps<{ device: HomeAssistantDiscoveredDevice }>()
|
||||
const { device: lightDevice } = toRefs(props)
|
||||
const { localParams, operationBusy, switchingDeviceId, isDeviceTurnedOn, toggleDevice, applyParam, controlLightTransition, lightTransition, loading } = useDeviceControlContext()
|
||||
const state = useDeviceCardState(() => props.device)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="device-card" v-if="lightDevice">
|
||||
<div class="device-head">
|
||||
<div class="dev-info">
|
||||
<span class="category-tag">卧室灯光</span>
|
||||
<h3>{{ lightDevice.name }}</h3>
|
||||
<span class="status-dot" :class="{ online: lightDevice.online }">
|
||||
{{ lightDevice.online ? '设备在线' : '设备离线' }}
|
||||
</span>
|
||||
</div>
|
||||
<VanButton
|
||||
size="small"
|
||||
round
|
||||
:type="isDeviceTurnedOn(lightDevice) ? 'primary' : 'default'"
|
||||
:color="isDeviceTurnedOn(lightDevice) ? '#0c5045' : ''"
|
||||
:loading="switchingDeviceId === lightDevice.deviceId"
|
||||
:disabled="operationBusy || !state.powerAvailable()"
|
||||
@click="toggleDevice(lightDevice)"
|
||||
>
|
||||
{{ state.powerLabel('已点亮', '已熄灭') }}
|
||||
</VanButton>
|
||||
</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 || !state.available('brightness')" @click="controlLightTransition('sleep')">🌙 开始助眠渐暗</button>
|
||||
<button type="button" :disabled="operationBusy || !state.available('brightness')" @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>
|
||||
<strong class="p-val">{{ lightTransition?.running ? `${localParams.lightBrightness}%` : state.display('brightness', localParams.lightBrightness, '%') }}</strong>
|
||||
</div>
|
||||
<VanSlider
|
||||
v-model="localParams.lightBrightness"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:disabled="operationBusy || !state.available('brightness')"
|
||||
active-color="#d6a657"
|
||||
@change="applyParam(lightDevice.deviceId, 'brightness', localParams.lightBrightness)"
|
||||
/>
|
||||
<div class="slider-scale">
|
||||
<span>0% 关闭灯光</span>
|
||||
<span>50% 舒缓</span>
|
||||
<span>100% 满亮</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 色温滑块 -->
|
||||
<div class="param-row">
|
||||
<div class="param-header">
|
||||
<span class="p-title">灯光色温调节</span>
|
||||
<strong class="p-val">{{ state.display('color_temp', localParams.lightColorTemp, 'K') }}</strong>
|
||||
</div>
|
||||
<VanSlider
|
||||
v-model="localParams.lightColorTemp"
|
||||
:min="2200"
|
||||
:max="5000"
|
||||
:step="50"
|
||||
:disabled="operationBusy || !state.available('color_temp')"
|
||||
active-color="#c87f2b"
|
||||
@change="applyParam(lightDevice.deviceId, 'color_temp', localParams.lightColorTemp)"
|
||||
/>
|
||||
<div class="slider-scale">
|
||||
<span>2200K 超暖橙光 (利于褪黑素)</span>
|
||||
<span>5000K 清爽白光</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,155 @@
|
||||
<script setup lang="ts">
|
||||
import { toRefs } from 'vue'
|
||||
import { Slider as VanSlider } from 'vant'
|
||||
import type { HomeAssistantDiscoveredDevice } from '../../services/health'
|
||||
import { useDeviceControlContext } from '../../devices/controlContext'
|
||||
import { useDeviceCardState } from '../../devices/useDeviceCardState'
|
||||
const props = defineProps<{ device: HomeAssistantDiscoveredDevice }>()
|
||||
const { device: mattressDevice } = toRefs(props)
|
||||
const { localParams, operationBusy, applyParam, mattressComfort, mattressActiveSide, mattressModeOptions, setMattressMode, setMattressSelect, mattressFirmnessOptions, mattressFirmnessZones, massagePartOptions } = useDeviceControlContext()
|
||||
const state = useDeviceCardState(() => props.device)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="device-card" v-if="mattressDevice">
|
||||
<div class="device-head">
|
||||
<div class="dev-info">
|
||||
<span class="category-tag">智能承托与温控</span>
|
||||
<h3>{{ mattressDevice.name }}</h3>
|
||||
<span class="status-dot" :class="{ online: mattressDevice.online }">
|
||||
{{ mattressDevice.online ? '设备在线' : '设备离线' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="params-body">
|
||||
<div class="mattress-recommendation">
|
||||
<strong>室内温度与 BMI 床垫建议</strong>
|
||||
<p>{{ mattressComfort?.heatingReason || '室温建议读取中' }}</p>
|
||||
<p>{{ mattressComfort?.firmnessReason || 'BMI 建议读取中' }}</p>
|
||||
<small v-if="mattressComfort?.indoorTemperatureAvailable">
|
||||
当前室温 {{ mattressComfort.indoorTemperatureCelsius }}℃
|
||||
</small>
|
||||
<small>下次一键启动使用当前建议;下面显示设备读数,可手动覆盖。</small>
|
||||
</div>
|
||||
<!-- 左侧加热档位 -->
|
||||
<div class="param-row">
|
||||
<div class="param-header">
|
||||
<span class="p-title">左侧加热档位</span>
|
||||
<strong class="p-val">{{ state.display('mattress_left_heating', localParams.mattressLeftHeating, ' 档') }}</strong>
|
||||
</div>
|
||||
<VanSlider
|
||||
v-model="localParams.mattressLeftHeating"
|
||||
:min="0"
|
||||
:max="4"
|
||||
:step="1"
|
||||
:disabled="operationBusy || !state.available('mattress_left_heating')"
|
||||
active-color="#d66927"
|
||||
@change="applyParam(mattressDevice.deviceId, 'mattress_left_heating', localParams.mattressLeftHeating)"
|
||||
/>
|
||||
<div class="slider-scale">
|
||||
<span>0 关闭</span>
|
||||
<span>2 舒适</span>
|
||||
<span>4 强加热</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧加热档位 -->
|
||||
<div class="param-row">
|
||||
<div class="param-header">
|
||||
<span class="p-title">右侧加热档位</span>
|
||||
<strong class="p-val">{{ state.display('mattress_right_heating', localParams.mattressRightHeating, ' 档') }}</strong>
|
||||
</div>
|
||||
<VanSlider
|
||||
v-model="localParams.mattressRightHeating"
|
||||
:min="0"
|
||||
:max="4"
|
||||
:step="1"
|
||||
:disabled="operationBusy || !state.available('mattress_right_heating')"
|
||||
active-color="#d66927"
|
||||
@change="applyParam(mattressDevice.deviceId, 'mattress_right_heating', localParams.mattressRightHeating)"
|
||||
/>
|
||||
<div class="slider-scale">
|
||||
<span>0 关闭</span>
|
||||
<span>2 舒适</span>
|
||||
<span>4 强加热</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="param-row">
|
||||
<p class="mattress-mode-note">
|
||||
左右可分别预选模式,但共用气泵,同一时间仅最近启动的一侧实际运行;另一侧保留预选,处于待机状态。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="param-row">
|
||||
<span class="p-title">左侧承托模式 · {{ !state.readable('mattress_left_mode') ? '状态未确认(保留预选)' : mattressActiveSide === 'left' ? '运行中' : '预选待机' }}</span>
|
||||
<div class="btn-group mattress-mode-buttons">
|
||||
<button
|
||||
v-for="mode in mattressModeOptions"
|
||||
:key="`left-${mode}`"
|
||||
:class="{ active: localParams.mattressLeftMode === mode, running: state.readable('mattress_left_mode') && mattressActiveSide === 'left' && localParams.mattressLeftMode === mode }"
|
||||
:disabled="operationBusy || !state.available('mattress_left_mode')"
|
||||
@click="setMattressMode('left', mode)"
|
||||
>{{ mode }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="param-row">
|
||||
<span class="p-title">右侧承托模式 · {{ !state.readable('mattress_right_mode') ? '状态未确认(保留预选)' : mattressActiveSide === 'right' ? '运行中' : '预选待机' }}</span>
|
||||
<div class="btn-group mattress-mode-buttons">
|
||||
<button
|
||||
v-for="mode in mattressModeOptions"
|
||||
:key="`right-${mode}`"
|
||||
:class="{ active: localParams.mattressRightMode === mode, running: state.readable('mattress_right_mode') && mattressActiveSide === 'right' && localParams.mattressRightMode === mode }"
|
||||
:disabled="operationBusy || !state.available('mattress_right_mode')"
|
||||
@click="setMattressMode('right', mode)"
|
||||
>{{ mode }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details class="mattress-advanced">
|
||||
<summary>分区软硬度与按摩调节</summary>
|
||||
<p class="mattress-mode-note">软硬度按左、右各 6 个部位独立设置;BMI 只给出演示起点,并非医学建议。选择按摩部位可能让床垫立即动作。</p>
|
||||
<div v-for="zone in mattressFirmnessZones" :key="zone.key" class="mattress-select-row">
|
||||
<label :for="`firmness-${zone.key}`">{{ zone.label }}软硬度</label>
|
||||
<select :id="`firmness-${zone.key}`"
|
||||
:value="state.readable(`mattress_firmness_${zone.key}`) ? localParams.mattressFirmness[zone.key] || '' : ''"
|
||||
:disabled="operationBusy || !state.available(`mattress_firmness_${zone.key}`)"
|
||||
@change="setMattressSelect(`mattress_firmness_${zone.key}`, $event)">
|
||||
<option value="" disabled>状态未确认</option>
|
||||
<option v-for="level in mattressFirmnessOptions" :key="level" :value="level">{{ level }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mattress-select-row">
|
||||
<label for="massage-left-part">左侧按摩部位</label>
|
||||
<select id="massage-left-part" :value="state.readable('mattress_massage_left_part') ? localParams.mattressMassageLeftPart : ''"
|
||||
:disabled="operationBusy || !state.available('mattress_massage_left_part')"
|
||||
@change="setMattressSelect('mattress_massage_left_part', $event)">
|
||||
<option value="" disabled>状态未确认</option>
|
||||
<option v-for="part in massagePartOptions" :key="part" :value="part">{{ part }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mattress-select-row">
|
||||
<label for="massage-right-part">右侧按摩部位</label>
|
||||
<select id="massage-right-part" :value="state.readable('mattress_massage_right_part') ? localParams.mattressMassageRightPart : ''"
|
||||
:disabled="operationBusy || !state.available('mattress_massage_right_part')"
|
||||
@change="setMattressSelect('mattress_massage_right_part', $event)">
|
||||
<option value="" disabled>状态未确认</option>
|
||||
<option v-for="part in massagePartOptions" :key="part" :value="part">{{ part }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mattress-select-row">
|
||||
<label for="massage-level">按摩力度</label>
|
||||
<select id="massage-level" :value="state.readable('mattress_massage_level') ? localParams.mattressMassageLevel : ''"
|
||||
:disabled="operationBusy || !state.available('mattress_massage_level')"
|
||||
@change="setMattressSelect('mattress_massage_level', $event)">
|
||||
<option value="" disabled>状态未确认</option>
|
||||
<option value="轻柔">轻柔</option>
|
||||
<option value="强劲">强劲</option>
|
||||
</select>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Button as VanButton } from 'vant'
|
||||
import type { HomeAssistantDiscoveredDevice } from '../../services/health'
|
||||
import { useDeviceControlContext } from '../../devices/controlContext'
|
||||
import { outletCapabilities } from '../../devices/outlet/capabilities'
|
||||
const props = defineProps<{ device: HomeAssistantDiscoveredDevice }>()
|
||||
const { operationBusy, switchingDeviceId, toggleDevice } = useDeviceControlContext()
|
||||
const capability = computed(() => outletCapabilities(props.device))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="device-card">
|
||||
<div class="device-head">
|
||||
<div class="dev-info">
|
||||
<span class="category-tag">智能插座</span>
|
||||
<h3>{{ device.name }}</h3>
|
||||
<span class="status-dot" :class="{ online: capability.available }">{{ capability.available ? '插座可用' : '插座主开关当前不可用' }}</span>
|
||||
</div>
|
||||
<VanButton size="small" round :type="capability.powerOn ? 'primary' : 'default'"
|
||||
:loading="switchingDeviceId === device.deviceId" :disabled="operationBusy || !capability.available"
|
||||
@click="toggleDevice(device)">
|
||||
{{ capability.available ? capability.powerOn ? '关闭电源' : '开启电源' : '暂不可用' }}
|
||||
</VanButton>
|
||||
</div>
|
||||
<div class="params-body">
|
||||
<div class="param-row inline-row">
|
||||
<span class="p-title">插座电源状态</span>
|
||||
<strong class="p-val">{{ capability.powerOn === null ? '--' : capability.powerOn ? '已通电' : '已断电' }}</strong>
|
||||
</div>
|
||||
<div v-if="capability.energyEntity" class="param-row inline-row">
|
||||
<span class="p-title">累计用电</span>
|
||||
<strong class="p-val">{{ capability.energy === undefined ? '--' : `${capability.energy} ${capability.energyEntity.unit}` }}</strong>
|
||||
</div>
|
||||
<p class="scale-note">仅单独控制插座电源,暂不参与一键舒睡或一键关闭。内部服务及保护设置不在此调整。</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { toRefs } from 'vue'
|
||||
import type { HomeAssistantDiscoveredDevice } from '../../services/health'
|
||||
import { useDeviceControlContext } from '../../devices/controlContext'
|
||||
const props = defineProps<{ device: HomeAssistantDiscoveredDevice }>()
|
||||
const { device: scaleDevice } = toRefs(props)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="device-card" v-if="scaleDevice">
|
||||
<div class="device-head">
|
||||
<div class="dev-info">
|
||||
<span class="category-tag">健康身体成分</span>
|
||||
<h3>{{ scaleDevice.name }}</h3>
|
||||
<span class="status-dot" :class="{ online: scaleDevice.online }">
|
||||
{{ scaleDevice.online ? '已绑定就绪' : '设备离线' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="scale-note">
|
||||
体脂秤数据是否自动同步取决于设备接入能力;当前 Demo 按健康档案中保存的身高、体重计算 BMI。
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
Reference in New Issue
Block a user