Files
QSY-lvjuxcx/pkgService/smart_control/smart_control.vue
liangjian 6e721ccc7e refactor: 国际化文本迁移至全局 locales/messages.js
将组件级 i18n 对象重构为统一由全局 locales/messages.js 管理,确保中英文语言切换时正确显示对应文本。

- 新增 utils/i18n.js 国际化工具函数
- 更新 store/$t.mixin.js 翻译混入逻辑
- 迁移 pkgService、pkgBooking、pkgAccount、pkgExplore、pages 等共 58 个组件的中英文文本至全局消息表
2026-07-24 13:43:41 +08:00

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>