wenshidp.vue 13 KB

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