wenshidp.vue 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417
  1. <template>
  2. <view class="greenhouse-page">
  3. <custom-card>
  4. <block slot="backText">
  5. <view class="greenhouse-top__title">
  6. {{ dataSource.devName }}
  7. </view> </block
  8. ><view slot="right">
  9. <!-- <view class="ntoNative" @tap="toNavigation">
  10. <image :src="location" class="icon"></image>
  11. </view> -->
  12. </view>
  13. </custom-card>
  14. <view class="greenhouse-top">
  15. <device-card :dataSource="dataSource" @navigation="toNavigation" />
  16. </view>
  17. <view class="tabs">
  18. <view
  19. class="tab-item"
  20. :class="{ active: currentTab === 'env' }"
  21. @click="currentTab = 'env'"
  22. >环境要素</view
  23. >
  24. <view
  25. class="tab-item"
  26. :class="{ active: currentTab === 'channel' }"
  27. @click="currentTab = 'channel'"
  28. >大棚控制</view
  29. >
  30. <view
  31. class="tab-item"
  32. :class="{ active: currentTab === 'record' }"
  33. @click="currentTab = 'record'"
  34. >操作记录</view
  35. >
  36. </view>
  37. <!-- 大棚控制:通道卡片 -->
  38. <view class="greenhouse-bottom" v-if="currentTab === 'channel'">
  39. <view class="greenhouse-bottom-header">
  40. 大棚控制
  41. </view>
  42. <operation-card
  43. @change="channelControlHandler"
  44. v-for="(item, index) in juanmoList"
  45. :key="index"
  46. :dourceData="item"
  47. />
  48. </view>
  49. <!-- 环境要素:传感器数据 -->
  50. <view class="greenhouse-bottom" v-else-if="currentTab === 'env'">
  51. <Element :sensorList="sensorList" title="环境要素" />
  52. </view>
  53. <!-- 操作记录 -->
  54. <view class="greenhouse-bottom" v-else>
  55. <RecordList :devBid="devBid" />
  56. </view>
  57. </view>
  58. </template>
  59. <script>
  60. import Element from './components/element.vue';
  61. import deviceCard from './components/DeviceCard.vue';
  62. import OperationCard from './components/OperationCard.vue';
  63. import RecordList from './components/RecordList.vue';
  64. export default {
  65. components: {
  66. Element,
  67. deviceCard,
  68. OperationCard,
  69. RecordList,
  70. },
  71. data() {
  72. return {
  73. location:
  74. 'https://s3.hnyfwlw.com/webstaticimg/bigdata_app/image/zhongshui/location.svg',
  75. ws: null,
  76. sfToken: '',
  77. entityId: '',
  78. heartbeatTimer: null,
  79. dataArray: [],
  80. webSockedData: {},
  81. reconnectCount: 0,
  82. manualClosed: false,
  83. currentTab: 'channel',
  84. juanmoList: [],
  85. sensorList: [],
  86. dataSource: {
  87. devStatus: '1',
  88. devStatusName: '在线',
  89. devName: '-',
  90. devCode: '-',
  91. devUpdateddate: '-',
  92. devProvincealign: '-',
  93. },
  94. };
  95. },
  96. methods: {
  97. init() {
  98. this.getKzgYunshangStatus();
  99. this.getYunshangInfo();
  100. },
  101. // 通道控制:三状态 value 0/1/2,两状态 value 0/1
  102. channelControlHandler(prop) {
  103. const { dourceData, code, value, field } = prop;
  104. // 本地先行更新开关状态,稍后拉取接口校正
  105. this.$set(dourceData, field || 'value', value);
  106. this.optDevctl({
  107. devBid: this.devBid,
  108. data: {
  109. [code]: value,
  110. },
  111. });
  112. },
  113. async optDevctl(payload) {
  114. const res = await this.$myRequest({
  115. url: '/api/v2/iot/device/kzg/yunshang/devctl/',
  116. method: 'post',
  117. data: payload,
  118. header: {
  119. 'Content-Type': 'application/json',
  120. },
  121. });
  122. // 提示
  123. uni.showToast({
  124. title: res?.msg || '',
  125. icon: 'none',
  126. duration: 3000,
  127. });
  128. setTimeout(() => {
  129. this.getKzgYunshangStatus();
  130. }, 1000);
  131. },
  132. // 获取设备详情(sfToken 用于 WebSocket 鉴权)
  133. async getYunshangInfo() {
  134. const res = await this.$myRequest({
  135. url: '/api/v2/iot/device/kzg/yunshang/info/',
  136. method: 'post',
  137. data: {
  138. devBid: this.devBid,
  139. },
  140. });
  141. this.dataSource = res;
  142. this.sfToken = res?.sfToken;
  143. this.entityId = res?.sfUuid;
  144. this.closeWebSocket();
  145. this.initWebSocket();
  146. },
  147. toNavigation() {
  148. const roundedLongitude =
  149. this.dataSource.devLngalign || this.dataSource.devLng;
  150. const roundedLatitude =
  151. this.dataSource.devLatalign || this.dataSource.devLat;
  152. uni.openLocation({
  153. latitude: Number(roundedLatitude || 0),
  154. longitude: Number(roundedLongitude || 0),
  155. name: '',
  156. address: '',
  157. scale: 18, // 地图缩放级别
  158. success: () => console.log('导航启动成功'),
  159. fail: (err) => console.error('导航失败:', err),
  160. });
  161. // if (!roundedLongitude) {
  162. // uni.showToast({
  163. // title: '当前设备无定位信息',
  164. // icon: 'none',
  165. // duration: 3000,
  166. // });
  167. // return;
  168. // }
  169. // if (
  170. // plus.runtime.isApplicationExist({
  171. // pname: 'com.autonavi.minimap',
  172. // action: 'iosamap://',
  173. // })
  174. // ) {
  175. // let url = `amapuri://route/plan?sourceApplication=yourAppName&dlat=${roundedLatitude}&dlon=${roundedLongitude}&dev=0&t=0&style=0&start=&auto=1&rtdType=1&coordinate=wgs84`;
  176. // plus.runtime.openURL(url);
  177. // } else if (
  178. // plus.runtime.isApplicationExist({
  179. // pname: 'com.baidu.BaiduMap',
  180. // action: 'baidumap://',
  181. // })
  182. // ) {
  183. // let url = `baidumap://map/direction?destination=${roundedLongitude},${roundedLatitude}&mode=navigation&output=html&coord_type=wgs84`;
  184. // plus.runtime.openURL(url);
  185. // } else {
  186. // uni.showToast({
  187. // title: '当前未安装 高德地图或百度地图 无法导航',
  188. // icon: 'none',
  189. // duration: 3000,
  190. // });
  191. // }
  192. },
  193. // 获取控制柜状态信息(只取 channel_list,sensor_list 不处理)
  194. async getKzgYunshangStatus() {
  195. const res = await this.$myRequest({
  196. url: '/api/v2/iot/device/kzg/yunshang/status/',
  197. method: 'post',
  198. data: {
  199. devBid: this.devBid,
  200. },
  201. });
  202. this.dataArray = res?.channel_list || [];
  203. this.juanmoList = this.dataArray;
  204. this.sensorList = res?.sensor_list || [];
  205. },
  206. initWebSocket() {
  207. this.manualClosed = false;
  208. const url = 'wss://things.ysiot.net:18080/api/ws';
  209. this.ws = uni.connectSocket({
  210. url: url,
  211. success: () => {
  212. console.log('WebSocket 连接初始化成功');
  213. },
  214. });
  215. uni.onSocketOpen(() => {
  216. console.log('WebSocket 已连接');
  217. // 订阅设备实时遥测,鉴权 token 来自 kzg/yunshang/info 接口
  218. const params = {
  219. cmds: [
  220. {
  221. type: 'TIMESERIES',
  222. entityType: 'DEVICE',
  223. entityId: this.entityId,
  224. scope: 'LATEST_TELEMETRY',
  225. cmdId: 1,
  226. },
  227. ],
  228. authCmd: {
  229. cmdId: 0,
  230. token: this.sfToken,
  231. },
  232. };
  233. uni.sendSocketMessage({
  234. data: JSON.stringify(params),
  235. });
  236. // 心跳:每 30 秒 ping 一次
  237. this.heartbeatTimer = setInterval(() => {
  238. uni.sendSocketMessage({
  239. data: JSON.stringify({ type: 'ping' }),
  240. });
  241. }, 30 * 1000);
  242. });
  243. uni.onSocketMessage((evt) => {
  244. try {
  245. const data = JSON.parse(evt.data);
  246. this.webSockedData = data;
  247. // 收到实时数据后刷新控制柜状态
  248. if (this.dataArray.length) {
  249. this.mergeTwoObject(this.dataArray, this.webSockedData?.data);
  250. }
  251. } catch (e) {
  252. console.error('WebSocket 消息解析失败', e);
  253. }
  254. });
  255. uni.onSocketError((e) => {
  256. console.error('WebSocket 错误', e);
  257. });
  258. uni.onSocketClose(() => {
  259. console.warn('WebSocket 已断开,3 秒后尝试重连');
  260. clearInterval(this.heartbeatTimer);
  261. if (this.manualClosed) {
  262. return;
  263. }
  264. this.reconnectCount = (this.reconnectCount || 0) + 1;
  265. if (this.reconnectCount <= 10) {
  266. setTimeout(() => this.initWebSocket(), 3000);
  267. } else {
  268. console.warn('WebSocket 重连次数已达上限,停止重连');
  269. }
  270. });
  271. },
  272. // 用 WebSocket 实时数据按 ChannelParamSetXX:Status 匹配 channel_list 更新状态
  273. mergeTwoObject(firstObject, secondObject) {
  274. // 构建 key 到通道的映射,实现 O(1) 查找
  275. const codeMap = new Map();
  276. const buildMap = (items) => {
  277. for (const item of items) {
  278. // Status key:优先同名属性,其次 code/sfCode 标识(值在 value 里)
  279. let statusKey = Object.keys(item).find((k) =>
  280. /^ChannelParamSet\d+:Status$/.test(k)
  281. );
  282. let statusField = statusKey || '';
  283. if (!statusKey) {
  284. statusKey = [item.code, item.sfCode].find((v) =>
  285. /^ChannelParamSet\d+:Status$/.test(String(v || ''))
  286. );
  287. statusField = 'value';
  288. }
  289. if (statusKey) {
  290. codeMap.set(statusKey, { item, statusField });
  291. }
  292. }
  293. };
  294. buildMap(firstObject);
  295. // 遍历 secondObject 并更新值
  296. for (const key in secondObject) {
  297. let target = codeMap.get(key);
  298. // 兼容不带后缀的上报 key
  299. if (!target && /^ChannelParamSet\d+$/.test(key)) {
  300. target = codeMap.get(`${key}:Status`);
  301. }
  302. if (!target) {
  303. continue;
  304. }
  305. const val = secondObject[key]?.[0]?.[1];
  306. if (val === undefined) {
  307. continue;
  308. }
  309. this.$set(target.item, target.statusField, val);
  310. }
  311. },
  312. // 关闭 WebSocket
  313. closeWebSocket() {
  314. this.manualClosed = true;
  315. clearInterval(this.heartbeatTimer);
  316. if (this.ws) {
  317. uni.closeSocket();
  318. this.ws = null;
  319. }
  320. },
  321. },
  322. onLoad(query) {
  323. this.devBid = query.devBid;
  324. this.devBid && this.init();
  325. },
  326. onUnload() {
  327. this.manualClosed = true;
  328. this.closeWebSocket();
  329. },
  330. };
  331. </script>
  332. <style scoped lang="scss">
  333. uni-page-body {
  334. position: relative;
  335. height: 100%;
  336. }
  337. .icon {
  338. width: 40rpx;
  339. height: 40rpx;
  340. }
  341. .tabs {
  342. display: flex;
  343. align-items: center;
  344. margin: 24rpx 30rpx 0;
  345. padding: 8rpx 16rpx;
  346. background: #ffffff;
  347. border-radius: 24rpx;
  348. .tab-item {
  349. flex: 1;
  350. height: 80rpx;
  351. display: flex;
  352. align-items: center;
  353. justify-content: center;
  354. position: relative;
  355. color: #999999;
  356. font-family: 'Source Han Sans CN VF';
  357. font-size: 28rpx;
  358. font-weight: 400;
  359. &.active {
  360. color: #042118;
  361. font-weight: 700;
  362. &::after {
  363. content: '';
  364. position: absolute;
  365. bottom: 8rpx;
  366. left: 50%;
  367. transform: translateX(-50%);
  368. width: 48rpx;
  369. height: 8rpx;
  370. border-radius: 4rpx;
  371. background: #0bbc58;
  372. }
  373. }
  374. }
  375. }
  376. .greenhouse-page {
  377. background: linear-gradient(
  378. 180deg,
  379. #ffffff00 0%,
  380. #eff2fa 23.64%,
  381. #eff2fa 100%
  382. ),
  383. linear-gradient(102deg, #bfeadd 6.77%, #b8f1e7 40.15%, #b9eef5 84.02%);
  384. min-height: 100vh;
  385. width: 100%;
  386. overflow-x: hidden;
  387. overflow-y: scroll;
  388. .greenhouse-top {
  389. text-align: center;
  390. margin: 0 auto;
  391. margin-top: 18rpx;
  392. padding: 0 30rpx;
  393. }
  394. .greenhouse-bottom {
  395. width: calc(100% - 128rpx);
  396. padding: 32rpx 32rpx 1rpx 32rpx;
  397. background: #ffffff;
  398. border-radius: 16rpx;
  399. margin: 0 auto;
  400. margin-top: 32rpx;
  401. .greenhouse-bottom-header {
  402. color: #042118;
  403. font-family: 'Source Han Sans CN VF';
  404. font-size: 28rpx;
  405. font-weight: 700;
  406. margin-bottom: 24rpx;
  407. }
  408. }
  409. }
  410. </style>