Files
QSY-lvjuxcx/pkgService/device_control/device_control.vue
2026-06-29 09:42:53 +08:00

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>