1111
This commit is contained in:
@@ -109,14 +109,14 @@ function navigate(target: string) {
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border-top: 1px solid rgba(18, 63, 55, 0.08);
|
||||
border-top: 1px solid #eef2f6;
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
align-items: flex-start;
|
||||
padding-top: 6px;
|
||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||
z-index: 999;
|
||||
box-shadow: 0 -4px 16px rgba(18, 63, 55, 0.04);
|
||||
box-shadow: 0 -4px 16px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
@@ -129,7 +129,7 @@ function navigate(target: string) {
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
height: 44px;
|
||||
color: #7d8e87;
|
||||
color: #94a3b8;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s ease;
|
||||
user-select: none;
|
||||
@@ -152,7 +152,7 @@ function navigate(target: string) {
|
||||
}
|
||||
|
||||
.tab-item.active {
|
||||
color: #0c5045;
|
||||
color: #0284c7;
|
||||
}
|
||||
|
||||
.tab-item.active .tab-label {
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { usePageRequest } from '../composables/usePageRequest'
|
||||
import { formatIb43Time, ib43Api, type Ib43Automation, type Ib43AutomationRecord, type Ib43AutomationRule } from '../services/ib43'
|
||||
|
||||
const props = defineProps<{ bindingId: string }>()
|
||||
const status = ref<Ib43Automation>()
|
||||
const loading = ref(false)
|
||||
const updatingKey = ref<string | null>(null)
|
||||
const updating = computed(() => updatingKey.value !== null)
|
||||
const loadError = ref('')
|
||||
const actionError = ref('')
|
||||
const requests = usePageRequest()
|
||||
let timer: ReturnType<typeof setInterval>
|
||||
|
||||
const rules = computed<Ib43AutomationRule[]>(() => {
|
||||
if (!status.value) return []
|
||||
if (status.value.rules) return status.value.rules
|
||||
const { available, enabled, actionLabel, targetLabel } = status.value
|
||||
return available ? [{ actionKey: '', enabled, actionLabel, targetLabel, previewOnly: false }] : []
|
||||
})
|
||||
|
||||
const resultLabels: Record<string, string> = {
|
||||
pending: '尚未记录结果',
|
||||
'readback-confirmed': '已回读确认',
|
||||
'already-closed': '窗帘已关闭,跳过',
|
||||
'already-closing': '窗帘正在关闭,跳过',
|
||||
'already-running': '动作已在运行,跳过',
|
||||
'state-not-open': '当前状态不适合关帘,跳过',
|
||||
'sent-readback-unconfirmed': '已发出指令,回读未确认',
|
||||
'target-not-configured': '目标设备未配置',
|
||||
'cancelled-unconfirmed': '已取消或超时,结果未确认',
|
||||
'failed-unconfirmed': '动作未确认',
|
||||
expired: '事件已过期,跳过',
|
||||
'preview-would-start': '预演通过,计划开启除湿机',
|
||||
'preview-humidity-normal': '预演跳过,湿度未超过阈值',
|
||||
'preview-humidity-unavailable': '预演跳过,湿度读数缺失或不可用',
|
||||
'preview-humidity-stale': '预演跳过,湿度读数过期或时间无效',
|
||||
'preview-humidity-invalid': '预演跳过,湿度读数或单位无效',
|
||||
'humidity-normal': '湿度未超过阈值,跳过',
|
||||
'humidity-unavailable': '湿度读数缺失或不可用,跳过',
|
||||
'humidity-stale': '湿度读数过期或时间无效,跳过',
|
||||
'humidity-invalid': '湿度读数或单位无效,跳过',
|
||||
'dehumidifier-unavailable': '除湿机不可用,跳过',
|
||||
'dehumidifier-already-on': '除湿机已开启,跳过',
|
||||
'dehumidifier-state-not-off': '无法确认除湿机已关机,跳过',
|
||||
'preview-climate-cool': '预演通过,计划制冷',
|
||||
'preview-climate-heat': '预演通过,计划制热',
|
||||
'preview-temperature-within-range': '预演跳过,室温在设定区间内',
|
||||
'preview-temperature-unavailable': '预演跳过,室温读数缺失或不可用',
|
||||
'preview-temperature-stale': '预演跳过,室温读数过期或时间无效',
|
||||
'preview-temperature-invalid': '预演跳过,室温读数或单位无效',
|
||||
'temperature-not-low': '室温未低于 22℃,跳过',
|
||||
'temperature-unavailable': '室温读数缺失或不可用,跳过',
|
||||
'temperature-stale': '室温读数过期或时间无效,跳过',
|
||||
'temperature-invalid': '室温读数或单位无效,跳过',
|
||||
'climate-unavailable': '空调不可用,跳过',
|
||||
'climate-not-off': '空调当前未关机,跳过',
|
||||
'climate-heat-unsupported': '空调不支持制热或 22℃ 目标温度,跳过',
|
||||
'climate-heat-mode-unconfirmed': '已发出制热指令,未确认制热模式',
|
||||
'climate-heat-state-changed': '制热状态已改变或不可用,跳过设温',
|
||||
'climate-heat-target-unconfirmed': '已确认制热,未确认维持制热并设为 22℃',
|
||||
'fragrance-release-sent': '已发送一次快速出香指令',
|
||||
'fragrance-release-unavailable': '快速出香按钮不可用,跳过',
|
||||
'speaker-music-not-configured': '舒睡曲目或音箱网关未配置,跳过',
|
||||
'speaker-state-unavailable': '音箱状态不可用,跳过',
|
||||
'speaker-not-idle': '音箱当前非空闲,跳过',
|
||||
'speaker-playback-sent': '已发送舒睡音乐播放指令',
|
||||
}
|
||||
|
||||
function resultLabel(row: Ib43AutomationRecord) {
|
||||
if (row.actionKey === 'curtain-close' && row.result === 'readback-confirmed') return '已确认关闭'
|
||||
if (row.actionKey === 'curtain-close' && row.result === 'sent-readback-unconfirmed') return '已发出关帘指令,未确认关闭'
|
||||
if (row.actionKey === 'dehumidifier-humidity-start' && row.result === 'readback-confirmed') return '已确认开启'
|
||||
if (row.actionKey === 'dehumidifier-humidity-start' && row.result === 'sent-readback-unconfirmed') return '已发出开机指令,未确认开启'
|
||||
if (row.actionKey === 'climate-heat-start' && row.result === 'readback-confirmed') return '已确认制热至 22℃'
|
||||
return resultLabels[row.result] || '结果未知'
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (updating.value || loading.value) return
|
||||
const request = requests.begin()
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await ib43Api.automation(props.bindingId)
|
||||
if (!requests.isCurrent(request)) return
|
||||
status.value = result
|
||||
loadError.value = ''
|
||||
} catch (error) {
|
||||
if (requests.isCurrent(request)) loadError.value = error instanceof Error ? error.message : '暂时无法读取联动状态'
|
||||
} finally {
|
||||
if (requests.isCurrent(request)) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggle(rule: Ib43AutomationRule) {
|
||||
const enabled = !rule.enabled
|
||||
if (!status.value || updating.value || (enabled && loadError.value)) return
|
||||
const request = requests.begin()
|
||||
updatingKey.value = rule.actionKey
|
||||
loading.value = false
|
||||
actionError.value = ''
|
||||
try {
|
||||
if (enabled) await ib43Api.startAutomation(props.bindingId, rule.actionKey || undefined)
|
||||
else await ib43Api.stopAutomation(props.bindingId, rule.actionKey || undefined)
|
||||
if (!requests.isCurrent(request)) return
|
||||
const previous = status.value
|
||||
status.value = previous.rules
|
||||
? { ...previous, rules: previous.rules.map(item => item.actionKey === rule.actionKey ? { ...item, enabled } : item) }
|
||||
: { ...previous, enabled }
|
||||
} catch (error) {
|
||||
if (requests.isCurrent(request)) actionError.value = error instanceof Error ? error.message : '更新联动状态失败'
|
||||
} finally {
|
||||
if (requests.isCurrent(request)) {
|
||||
updatingKey.value = null
|
||||
void load()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load()
|
||||
timer = setInterval(() => {
|
||||
if (!document.hidden && (rules.value.some(rule => rule.enabled) || status.value?.records.some(row =>
|
||||
row.result === 'pending' && Date.now() - Number(row.triggeredAtMs) < 90000))) void load()
|
||||
}, 5000)
|
||||
})
|
||||
onUnmounted(() => clearInterval(timer))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="section-card automation-card" aria-labelledby="ib43-automation-title">
|
||||
<div class="automation-heading">
|
||||
<h2 id="ib43-automation-title">家具联动</h2>
|
||||
<button class="automation-button" type="button" :disabled="loading || updating" @click="load">刷新记录</button>
|
||||
</div>
|
||||
<p v-if="!status && loading" class="automation-muted" role="status">正在读取联动状态…</p>
|
||||
<p v-if="loadError" class="automation-error" role="alert">{{ loadError }}</p>
|
||||
<template v-if="status">
|
||||
<p v-if="!rules.length" class="automation-muted">该床位当前未启用可管理的家具规则。</p>
|
||||
<div v-for="rule in rules" :key="rule.actionKey" class="automation-rule">
|
||||
<div class="automation-heading">
|
||||
<strong>上床后{{ rule.actionLabel }}{{ rule.previewOnly ? '(预演)' : '' }}</strong>
|
||||
<span class="automation-state" :class="{ enabled: rule.enabled }">{{ rule.enabled ? '已开启' : '已停止' }}</span>
|
||||
</div>
|
||||
<p v-if="rule.previewOnly">预演只读取数据并记录结果。{{ rule.conditionLabel }}</p>
|
||||
<p v-else-if="rule.conditionLabel">{{ rule.conditionLabel }}</p>
|
||||
<p v-else>模拟床位从离床变为在床时,对{{ rule.targetLabel }}执行此动作。</p>
|
||||
<button class="automation-button" :class="{ primary: !rule.enabled }" type="button"
|
||||
:disabled="updating || (!rule.enabled && !!loadError)" @click="toggle(rule)">
|
||||
{{ updatingKey === rule.actionKey ? '正在更新…' : rule.previewOnly
|
||||
? (rule.enabled ? '停止预演' : '开启预演') : (rule.enabled ? '停止联动' : '开启联动') }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="actionError" class="automation-error" role="alert">{{ actionError }}</p>
|
||||
<h3>最近执行记录</h3>
|
||||
<p v-if="status.records.length === 0" class="automation-muted">暂无执行记录。</p>
|
||||
<ol v-else class="automation-records">
|
||||
<li v-for="row in status.records" :key="row.id">
|
||||
<span>{{ row.targetLabel }} · {{ resultLabel(row) }}</span>
|
||||
<time>{{ formatIb43Time(row.triggeredAtMs) }}</time>
|
||||
</li>
|
||||
</ol>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.automation-heading { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
|
||||
h2 { margin: 0; font-size: 17px; }
|
||||
h3 { margin: 16px 0 8px; font-size: 14px; }
|
||||
.automation-button { min-height: 44px; border: 1px solid #bae6fd; border-radius: 12px; background: #f0f9ff; color: #0284c7; padding: 8px 12px; font: inherit; font-size: 13px; }
|
||||
.automation-button.primary { color: #fff; background: linear-gradient(135deg, #38bdf8 0%, #0284c7 100%); border-color: transparent; box-shadow: 0 4px 14px rgba(2, 132, 199, 0.25); }
|
||||
.automation-button:disabled { opacity: .55; }
|
||||
.automation-rule { margin-top: 12px; padding: 12px; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 13px; }
|
||||
.automation-rule p, .automation-muted, .automation-records time { color: var(--muted-text); font-size: 13px; }
|
||||
.automation-state { flex-shrink: 0; border-radius: 8px; padding: 4px 8px; background: #f1f5f9; color: #64748b; font-size: 12px; }
|
||||
.automation-state.enabled { background: #f0f9ff; color: #0284c7; }
|
||||
.automation-error { color: #8b432f; font-size: 13px; overflow-wrap: anywhere; }
|
||||
.automation-records { list-style: none; padding: 0; margin: 0; }
|
||||
.automation-records li { display: grid; gap: 5px; border-top: 1px solid #e2e8f0; padding: 10px 0; font-size: 14px; overflow-wrap: anywhere; }
|
||||
</style>
|
||||
@@ -1,20 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
import { toRefs } from 'vue'
|
||||
import { reactive, toRefs, watchEffect } 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 { defaults, labels, sync } from '../../devices/fragrance/parameters'
|
||||
import type { ParameterSnapshot } from '../../devices/parameterState'
|
||||
const props = defineProps<{ device: HomeAssistantDiscoveredDevice }>()
|
||||
const { device: fragranceDevice } = toRefs(props)
|
||||
const { localParams, operationBusy, switchingDeviceId, isDeviceTurnedOn, toggleDevice, applyParam, settingParam } = useDeviceControlContext()
|
||||
const state = useDeviceCardState(() => props.device)
|
||||
const { haStatus, operationBusy, switchingDeviceId, isDeviceTurnedOn, toggleDevice,
|
||||
applyParam: setParameter, settingParam } = useDeviceControlContext()
|
||||
const localParams = reactive({ ...defaults })
|
||||
const parameterKey = (key: string) => props.device.moduleKey === 'wake-fragrance' ? `wake_${key}` : key
|
||||
const card = useDeviceCardState(() => props.device)
|
||||
const state = {
|
||||
...card,
|
||||
available: (key: string) => card.available(parameterKey(key)),
|
||||
readable: (key: string) => card.readable(parameterKey(key)),
|
||||
display: (key: string, value: unknown, suffix = '') => card.display(parameterKey(key), value, suffix),
|
||||
}
|
||||
|
||||
function syncReadings() {
|
||||
const controls: ParameterSnapshot = {}
|
||||
for (const key of Object.keys(labels)) {
|
||||
const reading = haStatus.value?.controlParameters?.[parameterKey(key)]
|
||||
if (reading && state.available(key)) controls[key] = reading
|
||||
}
|
||||
Object.assign(localParams, defaults)
|
||||
sync(localParams, controls)
|
||||
}
|
||||
watchEffect(syncReadings)
|
||||
|
||||
async function applyParam(key: string, value: unknown) {
|
||||
await setParameter(props.device.deviceId, parameterKey(key), value)
|
||||
syncReadings()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="device-card" v-if="fragranceDevice">
|
||||
<div class="device-head">
|
||||
<div class="dev-info">
|
||||
<span class="category-tag">香氛助眠</span>
|
||||
<span class="category-tag">香氛控制</span>
|
||||
<h3>{{ fragranceDevice.name }}</h3>
|
||||
<span class="status-dot" :class="{ online: fragranceDevice.online }">
|
||||
{{ fragranceDevice.online ? '设备在线' : '设备离线' }}
|
||||
@@ -47,7 +74,7 @@ const state = useDeviceCardState(() => props.device)
|
||||
:max="6"
|
||||
:step="1"
|
||||
:disabled="operationBusy || !state.available('fragrance_duration')"
|
||||
@change="applyParam(fragranceDevice.deviceId, 'fragrance_duration', localParams.fragranceDuration)"
|
||||
@change="applyParam('fragrance_duration', localParams.fragranceDuration)"
|
||||
/>
|
||||
<div class="range-labels"><span>2 秒</span><span>4 秒</span><span>6 秒</span></div>
|
||||
</div>
|
||||
@@ -63,7 +90,7 @@ const state = useDeviceCardState(() => props.device)
|
||||
:max="20"
|
||||
:step="5"
|
||||
:disabled="operationBusy || !state.available('fragrance_interval')"
|
||||
@change="applyParam(fragranceDevice.deviceId, 'fragrance_interval', localParams.fragranceInterval)"
|
||||
@change="applyParam('fragrance_interval', localParams.fragranceInterval)"
|
||||
/>
|
||||
<div class="range-labels"><span>10 分钟</span><span>15 分钟</span><span>20 分钟</span></div>
|
||||
</div>
|
||||
@@ -77,7 +104,7 @@ const state = useDeviceCardState(() => props.device)
|
||||
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)"
|
||||
@click="applyParam('fragrance_light', !localParams.fragranceLight)"
|
||||
>{{ state.display('fragrance_light', localParams.fragranceLight ? '已开启' : '已关闭') }}</button>
|
||||
</div>
|
||||
|
||||
@@ -90,7 +117,7 @@ const state = useDeviceCardState(() => props.device)
|
||||
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)"
|
||||
@click="applyParam('fragrance_auto_release', !localParams.fragranceAutoRelease)"
|
||||
>{{ state.display('fragrance_auto_release', localParams.fragranceAutoRelease ? '已开启' : '已关闭') }}</button>
|
||||
</div>
|
||||
|
||||
@@ -103,7 +130,7 @@ const state = useDeviceCardState(() => props.device)
|
||||
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)"
|
||||
@click="applyParam('fragrance_auto_light', !localParams.fragranceAutoLight)"
|
||||
>{{ state.display('fragrance_auto_light', localParams.fragranceAutoLight ? '已开启' : '已关闭') }}</button>
|
||||
</div>
|
||||
|
||||
@@ -116,7 +143,7 @@ const state = useDeviceCardState(() => props.device)
|
||||
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)"
|
||||
@click="applyParam('fragrance_light_auto_off', !localParams.fragranceLightAutoOff)"
|
||||
>{{ state.display('fragrance_light_auto_off', localParams.fragranceLightAutoOff ? '已开启' : '已关闭') }}</button>
|
||||
</div>
|
||||
|
||||
@@ -130,9 +157,9 @@ const state = useDeviceCardState(() => props.device)
|
||||
round
|
||||
plain
|
||||
color="#0c5045"
|
||||
:loading="settingParam === `${fragranceDevice.deviceId}_fragrance_quick_release`"
|
||||
:loading="settingParam === `${fragranceDevice.deviceId}_${parameterKey('fragrance_quick_release')}`"
|
||||
:disabled="operationBusy || !state.available('fragrance_quick_release')"
|
||||
@click="applyParam(fragranceDevice.deviceId, 'fragrance_quick_release', true)"
|
||||
@click="applyParam('fragrance_quick_release', true)"
|
||||
>{{ state.available('fragrance_quick_release') ? '快速出香' : '暂不可用' }}</VanButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user