This commit is contained in:
fuenmao
2026-11-13 14:03:38 +08:00
parent db0c578890
commit 09e8bded7e
22 changed files with 1309 additions and 607 deletions
+4 -4
View File
@@ -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 {
+182
View File
@@ -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>
+39 -12
View File
@@ -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>