将组件级 i18n 对象重构为统一由全局 locales/messages.js 管理,确保中英文语言切换时正确显示对应文本。 - 新增 utils/i18n.js 国际化工具函数 - 更新 store/$t.mixin.js 翻译混入逻辑 - 迁移 pkgService、pkgBooking、pkgAccount、pkgExplore、pages 等共 58 个组件的中英文文本至全局消息表
659 lines
17 KiB
Vue
659 lines
17 KiB
Vue
<template>
|
|
<view class="smart-page">
|
|
<tn-nav-bar :isBack="true" :bottomShadow="false" backgroundColor="transparent" fontColor="#ffffff">
|
|
<view class="tn-text-bold tn-text-xl">{{ $tr('全屋智能') }}</view>
|
|
</tn-nav-bar>
|
|
|
|
<view class="header-section" :style="{ paddingTop: (vuex_custom_bar_height + 20) + 'px' }">
|
|
<view class="room-info">
|
|
<text class="room-name">{{ displayRoomName }}</text>
|
|
<view class="room-badge">Smart Home</view>
|
|
</view>
|
|
<view class="env-status">{{ summaryText }}</view>
|
|
</view>
|
|
|
|
<view class="toolbar">
|
|
<view class="toolbar-tip">{{ $tr('点击设备进入详情并下发控制指令') }}</view>
|
|
<view class="toolbar-action" @click="reloadDevices">
|
|
<text class="tn-icon-refresh" :class="{'spin-anim': isLoading}" style="margin-right: 8rpx;"></text>
|
|
<text>{{ isLoading ? $tr('加载中') : $tr('刷新状态') }}</text>
|
|
</view>
|
|
</view>
|
|
|
|
<!-- Feedback States -->
|
|
<view v-if="isLoading && !devices.length" class="empty-state">
|
|
<view class="loading-ring"></view>
|
|
<view class="empty-title">{{ $tr('正在连接设备') }}</view>
|
|
<view class="empty-desc">{{ $tr('正在查询当前房间的可控设备...') }}</view>
|
|
</view>
|
|
|
|
<view v-else-if="loadError && !devices.length" class="empty-state error-state">
|
|
<text class="tn-icon-warning empty-icon"></text>
|
|
<view class="empty-title">{{ $tr('设备加载失败') }}</view>
|
|
<view class="empty-desc">{{ loadError }}</view>
|
|
<view class="retry-btn" @click="reloadDevices">{{ $tr('重新加载') }}</view>
|
|
</view>
|
|
|
|
<view v-else-if="!devices.length" class="empty-state">
|
|
<text class="tn-icon-empty empty-icon"></text>
|
|
<view class="empty-title">{{ $tr('暂无可控设备') }}</view>
|
|
<view class="empty-desc">{{ $tr('当前房间未绑定智能设备') }}</view>
|
|
</view>
|
|
|
|
<scroll-view v-else scroll-y class="device-scroll">
|
|
<view class="device-grid">
|
|
<view
|
|
class="device-card"
|
|
:class="{'is-active': item.active}"
|
|
v-for="item in devices"
|
|
:key="item.renderKey"
|
|
@click="goToDetail(item)"
|
|
>
|
|
<view class="card-glow" v-if="item.active"></view>
|
|
<view class="card-top">
|
|
<view class="icon-wrapper" :class="{'is-active': item.active}" @click.stop="toggleDevicePower(item)">
|
|
<text :class="[item.iconClass || 'tn-icon-my-computer']"></text>
|
|
</view>
|
|
<view class="status-dot" :class="{ 'is-on': item.active }"></view>
|
|
</view>
|
|
<view class="card-bottom">
|
|
<view class="device-name">{{ item.deviceName }}</view>
|
|
<view class="device-state" :class="{'is-active': item.active}">{{ item.stateText || (item.active ? $tr('运行中') : $tr('已关闭')) }}</view>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
</scroll-view>
|
|
</view>
|
|
</template>
|
|
|
|
<script>
|
|
import { gatewayRoomDeviceList, gatewayExecuteDeviceCommand } from '../../api/index.js'
|
|
import { getSessionSnapshot } from '../../utils/app-state'
|
|
import {
|
|
normalizeSmartDeviceList,
|
|
getDeviceFunctions,
|
|
getControlTypeCategory,
|
|
buildCommandSchemaFields,
|
|
patchDeviceStatus
|
|
} from '../../utils/smart-device'
|
|
|
|
const DEFAULT_BRAND_ID = 'Tuya'
|
|
|
|
function pickFirstFilledValue(...values) {
|
|
for (let index = 0; index < values.length; index += 1) {
|
|
const value = values[index]
|
|
if (value === undefined || value === null) {
|
|
continue
|
|
}
|
|
const text = String(value).trim()
|
|
if (text) {
|
|
return text
|
|
}
|
|
}
|
|
return ''
|
|
}
|
|
|
|
function buildDeviceRenderKey(item = {}, index = 0) {
|
|
return pickFirstFilledValue(
|
|
item.deviceId,
|
|
item.platformDeviceId,
|
|
item.deviceNo,
|
|
`device-${index}`
|
|
)
|
|
}
|
|
|
|
function buildDeviceCardModel(item = {}, index = 0) {
|
|
return {
|
|
...item,
|
|
renderKey: buildDeviceRenderKey(item, index)
|
|
}
|
|
}
|
|
|
|
function resolveRefreshIntervalMs(value, fallback = 1000) {
|
|
const seconds = Number(value)
|
|
if (!Number.isFinite(seconds) || seconds <= 0) {
|
|
return fallback
|
|
}
|
|
return Math.round(seconds * 1000)
|
|
}
|
|
|
|
function resolveDeviceListPollingInterval(deviceList = [], fallback = 1000) {
|
|
const intervalList = (Array.isArray(deviceList) ? deviceList : [])
|
|
.map(item => resolveRefreshIntervalMs(item && item.refreshInterval, 0))
|
|
.filter(value => value > 0)
|
|
|
|
return intervalList.length ? Math.min(...intervalList) : fallback
|
|
}
|
|
|
|
export default {
|
|
data() {
|
|
return {
|
|
brandId: DEFAULT_BRAND_ID,
|
|
roomCode: '',
|
|
roomName: '',
|
|
devices: [],
|
|
isLoading: false,
|
|
isSyncing: false,
|
|
isSubmitting: false,
|
|
hasLoadedOnce: false,
|
|
loadError: '',
|
|
pollingEnabled: false,
|
|
pollingTimer: null,
|
|
pendingReloadPromise: null,
|
|
pollingIntervalMs: 1000
|
|
}
|
|
},
|
|
computed: {
|
|
language() {
|
|
return this.$getLanguage()
|
|
},
|
|
displayRoomName() {
|
|
return this.roomName || this.roomCode || this.$tr('当前房间')
|
|
},
|
|
summaryText() {
|
|
if (!this.roomCode) {
|
|
return this.$tr('缺少房间编码')
|
|
}
|
|
if (this.isLoading) {
|
|
return this.$tr('正在同步设备状态')
|
|
}
|
|
if (this.devices.length) {
|
|
return this.$tr('共 {count} 台设备,可进入详情页控制', { count: this.devices.length })
|
|
}
|
|
return this.$tr('暂无可控设备')
|
|
}
|
|
},
|
|
onLoad(options) {
|
|
this.resolvePageContext(options || {})
|
|
},
|
|
onShow() {
|
|
this.startPolling(true)
|
|
},
|
|
onPullDownRefresh() {
|
|
this.reloadDevices({
|
|
stopRefresh: true,
|
|
forceLoading: !this.devices.length
|
|
})
|
|
},
|
|
onHide() {
|
|
this.stopPolling()
|
|
},
|
|
onUnload() {
|
|
this.stopPolling()
|
|
},
|
|
methods: {
|
|
resolvePageContext(options = {}) {
|
|
const session = getSessionSnapshot()
|
|
const roomData = (session && session.myRoomData) || {}
|
|
|
|
this.brandId = pickFirstFilledValue(
|
|
options.brandId,
|
|
roomData.brandId,
|
|
roomData.platform,
|
|
roomData.brand,
|
|
DEFAULT_BRAND_ID
|
|
)
|
|
this.roomCode = pickFirstFilledValue(
|
|
options.roomCode,
|
|
roomData.roomCode
|
|
)
|
|
this.roomName = pickFirstFilledValue(
|
|
options.roomName,
|
|
roomData.roomName,
|
|
roomData.roomLabel,
|
|
roomData.roomNo
|
|
)
|
|
},
|
|
clearPollingTimer() {
|
|
if (this.pollingTimer) {
|
|
clearTimeout(this.pollingTimer)
|
|
this.pollingTimer = null
|
|
}
|
|
},
|
|
startPolling(immediate = false) {
|
|
if (this.pollingEnabled) {
|
|
if (immediate) {
|
|
this.pollNext(true)
|
|
}
|
|
return
|
|
}
|
|
|
|
this.pollingEnabled = true
|
|
this.pollNext(immediate)
|
|
},
|
|
stopPolling() {
|
|
this.pollingEnabled = false
|
|
this.clearPollingTimer()
|
|
this.isSyncing = false
|
|
},
|
|
scheduleNextPoll(delay = this.pollingIntervalMs) {
|
|
if (!this.pollingEnabled) {
|
|
return
|
|
}
|
|
|
|
this.clearPollingTimer()
|
|
this.pollingTimer = setTimeout(() => {
|
|
this.pollNext(true)
|
|
}, delay)
|
|
},
|
|
pollNext(immediate = false) {
|
|
if (!this.pollingEnabled) {
|
|
return Promise.resolve()
|
|
}
|
|
|
|
const requestPromise = this.reloadDevices({
|
|
silent: this.hasLoadedOnce,
|
|
forceLoading: !this.hasLoadedOnce,
|
|
keepErrorDevices: true
|
|
})
|
|
|
|
return requestPromise.finally(() => {
|
|
if (!this.pollingEnabled) {
|
|
return
|
|
}
|
|
this.scheduleNextPoll(immediate ? this.pollingIntervalMs : 0)
|
|
})
|
|
},
|
|
reloadDevices(options = {}) {
|
|
const {
|
|
stopRefresh = false,
|
|
silent = false,
|
|
forceLoading = false,
|
|
keepErrorDevices = false
|
|
} = options
|
|
|
|
if (!this.roomCode) {
|
|
this.devices = []
|
|
this.loadError = this.$tr('缺少房间编码')
|
|
if (stopRefresh) {
|
|
uni.stopPullDownRefresh()
|
|
}
|
|
return Promise.resolve()
|
|
}
|
|
|
|
if (this.pendingReloadPromise) {
|
|
return this.pendingReloadPromise.finally(() => {
|
|
if (stopRefresh) {
|
|
uni.stopPullDownRefresh()
|
|
}
|
|
})
|
|
}
|
|
|
|
this.isLoading = forceLoading || (!this.hasLoadedOnce && !silent)
|
|
this.isSyncing = silent && this.hasLoadedOnce
|
|
if (!silent) {
|
|
this.loadError = ''
|
|
}
|
|
|
|
const requestPromise = gatewayRoomDeviceList(this.brandId, this.roomCode)
|
|
.then((response) => {
|
|
const deviceList = normalizeSmartDeviceList(response).map((item, index) => buildDeviceCardModel(item, index))
|
|
this.devices = deviceList
|
|
this.pollingIntervalMs = resolveDeviceListPollingInterval(deviceList)
|
|
this.hasLoadedOnce = true
|
|
if (!silent) {
|
|
this.loadError = ''
|
|
}
|
|
if (!this.roomName && deviceList.length) {
|
|
this.roomName = deviceList[0].roomName || this.roomName
|
|
}
|
|
})
|
|
.catch((error) => {
|
|
console.error('load smart devices failed', error)
|
|
if (!keepErrorDevices) {
|
|
this.devices = []
|
|
}
|
|
if (!silent) {
|
|
this.loadError = (error && (error.message || error.msg)) || this.$tr('设备列表请求失败')
|
|
}
|
|
})
|
|
.finally(() => {
|
|
this.isLoading = false
|
|
this.isSyncing = false
|
|
this.pendingReloadPromise = null
|
|
if (stopRefresh) {
|
|
uni.stopPullDownRefresh()
|
|
}
|
|
})
|
|
|
|
this.pendingReloadPromise = requestPromise
|
|
return requestPromise
|
|
},
|
|
goToDetail(item) {
|
|
const payload = encodeURIComponent(JSON.stringify(item || {}))
|
|
uni.navigateTo({
|
|
url: `/pkgService/device_control/device_control?brandId=${encodeURIComponent(this.brandId)}&roomCode=${encodeURIComponent(this.roomCode)}&roomName=${encodeURIComponent(this.roomName)}&device=${payload}`
|
|
})
|
|
},
|
|
resolveCommandFieldName(func, candidates = [], fallback = 'command') {
|
|
if (!func || !func.commandSchema) return fallback
|
|
const schemaFields = buildCommandSchemaFields(func.commandSchema)
|
|
const fieldNames = schemaFields.map(field => String(field.name || '').trim()).filter(Boolean)
|
|
|
|
for (let i = 0; i < candidates.length; i++) {
|
|
if (fieldNames.includes(candidates[i])) return candidates[i]
|
|
}
|
|
return fieldNames[0] || fallback
|
|
},
|
|
toggleDevicePower(item) {
|
|
if (this.isSubmitting) return
|
|
|
|
const functions = getDeviceFunctions(item)
|
|
if (!functions || !functions.length) {
|
|
uni.showToast({ title: this.$tr('设备暂不支持快捷控制'), icon: 'none' })
|
|
return
|
|
}
|
|
|
|
// Find the first switch-like function
|
|
let targetFunc = functions.find(f => getControlTypeCategory(f.controlType) === 'switch')
|
|
if (!targetFunc) {
|
|
targetFunc = functions[0] // fallback to first function
|
|
}
|
|
|
|
const category = getControlTypeCategory(targetFunc.controlType)
|
|
if (category !== 'switch') {
|
|
uni.showToast({ title: this.$tr('该设备不支持快捷开关'), icon: 'none' })
|
|
return
|
|
}
|
|
|
|
const functionCode = String((targetFunc && targetFunc.functionCode) || '').trim()
|
|
if (!functionCode) {
|
|
uni.showToast({ title: this.$tr('功能编码缺失'), icon: 'none' })
|
|
return
|
|
}
|
|
|
|
const schemaText = (typeof targetFunc.commandSchema === 'string' ? targetFunc.commandSchema : JSON.stringify(targetFunc.commandSchema || '')).toLowerCase()
|
|
const fieldName = this.resolveCommandFieldName(targetFunc, ['command', 'action', 'value'], 'command')
|
|
const hasSchemaForm = buildCommandSchemaFields(targetFunc.commandSchema).length > 0
|
|
|
|
const enabled = !item.active
|
|
let commandPayload = {}
|
|
|
|
if (!hasSchemaForm && schemaText.includes('on/off')) {
|
|
commandPayload = { command: enabled ? 'on' : 'off' }
|
|
} else {
|
|
commandPayload = { [fieldName]: enabled ? 'on' : 'off' }
|
|
}
|
|
|
|
this.isSubmitting = true
|
|
uni.showLoading({ title: this.$tr('发送中...') })
|
|
|
|
gatewayExecuteDeviceCommand(this.brandId, item.deviceNo, functionCode, commandPayload)
|
|
.then(() => {
|
|
const patchedDevice = patchDeviceStatus(item, commandPayload, targetFunc.controlType)
|
|
|
|
// Update device in list
|
|
const index = this.devices.findIndex(d => d.deviceId === item.deviceId)
|
|
if (index !== -1) {
|
|
this.$set(this.devices, index, {
|
|
...patchedDevice,
|
|
renderKey: this.devices[index].renderKey
|
|
})
|
|
}
|
|
uni.showToast({ title: enabled ? this.$tr('已开启') : this.$tr('已关闭'), icon: 'success' })
|
|
})
|
|
.catch((error) => {
|
|
console.error('execute device command failed', error)
|
|
uni.showToast({ title: (error && (error.message || error.msg)) || this.$tr('指令下发失败'), icon: 'none' })
|
|
})
|
|
.finally(() => {
|
|
this.isSubmitting = false
|
|
uni.hideLoading()
|
|
})
|
|
}
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style lang="scss" scoped>
|
|
@keyframes spin {
|
|
0% { transform: rotate(0deg); }
|
|
100% { transform: rotate(360deg); }
|
|
}
|
|
@keyframes pulseGlow {
|
|
0% { opacity: 0.5; transform: scale(0.95); }
|
|
50% { opacity: 0.8; transform: scale(1.05); }
|
|
100% { opacity: 0.5; transform: scale(0.95); }
|
|
}
|
|
|
|
.spin-anim {
|
|
animation: spin 1s linear infinite;
|
|
display: inline-block;
|
|
}
|
|
|
|
.smart-page {
|
|
min-height: 100vh;
|
|
background-color: #0F1015;
|
|
background-image:
|
|
radial-gradient(circle at 80% 0%, rgba(70, 83, 255, 0.15) 0%, transparent 50%),
|
|
radial-gradient(circle at 20% 100%, rgba(70, 83, 255, 0.05) 0%, transparent 50%);
|
|
color: #ffffff;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.header-section {
|
|
padding: 40rpx 40rpx 20rpx;
|
|
position: relative;
|
|
z-index: 1;
|
|
}
|
|
|
|
.room-info {
|
|
display: flex;
|
|
align-items: center;
|
|
margin-bottom: 16rpx;
|
|
}
|
|
|
|
.room-name {
|
|
font-size: 60rpx;
|
|
font-weight: 700;
|
|
letter-spacing: 2rpx;
|
|
background: linear-gradient(135deg, #ffffff 0%, #a5a5b0 100%);
|
|
-webkit-background-clip: text;
|
|
color: transparent;
|
|
margin-right: 20rpx;
|
|
}
|
|
|
|
.room-badge {
|
|
background: rgba(70, 83, 255, 0.2);
|
|
color: #7281ff;
|
|
font-size: 20rpx;
|
|
padding: 4rpx 16rpx;
|
|
border-radius: 20rpx;
|
|
border: 1rpx solid rgba(70, 83, 255, 0.3);
|
|
}
|
|
|
|
.env-status {
|
|
font-size: 28rpx;
|
|
color: #888996;
|
|
}
|
|
|
|
.toolbar {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 20rpx 40rpx;
|
|
margin-bottom: 20rpx;
|
|
}
|
|
|
|
.toolbar-tip {
|
|
font-size: 24rpx;
|
|
color: #666775;
|
|
}
|
|
|
|
.toolbar-action {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 12rpx 24rpx;
|
|
border-radius: 30rpx;
|
|
background: rgba(255, 255, 255, 0.05);
|
|
font-size: 24rpx;
|
|
color: #e0e0e6;
|
|
border: 1rpx solid rgba(255, 255, 255, 0.08);
|
|
transition: all 0.2s;
|
|
}
|
|
.toolbar-action:active {
|
|
background: rgba(255, 255, 255, 0.1);
|
|
transform: scale(0.95);
|
|
}
|
|
|
|
.empty-state {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 100rpx 40rpx;
|
|
}
|
|
.empty-icon {
|
|
font-size: 100rpx;
|
|
color: #444554;
|
|
margin-bottom: 30rpx;
|
|
}
|
|
.empty-title {
|
|
font-size: 32rpx;
|
|
font-weight: 600;
|
|
color: #e0e0e6;
|
|
margin-bottom: 16rpx;
|
|
}
|
|
.empty-desc {
|
|
font-size: 26rpx;
|
|
color: #666775;
|
|
text-align: center;
|
|
}
|
|
.retry-btn {
|
|
margin-top: 40rpx;
|
|
padding: 16rpx 48rpx;
|
|
border-radius: 40rpx;
|
|
background: #4653ff;
|
|
color: #fff;
|
|
font-size: 28rpx;
|
|
font-weight: 500;
|
|
box-shadow: 0 8rpx 24rpx rgba(70, 83, 255, 0.3);
|
|
}
|
|
|
|
.loading-ring {
|
|
width: 80rpx;
|
|
height: 80rpx;
|
|
border: 6rpx solid rgba(255, 255, 255, 0.1);
|
|
border-top-color: #4653ff;
|
|
border-radius: 50%;
|
|
animation: spin 1s linear infinite;
|
|
margin-bottom: 30rpx;
|
|
}
|
|
|
|
.device-scroll {
|
|
flex: 1;
|
|
height: 0;
|
|
}
|
|
|
|
.device-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, 1fr);
|
|
gap: 30rpx;
|
|
padding: 10rpx 40rpx 60rpx;
|
|
}
|
|
|
|
.device-card {
|
|
position: relative;
|
|
background: rgba(255, 255, 255, 0.03);
|
|
border-radius: 40rpx;
|
|
padding: 36rpx;
|
|
height: 280rpx;
|
|
display: flex;
|
|
flex-direction: column;
|
|
justify-content: space-between;
|
|
border: 1px solid rgba(255, 255, 255, 0.05);
|
|
box-sizing: border-box;
|
|
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
|
|
overflow: hidden;
|
|
}
|
|
.device-card:active {
|
|
transform: scale(0.95);
|
|
}
|
|
|
|
.device-card.is-active {
|
|
background: rgba(70, 83, 255, 0.1);
|
|
border-color: rgba(70, 83, 255, 0.4);
|
|
}
|
|
|
|
.card-glow {
|
|
position: absolute;
|
|
top: -20%;
|
|
right: -20%;
|
|
width: 150rpx;
|
|
height: 150rpx;
|
|
background: rgba(70, 83, 255, 0.4);
|
|
filter: blur(40rpx);
|
|
border-radius: 50%;
|
|
animation: pulseGlow 4s infinite ease-in-out;
|
|
}
|
|
|
|
.card-top {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: flex-start;
|
|
position: relative;
|
|
z-index: 2;
|
|
}
|
|
|
|
.icon-wrapper {
|
|
width: 80rpx;
|
|
height: 80rpx;
|
|
border-radius: 24rpx;
|
|
background: rgba(255, 255, 255, 0.05);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
transition: all 0.3s;
|
|
}
|
|
.icon-wrapper text {
|
|
font-size: 44rpx;
|
|
color: #888996;
|
|
}
|
|
.icon-wrapper.is-active {
|
|
background: #4653ff;
|
|
box-shadow: 0 8rpx 20rpx rgba(70, 83, 255, 0.3);
|
|
}
|
|
.icon-wrapper.is-active text {
|
|
color: #ffffff;
|
|
}
|
|
|
|
.status-dot {
|
|
width: 16rpx;
|
|
height: 16rpx;
|
|
border-radius: 50%;
|
|
background: rgba(255, 255, 255, 0.2);
|
|
margin-top: 12rpx;
|
|
margin-right: 4rpx;
|
|
}
|
|
.status-dot.is-on {
|
|
background: #00D37A;
|
|
box-shadow: 0 0 12rpx rgba(0, 211, 122, 0.6);
|
|
}
|
|
|
|
.card-bottom {
|
|
position: relative;
|
|
z-index: 2;
|
|
}
|
|
|
|
.device-name {
|
|
font-size: 32rpx;
|
|
font-weight: 600;
|
|
color: #ffffff;
|
|
margin-bottom: 8rpx;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.device-state {
|
|
font-size: 24rpx;
|
|
color: #666775;
|
|
}
|
|
.device-state.is-active {
|
|
color: #a3abff;
|
|
}
|
|
</style>
|