|
|
@@ -0,0 +1,417 @@
|
|
|
+<template>
|
|
|
+ <view class="greenhouse-page">
|
|
|
+ <custom-card>
|
|
|
+ <block slot="backText">
|
|
|
+ <view class="greenhouse-top__title">
|
|
|
+ {{ dataSource.devName }}
|
|
|
+ </view> </block
|
|
|
+ ><view slot="right">
|
|
|
+ <!-- <view class="ntoNative" @tap="toNavigation">
|
|
|
+ <image :src="location" class="icon"></image>
|
|
|
+ </view> -->
|
|
|
+ </view>
|
|
|
+ </custom-card>
|
|
|
+ <view class="greenhouse-top">
|
|
|
+ <device-card :dataSource="dataSource" @navigation="toNavigation" />
|
|
|
+ </view>
|
|
|
+ <view class="tabs">
|
|
|
+ <view
|
|
|
+ class="tab-item"
|
|
|
+ :class="{ active: currentTab === 'env' }"
|
|
|
+ @click="currentTab = 'env'"
|
|
|
+ >环境要素</view
|
|
|
+ >
|
|
|
+ <view
|
|
|
+ class="tab-item"
|
|
|
+ :class="{ active: currentTab === 'channel' }"
|
|
|
+ @click="currentTab = 'channel'"
|
|
|
+ >大棚控制</view
|
|
|
+ >
|
|
|
+ <view
|
|
|
+ class="tab-item"
|
|
|
+ :class="{ active: currentTab === 'record' }"
|
|
|
+ @click="currentTab = 'record'"
|
|
|
+ >操作记录</view
|
|
|
+ >
|
|
|
+ </view>
|
|
|
+ <!-- 大棚控制:通道卡片 -->
|
|
|
+ <view class="greenhouse-bottom" v-if="currentTab === 'channel'">
|
|
|
+ <view class="greenhouse-bottom-header">
|
|
|
+ 大棚控制
|
|
|
+ </view>
|
|
|
+ <operation-card
|
|
|
+ @change="channelControlHandler"
|
|
|
+ v-for="(item, index) in juanmoList"
|
|
|
+ :key="index"
|
|
|
+ :dourceData="item"
|
|
|
+ />
|
|
|
+ </view>
|
|
|
+ <!-- 环境要素:传感器数据 -->
|
|
|
+ <view class="greenhouse-bottom" v-else-if="currentTab === 'env'">
|
|
|
+ <Element :sensorList="sensorList" title="环境要素" />
|
|
|
+ </view>
|
|
|
+ <!-- 操作记录 -->
|
|
|
+ <view class="greenhouse-bottom" v-else>
|
|
|
+ <RecordList :devBid="devBid" />
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script>
|
|
|
+import Element from './components/element.vue';
|
|
|
+import deviceCard from './components/DeviceCard.vue';
|
|
|
+import OperationCard from './components/OperationCard.vue';
|
|
|
+import RecordList from './components/RecordList.vue';
|
|
|
+
|
|
|
+export default {
|
|
|
+ components: {
|
|
|
+ Element,
|
|
|
+ deviceCard,
|
|
|
+ OperationCard,
|
|
|
+ RecordList,
|
|
|
+ },
|
|
|
+ data() {
|
|
|
+ return {
|
|
|
+ location:
|
|
|
+ 'https://s3.hnyfwlw.com/webstaticimg/bigdata_app/image/zhongshui/location.svg',
|
|
|
+ ws: null,
|
|
|
+ sfToken: '',
|
|
|
+ entityId: '',
|
|
|
+ heartbeatTimer: null,
|
|
|
+ dataArray: [],
|
|
|
+ webSockedData: {},
|
|
|
+ reconnectCount: 0,
|
|
|
+ manualClosed: false,
|
|
|
+ currentTab: 'channel',
|
|
|
+ juanmoList: [],
|
|
|
+ sensorList: [],
|
|
|
+ dataSource: {
|
|
|
+ devStatus: '1',
|
|
|
+ devStatusName: '在线',
|
|
|
+ devName: '-',
|
|
|
+ devCode: '-',
|
|
|
+ devUpdateddate: '-',
|
|
|
+ devProvincealign: '-',
|
|
|
+ },
|
|
|
+ };
|
|
|
+ },
|
|
|
+ methods: {
|
|
|
+ init() {
|
|
|
+ this.getKzgYunshangStatus();
|
|
|
+ this.getYunshangInfo();
|
|
|
+ },
|
|
|
+ // 通道控制:三状态 value 0/1/2,两状态 value 0/1
|
|
|
+ channelControlHandler(prop) {
|
|
|
+ const { dourceData, code, value, field } = prop;
|
|
|
+ // 本地先行更新开关状态,稍后拉取接口校正
|
|
|
+ this.$set(dourceData, field || 'value', value);
|
|
|
+ this.optDevctl({
|
|
|
+ devBid: this.devBid,
|
|
|
+ data: {
|
|
|
+ [code]: value,
|
|
|
+ },
|
|
|
+ });
|
|
|
+ },
|
|
|
+ async optDevctl(payload) {
|
|
|
+ const res = await this.$myRequest({
|
|
|
+ url: '/api/v2/iot/device/kzg/yunshang/devctl/',
|
|
|
+ method: 'post',
|
|
|
+ data: payload,
|
|
|
+ header: {
|
|
|
+ 'Content-Type': 'application/json',
|
|
|
+ },
|
|
|
+ });
|
|
|
+ // 提示
|
|
|
+ uni.showToast({
|
|
|
+ title: res?.msg || '',
|
|
|
+ icon: 'none',
|
|
|
+ duration: 3000,
|
|
|
+ });
|
|
|
+ setTimeout(() => {
|
|
|
+ this.getKzgYunshangStatus();
|
|
|
+ }, 1000);
|
|
|
+ },
|
|
|
+ // 获取设备详情(sfToken 用于 WebSocket 鉴权)
|
|
|
+ async getYunshangInfo() {
|
|
|
+ const res = await this.$myRequest({
|
|
|
+ url: '/api/v2/iot/device/kzg/yunshang/info/',
|
|
|
+ method: 'post',
|
|
|
+ data: {
|
|
|
+ devBid: this.devBid,
|
|
|
+ },
|
|
|
+ });
|
|
|
+ this.dataSource = res;
|
|
|
+ this.sfToken = res?.sfToken;
|
|
|
+ this.entityId = res?.sfUuid;
|
|
|
+ this.closeWebSocket();
|
|
|
+ this.initWebSocket();
|
|
|
+ },
|
|
|
+ toNavigation() {
|
|
|
+ const roundedLongitude =
|
|
|
+ this.dataSource.devLngalign || this.dataSource.devLng;
|
|
|
+ const roundedLatitude =
|
|
|
+ this.dataSource.devLatalign || this.dataSource.devLat;
|
|
|
+ uni.openLocation({
|
|
|
+ latitude: Number(roundedLatitude || 0),
|
|
|
+ longitude: Number(roundedLongitude || 0),
|
|
|
+ name: '',
|
|
|
+ address: '',
|
|
|
+ scale: 18, // 地图缩放级别
|
|
|
+ success: () => console.log('导航启动成功'),
|
|
|
+ fail: (err) => console.error('导航失败:', err),
|
|
|
+ });
|
|
|
+ // if (!roundedLongitude) {
|
|
|
+ // uni.showToast({
|
|
|
+ // title: '当前设备无定位信息',
|
|
|
+ // icon: 'none',
|
|
|
+ // duration: 3000,
|
|
|
+ // });
|
|
|
+ // return;
|
|
|
+ // }
|
|
|
+ // if (
|
|
|
+ // plus.runtime.isApplicationExist({
|
|
|
+ // pname: 'com.autonavi.minimap',
|
|
|
+ // action: 'iosamap://',
|
|
|
+ // })
|
|
|
+ // ) {
|
|
|
+ // let url = `amapuri://route/plan?sourceApplication=yourAppName&dlat=${roundedLatitude}&dlon=${roundedLongitude}&dev=0&t=0&style=0&start=&auto=1&rtdType=1&coordinate=wgs84`;
|
|
|
+ // plus.runtime.openURL(url);
|
|
|
+ // } else if (
|
|
|
+ // plus.runtime.isApplicationExist({
|
|
|
+ // pname: 'com.baidu.BaiduMap',
|
|
|
+ // action: 'baidumap://',
|
|
|
+ // })
|
|
|
+ // ) {
|
|
|
+ // let url = `baidumap://map/direction?destination=${roundedLongitude},${roundedLatitude}&mode=navigation&output=html&coord_type=wgs84`;
|
|
|
+ // plus.runtime.openURL(url);
|
|
|
+ // } else {
|
|
|
+ // uni.showToast({
|
|
|
+ // title: '当前未安装 高德地图或百度地图 无法导航',
|
|
|
+ // icon: 'none',
|
|
|
+ // duration: 3000,
|
|
|
+ // });
|
|
|
+ // }
|
|
|
+ },
|
|
|
+ // 获取控制柜状态信息(只取 channel_list,sensor_list 不处理)
|
|
|
+ async getKzgYunshangStatus() {
|
|
|
+ const res = await this.$myRequest({
|
|
|
+ url: '/api/v2/iot/device/kzg/yunshang/status/',
|
|
|
+ method: 'post',
|
|
|
+ data: {
|
|
|
+ devBid: this.devBid,
|
|
|
+ },
|
|
|
+ });
|
|
|
+ this.dataArray = res?.channel_list || [];
|
|
|
+ this.juanmoList = this.dataArray;
|
|
|
+ this.sensorList = res?.sensor_list || [];
|
|
|
+ },
|
|
|
+ initWebSocket() {
|
|
|
+ this.manualClosed = false;
|
|
|
+ const url = 'wss://things.ysiot.net:18080/api/ws';
|
|
|
+ this.ws = uni.connectSocket({
|
|
|
+ url: url,
|
|
|
+ success: () => {
|
|
|
+ console.log('WebSocket 连接初始化成功');
|
|
|
+ },
|
|
|
+ });
|
|
|
+
|
|
|
+ uni.onSocketOpen(() => {
|
|
|
+ console.log('WebSocket 已连接');
|
|
|
+ // 订阅设备实时遥测,鉴权 token 来自 kzg/yunshang/info 接口
|
|
|
+ const params = {
|
|
|
+ cmds: [
|
|
|
+ {
|
|
|
+ type: 'TIMESERIES',
|
|
|
+ entityType: 'DEVICE',
|
|
|
+ entityId: this.entityId,
|
|
|
+ scope: 'LATEST_TELEMETRY',
|
|
|
+ cmdId: 1,
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ authCmd: {
|
|
|
+ cmdId: 0,
|
|
|
+ token: this.sfToken,
|
|
|
+ },
|
|
|
+ };
|
|
|
+ uni.sendSocketMessage({
|
|
|
+ data: JSON.stringify(params),
|
|
|
+ });
|
|
|
+
|
|
|
+ // 心跳:每 30 秒 ping 一次
|
|
|
+ this.heartbeatTimer = setInterval(() => {
|
|
|
+ uni.sendSocketMessage({
|
|
|
+ data: JSON.stringify({ type: 'ping' }),
|
|
|
+ });
|
|
|
+ }, 30 * 1000);
|
|
|
+ });
|
|
|
+
|
|
|
+ uni.onSocketMessage((evt) => {
|
|
|
+ try {
|
|
|
+ const data = JSON.parse(evt.data);
|
|
|
+ this.webSockedData = data;
|
|
|
+ // 收到实时数据后刷新控制柜状态
|
|
|
+ if (this.dataArray.length) {
|
|
|
+ this.mergeTwoObject(this.dataArray, this.webSockedData?.data);
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ console.error('WebSocket 消息解析失败', e);
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ uni.onSocketError((e) => {
|
|
|
+ console.error('WebSocket 错误', e);
|
|
|
+ });
|
|
|
+
|
|
|
+ uni.onSocketClose(() => {
|
|
|
+ console.warn('WebSocket 已断开,3 秒后尝试重连');
|
|
|
+ clearInterval(this.heartbeatTimer);
|
|
|
+ if (this.manualClosed) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ this.reconnectCount = (this.reconnectCount || 0) + 1;
|
|
|
+ if (this.reconnectCount <= 10) {
|
|
|
+ setTimeout(() => this.initWebSocket(), 3000);
|
|
|
+ } else {
|
|
|
+ console.warn('WebSocket 重连次数已达上限,停止重连');
|
|
|
+ }
|
|
|
+ });
|
|
|
+ },
|
|
|
+ // 用 WebSocket 实时数据按 ChannelParamSetXX:Status 匹配 channel_list 更新状态
|
|
|
+ mergeTwoObject(firstObject, secondObject) {
|
|
|
+ // 构建 key 到通道的映射,实现 O(1) 查找
|
|
|
+ const codeMap = new Map();
|
|
|
+ const buildMap = (items) => {
|
|
|
+ for (const item of items) {
|
|
|
+ // Status key:优先同名属性,其次 code/sfCode 标识(值在 value 里)
|
|
|
+ let statusKey = Object.keys(item).find((k) =>
|
|
|
+ /^ChannelParamSet\d+:Status$/.test(k)
|
|
|
+ );
|
|
|
+ let statusField = statusKey || '';
|
|
|
+ if (!statusKey) {
|
|
|
+ statusKey = [item.code, item.sfCode].find((v) =>
|
|
|
+ /^ChannelParamSet\d+:Status$/.test(String(v || ''))
|
|
|
+ );
|
|
|
+ statusField = 'value';
|
|
|
+ }
|
|
|
+ if (statusKey) {
|
|
|
+ codeMap.set(statusKey, { item, statusField });
|
|
|
+ }
|
|
|
+ }
|
|
|
+ };
|
|
|
+ buildMap(firstObject);
|
|
|
+ // 遍历 secondObject 并更新值
|
|
|
+ for (const key in secondObject) {
|
|
|
+ let target = codeMap.get(key);
|
|
|
+ // 兼容不带后缀的上报 key
|
|
|
+ if (!target && /^ChannelParamSet\d+$/.test(key)) {
|
|
|
+ target = codeMap.get(`${key}:Status`);
|
|
|
+ }
|
|
|
+ if (!target) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ const val = secondObject[key]?.[0]?.[1];
|
|
|
+ if (val === undefined) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ this.$set(target.item, target.statusField, val);
|
|
|
+ }
|
|
|
+ },
|
|
|
+ // 关闭 WebSocket
|
|
|
+ closeWebSocket() {
|
|
|
+ this.manualClosed = true;
|
|
|
+ clearInterval(this.heartbeatTimer);
|
|
|
+ if (this.ws) {
|
|
|
+ uni.closeSocket();
|
|
|
+ this.ws = null;
|
|
|
+ }
|
|
|
+ },
|
|
|
+ },
|
|
|
+ onLoad(query) {
|
|
|
+ this.devBid = query.devBid;
|
|
|
+ this.devBid && this.init();
|
|
|
+ },
|
|
|
+ onUnload() {
|
|
|
+ this.manualClosed = true;
|
|
|
+ this.closeWebSocket();
|
|
|
+ },
|
|
|
+};
|
|
|
+</script>
|
|
|
+<style scoped lang="scss">
|
|
|
+uni-page-body {
|
|
|
+ position: relative;
|
|
|
+ height: 100%;
|
|
|
+}
|
|
|
+.icon {
|
|
|
+ width: 40rpx;
|
|
|
+ height: 40rpx;
|
|
|
+}
|
|
|
+.tabs {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ margin: 24rpx 30rpx 0;
|
|
|
+ padding: 8rpx 16rpx;
|
|
|
+ background: #ffffff;
|
|
|
+ border-radius: 24rpx;
|
|
|
+ .tab-item {
|
|
|
+ flex: 1;
|
|
|
+ height: 80rpx;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ position: relative;
|
|
|
+ color: #999999;
|
|
|
+ font-family: 'Source Han Sans CN VF';
|
|
|
+ font-size: 28rpx;
|
|
|
+ font-weight: 400;
|
|
|
+ &.active {
|
|
|
+ color: #042118;
|
|
|
+ font-weight: 700;
|
|
|
+ &::after {
|
|
|
+ content: '';
|
|
|
+ position: absolute;
|
|
|
+ bottom: 8rpx;
|
|
|
+ left: 50%;
|
|
|
+ transform: translateX(-50%);
|
|
|
+ width: 48rpx;
|
|
|
+ height: 8rpx;
|
|
|
+ border-radius: 4rpx;
|
|
|
+ background: #0bbc58;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+.greenhouse-page {
|
|
|
+ background: linear-gradient(
|
|
|
+ 180deg,
|
|
|
+ #ffffff00 0%,
|
|
|
+ #eff2fa 23.64%,
|
|
|
+ #eff2fa 100%
|
|
|
+ ),
|
|
|
+ linear-gradient(102deg, #bfeadd 6.77%, #b8f1e7 40.15%, #b9eef5 84.02%);
|
|
|
+ min-height: 100vh;
|
|
|
+ width: 100%;
|
|
|
+ overflow-x: hidden;
|
|
|
+ overflow-y: scroll;
|
|
|
+ .greenhouse-top {
|
|
|
+ text-align: center;
|
|
|
+ margin: 0 auto;
|
|
|
+ margin-top: 18rpx;
|
|
|
+ padding: 0 30rpx;
|
|
|
+ }
|
|
|
+ .greenhouse-bottom {
|
|
|
+ width: calc(100% - 128rpx);
|
|
|
+ padding: 32rpx 32rpx 1rpx 32rpx;
|
|
|
+ background: #ffffff;
|
|
|
+ border-radius: 16rpx;
|
|
|
+ margin: 0 auto;
|
|
|
+ margin-top: 32rpx;
|
|
|
+ .greenhouse-bottom-header {
|
|
|
+ color: #042118;
|
|
|
+ font-family: 'Source Han Sans CN VF';
|
|
|
+ font-size: 28rpx;
|
|
|
+ font-weight: 700;
|
|
|
+ margin-bottom: 24rpx;
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+</style>
|