wenshidp.vue 12 KB

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