1066 lines
34 KiB
Vue
1066 lines
34 KiB
Vue
<template>
|
|
<view class="device-detail-page" :class="'theme-' + controlTheme">
|
|
<view class="bg-blur-circle"></view>
|
|
<view class="bg-blur-circle-2"></view>
|
|
|
|
<view class="custom-nav" :style="{ paddingTop: vuex_status_bar_height + 'px' }">
|
|
<view class="nav-btn" @click="goBack">
|
|
<text class="tn-icon-left"></text>
|
|
</view>
|
|
<view class="nav-center">
|
|
<text class="nav-title">{{ deviceName }}</text>
|
|
</view>
|
|
<view class="nav-btn nav-btn-outline" style="opacity: 0;">
|
|
<text class="tn-icon-more-vertical"></text>
|
|
</view>
|
|
</view>
|
|
|
|
<view class="hero-section">
|
|
<view class="device-hero-icon" :class="{'is-active': device.active}">
|
|
<text :class="device.iconClass || 'tn-icon-my-computer'"></text>
|
|
</view>
|
|
<view class="device-status-badge" :class="{'is-active': device.active}">
|
|
<view class="dot"></view>
|
|
<text>{{ statusText }}</text>
|
|
</view>
|
|
<view class="device-room-text">{{ displayRoomName }}</view>
|
|
</view>
|
|
|
|
<view class="bottom-sheet">
|
|
<view class="sheet-handle"></view>
|
|
|
|
<view class="sheet-header">
|
|
<text class="sheet-title">{{ t('controlPanel') }}</text>
|
|
<text class="sheet-subtitle">{{ functionCountText }}</text>
|
|
</view>
|
|
|
|
<scroll-view scroll-y class="panel-scroll">
|
|
<view
|
|
v-for="(func, index) in deviceFunctions"
|
|
:key="'panel-' + index"
|
|
class="control-panel"
|
|
:class="{ 'is-disabled': !isFunctionSupported(func) }"
|
|
>
|
|
<view class="panel-header">
|
|
<view class="panel-title">
|
|
<text class="tn-icon-set panel-icon"></text>
|
|
<text>{{ getFunctionLabel(func) }}</text>
|
|
</view>
|
|
<view class="status-tag" v-if="!isFunctionSupported(func)">{{ t('pendingTag') }}</view>
|
|
</view>
|
|
|
|
<!-- Switch Theme -->
|
|
<view v-if="isSwitchLikeFunction(func)" class="control-row switch-row">
|
|
<view class="power-btn-huge" :class="{'is-active': device.active, 'is-disabled': isSubmitting}" @click="togglePower(index)">
|
|
<text class="tn-icon-power"></text>
|
|
</view>
|
|
</view>
|
|
|
|
<!-- Slider Theme -->
|
|
<block v-else-if="isSlideFunction(func)">
|
|
<view class="slider-container">
|
|
<view class="slider-header">
|
|
<text class="slider-label">{{ t('sliderLabel') }}</text>
|
|
<text class="slider-value">{{ controlValue }}%</text>
|
|
</view>
|
|
<slider
|
|
class="custom-slider"
|
|
min="0"
|
|
max="100"
|
|
step="1"
|
|
:value="controlValue"
|
|
activeColor="var(--theme-color)"
|
|
backgroundColor="rgba(255, 255, 255, 0.1)"
|
|
block-color="#ffffff"
|
|
block-size="24"
|
|
@change="handleControlValueChange"
|
|
/>
|
|
<view class="slider-apply-btn" :class="{ 'is-disabled': isSubmitting }" @click="submitSlideValue(index)">
|
|
<text>{{ t('applySetting') }}</text>
|
|
</view>
|
|
</view>
|
|
</block>
|
|
|
|
<!-- Curtain Theme -->
|
|
<block v-else-if="isCurtainButtonFunction(func)">
|
|
<view class="control-row curtain-row">
|
|
<view class="curtain-btn" :class="{ 'is-active': curtainAction === 'open', 'is-disabled': isSubmitting }" @click="operateCurtain(index, 'open')">
|
|
<text class="tn-icon-up"></text>
|
|
<text class="btn-label">{{ t('curtainOpen') }}</text>
|
|
</view>
|
|
<view class="curtain-btn stop-btn" :class="{ 'is-active': curtainAction === 'stop', 'is-disabled': isSubmitting }" @click="operateCurtain(index, 'stop')">
|
|
<text class="tn-icon-minus"></text>
|
|
<text class="btn-label">{{ t('curtainPause') }}</text>
|
|
</view>
|
|
<view class="curtain-btn" :class="{ 'is-active': curtainAction === 'close', 'is-disabled': isSubmitting }" @click="operateCurtain(index, 'close')">
|
|
<text class="tn-icon-down"></text>
|
|
<text class="btn-label">{{ t('curtainClose') }}</text>
|
|
</view>
|
|
</view>
|
|
</block>
|
|
|
|
<view v-else class="unsupported-box" @click="notifyUnsupportedFunction(index)">
|
|
<text class="tn-icon-warning"></text>
|
|
<text>{{ t('unsupportedBoxText') }}</text>
|
|
</view>
|
|
</view>
|
|
|
|
<view class="sync-hint">
|
|
<text class="tn-icon-info-circle"></text>
|
|
<text>{{ syncHintText }}</text>
|
|
</view>
|
|
</scroll-view>
|
|
</view>
|
|
</view>
|
|
</template>
|
|
|
|
<script>
|
|
import { gatewayExecuteDeviceCommand, gatewaySyncDeviceInfo } from '../../api/index.js'
|
|
import {
|
|
buildCommandSchemaFields,
|
|
buildDeviceStateText,
|
|
getControlTypeCategory,
|
|
getDeviceControlLabel,
|
|
getDeviceFunctions,
|
|
inferDeviceControlType,
|
|
isSupportedDeviceControlType,
|
|
normalizeSmartDevice,
|
|
patchDeviceStatus
|
|
} from '../../utils/smart-device'
|
|
|
|
const DEFAULT_BRAND_ID = 'Tuya'
|
|
|
|
function safeParseDevice(value) {
|
|
if (!value) {
|
|
return null
|
|
}
|
|
|
|
try {
|
|
return JSON.parse(decodeURIComponent(value))
|
|
} catch (error) {
|
|
console.error('parse device payload failed', error)
|
|
return null
|
|
}
|
|
}
|
|
|
|
function getControlValue(device = {}) {
|
|
const rawStatus = device && device.currentStatus
|
|
if (rawStatus === undefined || rawStatus === null || rawStatus === '') {
|
|
return 0
|
|
}
|
|
|
|
const status = typeof rawStatus === 'object'
|
|
? rawStatus
|
|
: { value: rawStatus }
|
|
|
|
if (!status || typeof status !== 'object') {
|
|
return 0
|
|
}
|
|
const value = Number(
|
|
status.value !== undefined
|
|
? status.value
|
|
: (status.percent !== undefined ? status.percent : status.position)
|
|
)
|
|
return Number.isFinite(value) ? value : 0
|
|
}
|
|
|
|
function resolveRefreshIntervalMs(value, fallback = 1000) {
|
|
const seconds = Number(value)
|
|
if (!Number.isFinite(seconds) || seconds <= 0) {
|
|
return fallback
|
|
}
|
|
return Math.round(seconds * 1000)
|
|
}
|
|
|
|
export default {
|
|
i18n: {
|
|
zh: {
|
|
controlPanel: '控制面板',
|
|
pendingTag: '待完善',
|
|
sliderLabel: '亮度 / 数值',
|
|
applySetting: '应用设置',
|
|
curtainOpen: '打开',
|
|
curtainPause: '暂停',
|
|
curtainClose: '关闭',
|
|
unsupportedBoxText: '当前功能暂未开放使用,点击反馈',
|
|
deviceDetail: '设备详情',
|
|
currentRoom: '当前房间',
|
|
functionCount: '{count}项功能',
|
|
commandSending: '指令下发中...',
|
|
syncHint: '操作完成后会自动刷新设备状态',
|
|
deviceMissing: '设备信息缺失',
|
|
unsupportedFunction: '{name}操作功能未完善',
|
|
unknownSchema: '未识别到参数模板',
|
|
schemaApplied: '{name}参数已下发',
|
|
actionSuccess: '操作成功',
|
|
deviceIdMissing: '设备ID缺失',
|
|
functionCodeMissing: '功能编码缺失',
|
|
commandFailed: '指令下发失败',
|
|
deviceClosed: '已关闭',
|
|
deviceEnabled: '已开启',
|
|
deviceOpened: '已打开',
|
|
curtainOpened: '窗帘已打开',
|
|
curtainClosed: '窗帘已关闭',
|
|
curtainPaused: '窗帘已暂停',
|
|
sliderUpdated: '滑块值已更新'
|
|
},
|
|
en: {
|
|
controlPanel: 'Control Panel',
|
|
pendingTag: 'Pending',
|
|
sliderLabel: 'Brightness / Value',
|
|
applySetting: 'Apply',
|
|
curtainOpen: 'Open',
|
|
curtainPause: 'Pause',
|
|
curtainClose: 'Close',
|
|
unsupportedBoxText: 'This function is not available yet. Tap to send feedback.',
|
|
deviceDetail: 'Device Details',
|
|
currentRoom: 'Current Room',
|
|
functionCount: '{count} functions',
|
|
commandSending: 'Sending command...',
|
|
syncHint: 'Device status refreshes automatically after each action',
|
|
deviceMissing: 'Missing device information',
|
|
unsupportedFunction: '{name} is not fully supported yet',
|
|
unknownSchema: 'No parameter template recognized',
|
|
schemaApplied: '{name} parameters sent',
|
|
actionSuccess: 'Action completed',
|
|
deviceIdMissing: 'Missing device ID',
|
|
functionCodeMissing: 'Missing function code',
|
|
commandFailed: 'Failed to send command',
|
|
deviceClosed: 'Closed',
|
|
deviceEnabled: 'Enabled',
|
|
deviceOpened: 'Opened',
|
|
curtainOpened: 'Curtain opened',
|
|
curtainClosed: 'Curtain closed',
|
|
curtainPaused: 'Curtain paused',
|
|
sliderUpdated: 'Slider value updated'
|
|
}
|
|
},
|
|
data() {
|
|
return {
|
|
brandId: DEFAULT_BRAND_ID,
|
|
roomCode: '',
|
|
roomName: '',
|
|
device: {},
|
|
isSubmitting: false,
|
|
isSyncingStatus: false,
|
|
controlValue: 0,
|
|
functionFieldMap: {},
|
|
functionFormValues: {},
|
|
pollingEnabled: false,
|
|
pollingTimer: null,
|
|
pendingSyncPromise: null,
|
|
devicePollingIntervalMs: 1000
|
|
}
|
|
},
|
|
computed: {
|
|
language() {
|
|
return this.$getLanguage()
|
|
},
|
|
deviceName() {
|
|
return this.device.deviceName || this.t('deviceDetail')
|
|
},
|
|
displayRoomName() {
|
|
return this.roomName || this.roomCode || this.t('currentRoom')
|
|
},
|
|
controlType() {
|
|
return inferDeviceControlType(this.device)
|
|
},
|
|
controlTheme() {
|
|
return getControlTypeCategory(this.controlType)
|
|
},
|
|
controlTypeLabel() {
|
|
return getDeviceControlLabel(this.controlType)
|
|
},
|
|
functionCountText() {
|
|
const count = this.deviceFunctions.length
|
|
return count ? this.t('functionCount', { count }) : this.controlTypeLabel
|
|
},
|
|
statusText() {
|
|
return buildDeviceStateText(this.device)
|
|
},
|
|
syncHintText() {
|
|
if (this.isSubmitting) {
|
|
return this.t('commandSending')
|
|
}
|
|
return this.t('syncHint')
|
|
},
|
|
deviceFunctions() {
|
|
return getDeviceFunctions(this.device)
|
|
},
|
|
curtainAction() {
|
|
const rawStatus = this.device.currentStatus
|
|
if (rawStatus === undefined || rawStatus === null) {
|
|
return ''
|
|
}
|
|
if (typeof rawStatus === 'object') {
|
|
return String(rawStatus.command || rawStatus.action || rawStatus.state || '').trim().toLowerCase()
|
|
}
|
|
return String(rawStatus).trim().toLowerCase()
|
|
}
|
|
},
|
|
onLoad(options) {
|
|
this.brandId = options.brandId ? decodeURIComponent(options.brandId) : DEFAULT_BRAND_ID
|
|
this.roomCode = options.roomCode ? decodeURIComponent(options.roomCode) : ''
|
|
this.roomName = options.roomName ? decodeURIComponent(options.roomName) : ''
|
|
|
|
const parsedDevice = safeParseDevice(options.device)
|
|
if (!parsedDevice) {
|
|
uni.showToast({
|
|
title: this.t('deviceMissing'),
|
|
icon: 'none'
|
|
})
|
|
return
|
|
}
|
|
|
|
this.device = normalizeSmartDevice(parsedDevice)
|
|
this.devicePollingIntervalMs = resolveRefreshIntervalMs(this.device.refreshInterval, this.devicePollingIntervalMs)
|
|
this.controlValue = getControlValue(this.device)
|
|
this.rebuildFunctionSchemaState()
|
|
},
|
|
onShow() {
|
|
this.startDevicePolling(true)
|
|
},
|
|
onHide() {
|
|
this.stopDevicePolling()
|
|
},
|
|
onUnload() {
|
|
this.stopDevicePolling()
|
|
},
|
|
methods: {
|
|
t(key, params) {
|
|
return this.$tm('pkgService.device_control.device_control', key, params)
|
|
},
|
|
goBack() {
|
|
uni.navigateBack()
|
|
},
|
|
clearDevicePollingTimer() {
|
|
if (this.pollingTimer) {
|
|
clearTimeout(this.pollingTimer)
|
|
this.pollingTimer = null
|
|
}
|
|
},
|
|
startDevicePolling(immediate = false) {
|
|
if (!this.device || !this.device.deviceId) {
|
|
return
|
|
}
|
|
|
|
if (this.pollingEnabled) {
|
|
if (immediate) {
|
|
this.pollNextDeviceStatus()
|
|
}
|
|
return
|
|
}
|
|
|
|
this.pollingEnabled = true
|
|
if (immediate) {
|
|
this.pollNextDeviceStatus()
|
|
return
|
|
}
|
|
this.scheduleNextDevicePoll(this.devicePollingIntervalMs)
|
|
},
|
|
stopDevicePolling() {
|
|
this.pollingEnabled = false
|
|
this.clearDevicePollingTimer()
|
|
this.isSyncingStatus = false
|
|
},
|
|
scheduleNextDevicePoll(delay = this.devicePollingIntervalMs) {
|
|
if (!this.pollingEnabled) {
|
|
return
|
|
}
|
|
|
|
this.clearDevicePollingTimer()
|
|
this.pollingTimer = setTimeout(() => {
|
|
this.pollNextDeviceStatus()
|
|
}, delay)
|
|
},
|
|
pollNextDeviceStatus() {
|
|
if (!this.pollingEnabled) {
|
|
return Promise.resolve()
|
|
}
|
|
|
|
return this.syncCurrentDeviceStatus()
|
|
.finally(() => {
|
|
if (this.pollingEnabled) {
|
|
this.scheduleNextDevicePoll(this.devicePollingIntervalMs)
|
|
}
|
|
})
|
|
},
|
|
syncCurrentDeviceStatus() {
|
|
if (!this.device || !this.device.deviceId || !this.device.deviceNo || !this.brandId) {
|
|
return Promise.resolve()
|
|
}
|
|
|
|
if (this.isSubmitting) {
|
|
return Promise.resolve()
|
|
}
|
|
|
|
if (this.pendingSyncPromise) {
|
|
return this.pendingSyncPromise
|
|
}
|
|
|
|
this.isSyncingStatus = true
|
|
const requestPromise = gatewaySyncDeviceInfo(this.brandId, this.device.deviceNo)
|
|
.then((response) => {
|
|
const nextDevice = normalizeSmartDevice(response)
|
|
if (nextDevice && Object.keys(nextDevice).length) {
|
|
this.syncDevice(nextDevice, { vibrate: false })
|
|
}
|
|
})
|
|
.catch((error) => {
|
|
console.error('sync current smart device failed', error)
|
|
})
|
|
.finally(() => {
|
|
this.isSyncingStatus = false
|
|
this.pendingSyncPromise = null
|
|
})
|
|
|
|
this.pendingSyncPromise = requestPromise
|
|
return requestPromise
|
|
},
|
|
syncDevice(nextDevice, options = {}) {
|
|
const {
|
|
vibrate = false
|
|
} = options
|
|
this.device = normalizeSmartDevice(nextDevice)
|
|
this.devicePollingIntervalMs = resolveRefreshIntervalMs(this.device.refreshInterval, this.devicePollingIntervalMs)
|
|
this.controlValue = getControlValue(this.device)
|
|
this.rebuildFunctionSchemaState()
|
|
if (vibrate) {
|
|
uni.vibrateShort()
|
|
}
|
|
},
|
|
getDeviceFunctionByIndex(index = 0) {
|
|
const functionList = this.deviceFunctions || []
|
|
return functionList[index] || {}
|
|
},
|
|
getFunctionKey(func = {}, index = 0) {
|
|
return `${this.getFunctionType(func) || 'unknown'}-${index}`
|
|
},
|
|
getFunctionType(func = {}) {
|
|
return String((func && func.controlType) || '').trim().toLowerCase()
|
|
},
|
|
getFunctionCategory(func = {}) {
|
|
return getControlTypeCategory(this.getFunctionType(func))
|
|
},
|
|
getFunctionLabel(func = {}) {
|
|
return String((func && func.functionDes) || '').trim() || getDeviceControlLabel(this.getFunctionType(func))
|
|
},
|
|
getFunctionSchemaText(func = {}) {
|
|
const schema = func && func.commandSchema
|
|
if (schema === undefined || schema === null || schema === '') {
|
|
return ''
|
|
}
|
|
if (typeof schema === 'string') {
|
|
return schema
|
|
}
|
|
try {
|
|
return JSON.stringify(schema)
|
|
} catch (error) {
|
|
return String(schema)
|
|
}
|
|
},
|
|
isFunctionSupported(func = {}) {
|
|
return isSupportedDeviceControlType(this.getFunctionType(func))
|
|
},
|
|
isSwitchLikeFunction(func = {}) {
|
|
return this.getFunctionCategory(func) === 'switch'
|
|
},
|
|
isSlideFunction(func = {}) {
|
|
return this.getFunctionCategory(func) === 'slide'
|
|
},
|
|
isCurtainButtonFunction(func = {}) {
|
|
return this.getFunctionCategory(func) === 'curtain'
|
|
},
|
|
isNumberSliderField(field = {}) {
|
|
return field.type === 'number'
|
|
&& Number.isFinite(Number(field.min))
|
|
&& Number.isFinite(Number(field.max))
|
|
},
|
|
getFieldSliderMin(field = {}) {
|
|
return Number(field.min || 0)
|
|
},
|
|
getFieldSliderMax(field = {}) {
|
|
return Number(field.max || 100)
|
|
},
|
|
getFieldSliderStep(field = {}) {
|
|
return Number(field.step || 1)
|
|
},
|
|
getFunctionFields(func = {}, index = 0) {
|
|
return this.functionFieldMap[this.getFunctionKey(func, index)] || []
|
|
},
|
|
hasSchemaForm(func = {}, index = 0) {
|
|
return this.getFunctionFields(func, index).length > 0
|
|
},
|
|
getFunctionFormValuesByIndex(index = 0) {
|
|
const func = this.getDeviceFunctionByIndex(index)
|
|
return this.getFunctionFormValues(func, index)
|
|
},
|
|
getFunctionFormValues(func = {}, index = 0) {
|
|
return this.functionFormValues[this.getFunctionKey(func, index)] || {}
|
|
},
|
|
rebuildFunctionSchemaState() {
|
|
const nextFieldMap = {}
|
|
const nextFormValues = {}
|
|
const previousValues = this.functionFormValues || {}
|
|
|
|
this.deviceFunctions.forEach((func, index) => {
|
|
const functionKey = this.getFunctionKey(func, index)
|
|
const fields = buildCommandSchemaFields(func.commandSchema)
|
|
const existingValues = previousValues[functionKey] || {}
|
|
nextFieldMap[functionKey] = fields
|
|
nextFormValues[functionKey] = {}
|
|
|
|
fields.forEach((field) => {
|
|
const hasExistingValue = Object.prototype.hasOwnProperty.call(existingValues, field.name)
|
|
nextFormValues[functionKey][field.name] = hasExistingValue
|
|
? existingValues[field.name]
|
|
: this.getDefaultFieldValue(field, func)
|
|
})
|
|
})
|
|
|
|
this.functionFieldMap = nextFieldMap
|
|
this.functionFormValues = nextFormValues
|
|
},
|
|
getDefaultFieldValue(field = {}, func = {}) {
|
|
if (field.defaultValue !== undefined) {
|
|
return field.defaultValue
|
|
}
|
|
|
|
const functionType = this.getFunctionType(func)
|
|
const functionCategory = this.getFunctionCategory(func)
|
|
if (field.type === 'boolean') {
|
|
if (field.name === 'value' && this.isSwitchLikeFunction(func)) {
|
|
return !!this.device.active
|
|
}
|
|
return false
|
|
}
|
|
|
|
if (field.type === 'number') {
|
|
if ((functionCategory === 'slide' || functionCategory === 'curtain') && ['value', 'percent', 'position'].includes(field.name)) {
|
|
return this.controlValue
|
|
}
|
|
if (Number.isFinite(Number(field.min))) {
|
|
return Number(field.min)
|
|
}
|
|
return 0
|
|
}
|
|
|
|
if (field.type === 'enum') {
|
|
return field.options && field.options.length ? field.options[0].value : ''
|
|
}
|
|
|
|
if (field.type === 'string' && functionCategory === 'switch' && ['command', 'action', 'value'].includes(field.name)) {
|
|
return this.device.active ? 'on' : 'off'
|
|
}
|
|
|
|
if (field.type === 'string' && functionType === 'curtain:button' && ['command', 'action', 'value'].includes(field.name)) {
|
|
return 'open'
|
|
}
|
|
|
|
return ''
|
|
},
|
|
updateFunctionFieldValue(index = 0, fieldName = '', value = '') {
|
|
const func = this.getDeviceFunctionByIndex(index)
|
|
const functionKey = this.getFunctionKey(func, index)
|
|
this.$set(this.functionFormValues, functionKey, {
|
|
...(this.functionFormValues[functionKey] || {}),
|
|
[fieldName]: value
|
|
})
|
|
},
|
|
handleSchemaNumberChange(index = 0, fieldName = '', event) {
|
|
const value = Number((event && event.detail && event.detail.value) || 0)
|
|
this.updateFunctionFieldValue(index, fieldName, value)
|
|
},
|
|
handleSchemaInput(index = 0, fieldName = '', event) {
|
|
const value = event && event.detail ? event.detail.value : ''
|
|
this.updateFunctionFieldValue(index, fieldName, value)
|
|
},
|
|
getBooleanFieldValue(index = 0, fieldName = '') {
|
|
return !!this.getFunctionFormValuesByIndex(index)[fieldName]
|
|
},
|
|
getNumberFieldValue(index = 0, field = {}) {
|
|
const value = Number(this.getFunctionFormValuesByIndex(index)[field.name])
|
|
if (Number.isFinite(value)) {
|
|
return value
|
|
}
|
|
if (Number.isFinite(Number(field.min))) {
|
|
return Number(field.min)
|
|
}
|
|
return 0
|
|
},
|
|
getTextFieldValue(index = 0, field = {}) {
|
|
const value = this.getFunctionFormValuesByIndex(index)[field.name]
|
|
return value === undefined || value === null ? '' : String(value)
|
|
},
|
|
normalizeSchemaFieldValue(field = {}, value) {
|
|
if (field.type === 'boolean') {
|
|
return !!value
|
|
}
|
|
if (field.type === 'number') {
|
|
const numericValue = Number(value)
|
|
return Number.isFinite(numericValue) ? numericValue : 0
|
|
}
|
|
return value === undefined || value === null ? '' : String(value)
|
|
},
|
|
handleFunctionChipClick(index = 0) {
|
|
const func = this.getDeviceFunctionByIndex(index)
|
|
if (!this.isFunctionSupported(func)) {
|
|
this.notifyUnsupportedFunction(func)
|
|
}
|
|
},
|
|
notifyUnsupportedFunction(funcOrIndex = {}) {
|
|
const func = typeof funcOrIndex === 'number'
|
|
? this.getDeviceFunctionByIndex(funcOrIndex)
|
|
: funcOrIndex
|
|
uni.showToast({
|
|
title: this.t('unsupportedFunction', { name: this.getFunctionLabel(func) }),
|
|
icon: 'none'
|
|
})
|
|
},
|
|
resolveCommandFieldName(index = 0, candidates = [], fallback = 'command') {
|
|
const func = this.getDeviceFunctionByIndex(index)
|
|
const fields = this.getFunctionFields(func, index)
|
|
const fieldNames = fields.map(field => String(field.name || '').trim()).filter(Boolean)
|
|
|
|
for (let candidateIndex = 0; candidateIndex < candidates.length; candidateIndex += 1) {
|
|
const candidate = candidates[candidateIndex]
|
|
if (fieldNames.includes(candidate)) {
|
|
return candidate
|
|
}
|
|
}
|
|
|
|
return fieldNames[0] || fallback
|
|
},
|
|
buildSwitchCommandPayload(index = 0, enabled = false) {
|
|
const func = this.getDeviceFunctionByIndex(index)
|
|
const schemaText = this.getFunctionSchemaText(func).toLowerCase()
|
|
const fieldName = this.resolveCommandFieldName(index, ['command', 'action', 'value'], 'command')
|
|
if (!this.hasSchemaForm(func, index) && schemaText.includes('on/off')) {
|
|
return { command: enabled ? 'on' : 'off' }
|
|
}
|
|
return {
|
|
[fieldName]: enabled ? 'on' : 'off'
|
|
}
|
|
},
|
|
buildSlideCommandPayload(index = 0, value = 0) {
|
|
const func = this.getDeviceFunctionByIndex(index)
|
|
const schemaText = this.getFunctionSchemaText(func).toLowerCase()
|
|
if (!this.hasSchemaForm(func, index) && schemaText.includes('setpercent')) {
|
|
return {
|
|
command: 'setpercent',
|
|
value: Number(value || 0)
|
|
}
|
|
}
|
|
const fieldName = this.resolveCommandFieldName(index, ['value', 'percent', 'position'], 'value')
|
|
return {
|
|
[fieldName]: Number(value || 0)
|
|
}
|
|
},
|
|
buildCurtainCommandPayload(index = 0, action = '') {
|
|
const fieldName = this.resolveCommandFieldName(index, ['command', 'action', 'value'], 'command')
|
|
return {
|
|
[fieldName]: String(action || '').trim().toLowerCase()
|
|
}
|
|
},
|
|
submitSchemaForm(index = 0) {
|
|
const func = this.getDeviceFunctionByIndex(index)
|
|
if (this.isSubmitting) {
|
|
return
|
|
}
|
|
|
|
if (!this.isFunctionSupported(func)) {
|
|
this.notifyUnsupportedFunction(func)
|
|
return
|
|
}
|
|
|
|
const fields = this.getFunctionFields(func, index)
|
|
if (!fields.length) {
|
|
uni.showToast({
|
|
title: this.t('unknownSchema'),
|
|
icon: 'none'
|
|
})
|
|
return
|
|
}
|
|
|
|
const rawValues = this.getFunctionFormValues(func, index)
|
|
const payload = fields.reduce((result, field) => {
|
|
result[field.name] = this.normalizeSchemaFieldValue(field, rawValues[field.name])
|
|
return result
|
|
}, {})
|
|
|
|
this.executeCommand(func, payload, this.t('schemaApplied', { name: this.getFunctionLabel(func) }))
|
|
},
|
|
executeCommand(func, command, successText = this.t('actionSuccess')) {
|
|
if (!this.device || !this.device.deviceNo) {
|
|
uni.showToast({ title: this.t('deviceIdMissing'), icon: 'none' })
|
|
return Promise.resolve()
|
|
}
|
|
|
|
if (!this.isFunctionSupported(func)) {
|
|
this.notifyUnsupportedFunction(func)
|
|
return Promise.resolve()
|
|
}
|
|
|
|
const functionCode = String((func && func.functionCode) || '').trim()
|
|
if (!functionCode) {
|
|
uni.showToast({ title: this.t('functionCodeMissing'), icon: 'none' })
|
|
return Promise.resolve()
|
|
}
|
|
|
|
const controlType = this.getFunctionType(func)
|
|
this.isSubmitting = true
|
|
return gatewayExecuteDeviceCommand(this.brandId, this.device.deviceNo, functionCode, command)
|
|
.then(() => {
|
|
this.syncDevice(patchDeviceStatus(this.device, command, controlType), { vibrate: true })
|
|
uni.showToast({
|
|
title: successText,
|
|
icon: 'success'
|
|
})
|
|
})
|
|
.catch((error) => {
|
|
console.error('execute device command failed', error)
|
|
uni.showToast({
|
|
title: (error && (error.message || error.msg)) || this.t('commandFailed'),
|
|
icon: 'none'
|
|
})
|
|
})
|
|
.finally(() => {
|
|
this.isSubmitting = false
|
|
this.scheduleNextDevicePoll(300)
|
|
})
|
|
},
|
|
togglePower(index = 0) {
|
|
const func = this.getDeviceFunctionByIndex(index)
|
|
if (this.isSubmitting) {
|
|
return
|
|
}
|
|
this.executeCommand(func, this.buildSwitchCommandPayload(index, !this.device.active), this.device.active ? this.t('deviceClosed') : this.t('deviceEnabled'))
|
|
},
|
|
setSwitchState(index = 0, value) {
|
|
const func = this.getDeviceFunctionByIndex(index)
|
|
if (this.isSubmitting) {
|
|
return
|
|
}
|
|
this.executeCommand(func, this.buildSwitchCommandPayload(index, !!value), value ? this.t('deviceOpened') : this.t('deviceClosed'))
|
|
},
|
|
operateCurtain(index = 0, action) {
|
|
const func = this.getDeviceFunctionByIndex(index)
|
|
if (this.isSubmitting) {
|
|
return
|
|
}
|
|
this.executeCommand(
|
|
func,
|
|
this.buildCurtainCommandPayload(index, action),
|
|
action === 'open' ? this.t('curtainOpened') : (action === 'close' ? this.t('curtainClosed') : this.t('curtainPaused'))
|
|
)
|
|
},
|
|
handleControlValueChange(event) {
|
|
this.controlValue = Number((event && event.detail && event.detail.value) || 0)
|
|
},
|
|
submitSlideValue(index = 0) {
|
|
const func = this.getDeviceFunctionByIndex(index)
|
|
if (this.isSubmitting) {
|
|
return
|
|
}
|
|
this.executeCommand(
|
|
func,
|
|
this.buildSlideCommandPayload(index, this.controlValue),
|
|
this.t('sliderUpdated')
|
|
)
|
|
}
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.device-detail-page {
|
|
width: 100vw;
|
|
height: 100vh;
|
|
position: relative;
|
|
overflow: hidden;
|
|
background-color: #0B0C10;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
/* Themes */
|
|
.theme-switch { --theme-color: #4653ff; --theme-glow: rgba(70, 83, 255, 0.4); }
|
|
.theme-slide { --theme-color: #00d37a; --theme-glow: rgba(0, 211, 122, 0.4); }
|
|
.theme-curtain { --theme-color: #ffb800; --theme-glow: rgba(255, 184, 0, 0.4); }
|
|
.theme-unknown { --theme-color: #a3abff; --theme-glow: rgba(163, 171, 255, 0.4); }
|
|
|
|
.bg-blur-circle {
|
|
position: absolute;
|
|
top: 10%;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
width: 600rpx;
|
|
height: 600rpx;
|
|
background: var(--theme-glow);
|
|
filter: blur(100px);
|
|
border-radius: 50%;
|
|
opacity: 0.6;
|
|
z-index: 0;
|
|
}
|
|
.bg-blur-circle-2 {
|
|
position: absolute;
|
|
bottom: -10%;
|
|
right: -20%;
|
|
width: 500rpx;
|
|
height: 500rpx;
|
|
background: rgba(255, 255, 255, 0.05);
|
|
filter: blur(80px);
|
|
border-radius: 50%;
|
|
z-index: 0;
|
|
}
|
|
|
|
.custom-nav {
|
|
position: relative;
|
|
z-index: 10;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
height: 88rpx;
|
|
padding: 0 40rpx;
|
|
}
|
|
|
|
.nav-btn {
|
|
width: 72rpx;
|
|
height: 72rpx;
|
|
border-radius: 50%;
|
|
background: rgba(255, 255, 255, 0.08);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 36rpx;
|
|
color: #fff;
|
|
backdrop-filter: blur(10px);
|
|
transition: background 0.2s;
|
|
}
|
|
.nav-btn:active { background: rgba(255, 255, 255, 0.15); }
|
|
.nav-btn-outline { background: transparent; border: 1px solid rgba(255,255,255,0.1); }
|
|
|
|
.nav-center { flex: 1; text-align: center; padding: 0 20rpx; overflow: hidden; }
|
|
.nav-title { font-size: 34rpx; font-weight: 600; color: #fff; letter-spacing: 2rpx; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: block; }
|
|
|
|
.hero-section {
|
|
position: relative;
|
|
z-index: 10;
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding-bottom: 60rpx;
|
|
}
|
|
|
|
.device-hero-icon {
|
|
width: 220rpx;
|
|
height: 220rpx;
|
|
border-radius: 50%;
|
|
background: linear-gradient(135deg, rgba(255, 255, 255, 0.08) 0%, rgba(255, 255, 255, 0.02) 100%);
|
|
border: 2rpx solid rgba(255, 255, 255, 0.08);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
margin-bottom: 50rpx;
|
|
box-shadow: 0 30rpx 60rpx rgba(0, 0, 0, 0.3), inset 0 4rpx 20rpx rgba(255, 255, 255, 0.05);
|
|
transition: all 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
|
|
backdrop-filter: blur(20px);
|
|
}
|
|
.device-hero-icon text { font-size: 90rpx; color: rgba(255, 255, 255, 0.5); transition: all 0.3s; }
|
|
.device-hero-icon.is-active {
|
|
background: linear-gradient(135deg, rgba(255, 255, 255, 0.15) 0%, rgba(255, 255, 255, 0.05) 100%);
|
|
border-color: rgba(255, 255, 255, 0.3);
|
|
box-shadow: 0 40rpx 80rpx var(--theme-glow), inset 0 4rpx 30rpx rgba(255, 255, 255, 0.2);
|
|
transform: translateY(-16rpx) scale(1.05);
|
|
}
|
|
.device-hero-icon.is-active text {
|
|
color: #fff;
|
|
text-shadow: 0 0 30rpx rgba(255, 255, 255, 0.8), 0 0 60rpx var(--theme-color);
|
|
}
|
|
|
|
.device-status-badge {
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 14rpx 36rpx;
|
|
border-radius: 60rpx;
|
|
background: rgba(0, 0, 0, 0.2);
|
|
border: 1rpx solid rgba(255, 255, 255, 0.1);
|
|
margin-bottom: 20rpx;
|
|
backdrop-filter: blur(10px);
|
|
box-shadow: 0 8rpx 20rpx rgba(0, 0, 0, 0.2);
|
|
transition: all 0.3s;
|
|
}
|
|
.device-status-badge .dot {
|
|
width: 14rpx;
|
|
height: 14rpx;
|
|
border-radius: 50%;
|
|
background: rgba(255, 255, 255, 0.3);
|
|
margin-right: 16rpx;
|
|
transition: all 0.3s;
|
|
}
|
|
.device-status-badge.is-active {
|
|
background: rgba(255, 255, 255, 0.1);
|
|
border-color: rgba(255, 255, 255, 0.2);
|
|
}
|
|
.device-status-badge.is-active .dot {
|
|
background: var(--theme-color);
|
|
box-shadow: 0 0 16rpx var(--theme-color), 0 0 8rpx #fff;
|
|
}
|
|
.device-status-badge text {
|
|
font-size: 28rpx;
|
|
color: rgba(255, 255, 255, 0.9);
|
|
font-weight: 500;
|
|
letter-spacing: 2rpx;
|
|
}
|
|
|
|
.device-room-text {
|
|
font-size: 26rpx;
|
|
color: rgba(255, 255, 255, 0.4);
|
|
letter-spacing: 4rpx;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.bottom-sheet {
|
|
position: relative;
|
|
z-index: 20;
|
|
height: 55vh;
|
|
background: rgba(20, 22, 28, 0.85);
|
|
backdrop-filter: blur(20px);
|
|
border-top-left-radius: 60rpx;
|
|
border-top-right-radius: 60rpx;
|
|
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
|
display: flex;
|
|
flex-direction: column;
|
|
padding-bottom: env(safe-area-inset-bottom);
|
|
}
|
|
|
|
.sheet-handle {
|
|
width: 80rpx;
|
|
height: 8rpx;
|
|
border-radius: 4rpx;
|
|
background: rgba(255, 255, 255, 0.15);
|
|
margin: 24rpx auto 10rpx;
|
|
}
|
|
|
|
.sheet-header {
|
|
padding: 30rpx 50rpx;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: flex-end;
|
|
}
|
|
.sheet-title { font-size: 36rpx; font-weight: 600; color: #fff; }
|
|
.sheet-subtitle { font-size: 26rpx; color: rgba(255, 255, 255, 0.4); }
|
|
|
|
.panel-scroll {
|
|
flex: 1;
|
|
padding: 0 40rpx;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
.control-panel {
|
|
background: rgba(255, 255, 255, 0.04);
|
|
border-radius: 32rpx;
|
|
padding: 40rpx;
|
|
margin-bottom: 30rpx;
|
|
border: 1px solid rgba(255, 255, 255, 0.03);
|
|
}
|
|
.control-panel.is-disabled { opacity: 0.6; pointer-events: none; }
|
|
|
|
.panel-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 40rpx;
|
|
}
|
|
.panel-title { display: flex; align-items: center; font-size: 28rpx; color: #e0e0e6; font-weight: 500; }
|
|
.panel-icon { margin-right: 12rpx; font-size: 32rpx; color: rgba(255,255,255,0.4); }
|
|
.status-tag { font-size: 22rpx; padding: 6rpx 16rpx; background: rgba(255,255,255,0.1); border-radius: 10rpx; color: #aaa; }
|
|
|
|
.switch-row { display: flex; justify-content: center; padding: 20rpx 0; }
|
|
.power-btn-huge {
|
|
width: 160rpx;
|
|
height: 160rpx;
|
|
border-radius: 50%;
|
|
background: rgba(255, 255, 255, 0.05);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 60rpx;
|
|
color: #fff;
|
|
border: 2px solid rgba(255, 255, 255, 0.1);
|
|
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
|
|
box-shadow: inset 0 0 20rpx rgba(0,0,0,0.5);
|
|
}
|
|
.power-btn-huge.is-active {
|
|
background: var(--theme-color);
|
|
border-color: transparent;
|
|
box-shadow: 0 10rpx 40rpx var(--theme-glow), inset 0 0 20rpx rgba(255,255,255,0.2);
|
|
}
|
|
.power-btn-huge.is-disabled { opacity: 0.5; transform: scale(0.95); }
|
|
|
|
.slider-container { display: flex; flex-direction: column; }
|
|
.slider-header { display: flex; justify-content: space-between; margin-bottom: 20rpx; }
|
|
.slider-label { font-size: 26rpx; color: rgba(255,255,255,0.6); }
|
|
.slider-value { font-size: 32rpx; font-weight: 600; color: #fff; }
|
|
.custom-slider { margin: 20rpx 0 40rpx; }
|
|
.slider-apply-btn {
|
|
background: rgba(255, 255, 255, 0.08);
|
|
padding: 24rpx 0;
|
|
border-radius: 20rpx;
|
|
text-align: center;
|
|
font-size: 28rpx;
|
|
color: #fff;
|
|
transition: background 0.2s;
|
|
}
|
|
.slider-apply-btn:active { background: rgba(255, 255, 255, 0.15); }
|
|
|
|
.curtain-row { display: flex; justify-content: space-between; }
|
|
.curtain-btn {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: rgba(255, 255, 255, 0.05);
|
|
border-radius: 24rpx;
|
|
padding: 30rpx 0;
|
|
margin: 0 10rpx;
|
|
transition: all 0.2s;
|
|
border: 1px solid transparent;
|
|
}
|
|
.curtain-btn:first-child { margin-left: 0; }
|
|
.curtain-btn:last-child { margin-right: 0; }
|
|
.curtain-btn text:first-child { font-size: 40rpx; color: #fff; margin-bottom: 12rpx; }
|
|
.btn-label { font-size: 26rpx; color: rgba(255,255,255,0.7); }
|
|
.curtain-btn.is-active {
|
|
background: rgba(255, 255, 255, 0.1);
|
|
border-color: var(--theme-color);
|
|
}
|
|
.curtain-btn.is-active text:first-child { color: var(--theme-color); }
|
|
.curtain-btn:active { transform: scale(0.95); }
|
|
|
|
.unsupported-box {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 30rpx;
|
|
background: rgba(255, 170, 0, 0.1);
|
|
border-radius: 20rpx;
|
|
color: #ffaa00;
|
|
font-size: 26rpx;
|
|
}
|
|
.unsupported-box text:first-child { margin-right: 12rpx; font-size: 32rpx; }
|
|
|
|
.sync-hint {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 40rpx 0;
|
|
color: rgba(255, 255, 255, 0.3);
|
|
font-size: 24rpx;
|
|
}
|
|
.sync-hint text:first-child { margin-right: 10rpx; }
|
|
</style>
|