index.vue 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683
  1. <template>
  2. <view class="page-container">
  3. <!-- 顶部状态栏 -->
  4. <view class="header-section">
  5. <view class="header-left">
  6. <text class="device-name">{{ deviceName || '设备名称' }}</text>
  7. <view class="status-badge" :class="statusClass">{{ statusText }}</view>
  8. </view>
  9. <view class="header-right">
  10. <view class="info-item">
  11. <text class="info-label">进度</text>
  12. <text class="info-value">{{ jobProgress}}%</text>
  13. </view>
  14. <view class="info-item">
  15. <text class="info-label">速度</text>
  16. <text class="info-value">{{ deviceSpeed}}m/s</text>
  17. </view>
  18. <view class="info-item">
  19. <text class="info-label">电量</text>
  20. <text class="info-value">{{ batteryLevel}}%</text>
  21. </view>
  22. </view>
  23. </view>
  24. <!-- 地图与控制面板 -->
  25. <view class="main-content">
  26. <view class="map-wrapper">
  27. <view id="mapContainer" class="map-instance"></view>
  28. </view>
  29. <view class="control-panel">
  30. <view class="job-info-card">
  31. <view class="job-info-header">
  32. <text class="job-info-title">{{ jobInfo.taskName || '作业任务名称' }}</text>
  33. <text class="job-info-tag">{{ areaTypeText }}</text>
  34. </view>
  35. <text class="job-info-time">创建于 {{ formatTime(jobInfo.createTime) }}</text>
  36. </view>
  37. <view class="action-buttons-group">
  38. <button class="action-btn" @click="handleAction('pause')" :disabled="!canPause">暂停作业</button>
  39. <button class="action-btn" @click="handleAction('stop')" :disabled="!canStop">停止作业</button>
  40. </view>
  41. <button class="action-btn recall-btn" @click="handleAction('recall')" :disabled="!canRecall">召回设备</button>
  42. <button class="action-btn resume-btn" v-if="jobStatus === 3" @click="handleAction('resume')">继续作业</button>
  43. </view>
  44. </view>
  45. </view>
  46. </template>
  47. <script>
  48. import { getInfo, pauseTask, startTask, stopTask, recallTask, getRealtimeData } from '@/api/services/job.js'
  49. import coordinateUtils from '@/utils/coordinateUtils.js'
  50. export default {
  51. data() {
  52. return {
  53. jobId: null,
  54. deviceId:null,
  55. jobInfo: {},
  56. map: null,
  57. deviceName:'', // 设备名称
  58. // 地图元素
  59. areaPolygon: null, // 作业区域
  60. routePolyline: null, // 规划路线
  61. passedPolyline: null, // 已走路线
  62. deviceMarker: null, // 设备图标
  63. // 状态
  64. amapLoaded: false,
  65. pollingTimer: null,
  66. realtimeTrackPoints: [], // 实时轨迹点:[[lng,lat], ...]
  67. lastReportTime: null,
  68. // 模拟数据
  69. jobProgress: 0,
  70. deviceSpeed: 0.0,
  71. batteryLevel: 0,
  72. // 字典
  73. statusMap: {
  74. 0: '未开始',
  75. 1: '进行中',
  76. 2: '已完成',
  77. 3: '已暂停',
  78. 4: '已停止',
  79. 5: '已取消',
  80. default: '未知'
  81. },
  82. areaTypeMap: {
  83. 1: '回字形',
  84. 2: '弓字形',
  85. 3: '自定义',
  86. 4: '垄沟',
  87. default: '未知区域'
  88. }
  89. }
  90. },
  91. computed: {
  92. jobStatus() {
  93. return this.jobInfo.taskStatus
  94. },
  95. statusText() {
  96. return this.statusMap[this.jobStatus] || this.statusMap.default
  97. },
  98. statusClass() {
  99. return `status-${this.jobStatus}`
  100. },
  101. areaTypeText() {
  102. return this.areaTypeMap[this.jobInfo.workAreas && this.jobInfo.workAreas.areaType || 0] || this.areaTypeMap.default
  103. },
  104. // 按钮可用状态
  105. canPause() {
  106. return this.jobStatus === 1
  107. },
  108. canResume() {
  109. return this.jobStatus === 3
  110. },
  111. canStop() {
  112. return [0, 1, 3].includes(this.jobStatus)
  113. },
  114. canRecall() {
  115. return [0, 1, 3].includes(this.jobStatus)
  116. }
  117. },
  118. onLoad(options) {
  119. if (options.id || options.deviceId) {
  120. this.jobId = options.id
  121. this.deviceId = options.deviceId
  122. this.deviceName = options.deviceName
  123. this.loadJobDetails()
  124. } else {
  125. uni.showToast({ title: '缺少作业ID', icon: 'none' })
  126. uni.navigateBack()
  127. }
  128. },
  129. onReady() {
  130. this.initMap()
  131. },
  132. onUnload() {
  133. this.clearPolling()
  134. },
  135. methods: {
  136. // 加载作业详情
  137. async loadJobDetails() {
  138. uni.showLoading({ title: '加载中...' })
  139. try {
  140. const res = await getInfo(this.jobId)
  141. if (res.data.code === 200 &&res.data.data.workAreas) {
  142. this.jobInfo = res.data.data || {}
  143. // 更新模拟数据
  144. // this.jobProgress = this.jobInfo.progress || 0
  145. // this.deviceSpeed = this.jobInfo.speed || 0.0
  146. // this.batteryLevel = this.jobInfo.battery || 0
  147. this.updateMapElements()
  148. this.setupPolling()
  149. } else {
  150. throw new Error(res.data.msg || '加载失败')
  151. }
  152. } catch (err) {
  153. uni.showToast({ title: err.message, icon: 'none' })
  154. } finally {
  155. uni.hideLoading()
  156. }
  157. },
  158. // 初始化地图
  159. initMap() {
  160. if (window.AMap) {
  161. this.createMap()
  162. } else {
  163. const script = document.createElement('script')
  164. script.src = `https://webapi.amap.com/maps?v=2.0&key=9f2cac7ea18905dd3830cf7360a43a35`
  165. script.onload = this.createMap
  166. document.head.appendChild(script)
  167. }
  168. },
  169. createMap() {
  170. this.map = new AMap.Map('mapContainer', {
  171. zoom: 16,
  172. viewMode: '2D',
  173. pitch: 45
  174. })
  175. this.map.add(new AMap.TileLayer.Satellite())
  176. // ToolBar 在 v2 需要通过 plugin 异步加载;直接 new AMap.ToolBar() 会报:AMap.ToolBar is not a constructor
  177. try {
  178. AMap.plugin(['AMap.ToolBar'], () => {
  179. try {
  180. const toolBar = new AMap.ToolBar()
  181. this.map.addControl(toolBar)
  182. } catch (e) {
  183. // eslint-disable-next-line no-console
  184. console.warn('[job-detail] init toolbar failed', e)
  185. }
  186. })
  187. } catch (e) {
  188. // eslint-disable-next-line no-console
  189. console.warn('[job-detail] AMap.plugin ToolBar failed', e)
  190. }
  191. this.amapLoaded = true
  192. // 地图准备好后,如果作业信息已加载,则渲染一次静态元素 & 启动轮询
  193. if (this.jobInfo && Object.keys(this.jobInfo).length) {
  194. this.updateMapElements()
  195. this.setupPolling()
  196. }
  197. },
  198. // 更新地图元素
  199. updateMapElements() {
  200. // 你当前接口返回:jobInfo.workAreas.waypoints 直接就是点数组:[{lng,lat}, ...]
  201. // 同时兼容未来可能的结构:
  202. // - workAreas 是数组:[{ waypoints:[...] }, ...]
  203. // - workAreas 是对象:{ waypoints:[...] }
  204. if (!this.amapLoaded) return
  205. const workAreas = this.jobInfo && this.jobInfo.workAreas
  206. let points = []
  207. // 情况A:workAreas.waypoints = [{lng,lat}, ...](你现在的情况)
  208. // 注意:waypoints 有可能被后端返回成字符串 / 对象(单点)/ null,先做数组兜底
  209. if (workAreas && workAreas.waypoints) {
  210. try {
  211. // 如果是字符串,尝试解析为 JSON
  212. points = typeof workAreas.waypoints === 'string'
  213. ? JSON.parse(workAreas.waypoints)
  214. : workAreas.waypoints;
  215. // 确保解析后是数组
  216. if (!Array.isArray(points)) {
  217. console.warn('[job-detail] workAreas.waypoints is not an array after parsing:', workAreas.waypoints);
  218. points = [];
  219. }
  220. } catch (e) {
  221. console.error('[job-detail] Failed to parse workAreas.waypoints:', e);
  222. points = [];
  223. }
  224. }
  225. if (!points.length) return
  226. console.log("points",points);
  227. const waypoints = points
  228. .filter(p => p && p.lng != null && p.lat != null)
  229. .map(p => [p.lng, p.lat])
  230. console.log("waypoints",waypoints);
  231. if (waypoints.length <= 2) return
  232. // 1. 绘制作业区域
  233. if (!this.areaPolygon) {
  234. this.areaPolygon = new AMap.Polygon({
  235. map: this.map,
  236. path: waypoints,
  237. strokeColor: '#28F',
  238. strokeWeight: 2,
  239. fillColor: '#28F',
  240. fillOpacity: 0.1
  241. })
  242. } else {
  243. this.areaPolygon.setPath(waypoints)
  244. }
  245. // 2. 绘制规划路线
  246. // 注意:AMap.Polyline 的 path 需要是 [[lng,lat], ...] 或 AMap.LngLat[]。
  247. // 这里统一把 routePoints 归一化成 [[lng,lat], ...],避免出现 undefined[0] 这类错误。
  248. const rawRoutePoints = this.jobInfo.routePoints || []
  249. let routePath = []
  250. if (Array.isArray(rawRoutePoints) && rawRoutePoints.length) {
  251. // 可能是 [{lng,lat},...] 或 [[lng,lat],...]
  252. const first = rawRoutePoints[0]
  253. if (Array.isArray(first)) {
  254. routePath = rawRoutePoints
  255. } else {
  256. routePath = rawRoutePoints
  257. .filter(p => p && p.lng != null && p.lat != null)
  258. .map(p => [p.lng, p.lat])
  259. }
  260. } else {
  261. // 没有 routePoints 就用区域边界 waypoints(它已经是 [[lng,lat],...])
  262. routePath = waypoints
  263. }
  264. if (routePath.length >= 2) {
  265. if (!this.routePolyline) {
  266. this.routePolyline = new AMap.Polyline({
  267. map: this.map,
  268. path: routePath,
  269. showDir: true,
  270. strokeColor: '#28F',
  271. strokeWeight: 6
  272. })
  273. } else {
  274. this.routePolyline.setPath(routePath)
  275. }
  276. }
  277. // 3. 绘制已走轨迹 (假设后端返回 passedPoints)
  278. const rawPassedPoints = this.jobInfo.passedPoints || []
  279. let passedPath = []
  280. if (Array.isArray(rawPassedPoints) && rawPassedPoints.length) {
  281. const first = rawPassedPoints[0]
  282. if (Array.isArray(first)) {
  283. passedPath = rawPassedPoints
  284. } else {
  285. passedPath = rawPassedPoints
  286. .filter(p => p && p.lng != null && p.lat != null)
  287. .map(p => [p.lng, p.lat])
  288. }
  289. }
  290. // 3. 绘制已走轨迹:优先用实时轮询轨迹;否则使用后端历史 passedPoints
  291. const finalPassedPath = (this.realtimeTrackPoints && this.realtimeTrackPoints.length)
  292. ? this.realtimeTrackPoints
  293. : passedPath
  294. if (!this.passedPolyline) {
  295. this.passedPolyline = new AMap.Polyline({
  296. map: this.map,
  297. strokeColor: '#AF5',
  298. strokeWeight: 6
  299. })
  300. }
  301. this.passedPolyline.setPath(finalPassedPath)
  302. // 4. 绘制设备位置
  303. const devicePosition = this.jobInfo.currentPosition
  304. if (devicePosition && devicePosition.lng) {
  305. if (!this.deviceMarker) {
  306. this.deviceMarker = new AMap.Marker({ map: this.map, position: [devicePosition.lng, devicePosition.lat]})
  307. } else {
  308. this.deviceMarker.setPosition([devicePosition.lng, devicePosition.lat])
  309. }
  310. this.map.setCenter([devicePosition.lng, devicePosition.lat])
  311. }
  312. // 首次渲染静态元素时再 fitView,实时更新时不要频繁 fitView
  313. this.map.setFitView()
  314. },
  315. // 设置轮询(实时位置 + 轨迹)
  316. setupPolling() {
  317. // 先清一次,避免重复创建 interval
  318. this.clearPolling()
  319. // 没有 deviceId 就无法拉实时数据
  320. const deviceId = this.deviceId
  321. if (!deviceId) return
  322. // 仅在进行中/暂停时轮询(你也可以放开为所有状态)
  323. if (![1, 3].includes(this.jobStatus)) return
  324. // 立刻拉一次,避免要等第一轮 interval
  325. this.fetchRealtimeAndUpdate()
  326. // 3 秒轮询一次
  327. this.pollingTimer = setInterval(() => {
  328. this.fetchRealtimeAndUpdate()
  329. }, 3000)
  330. },
  331. clearPolling() {
  332. if (this.pollingTimer) {
  333. clearInterval(this.pollingTimer)
  334. this.pollingTimer = null
  335. }
  336. },
  337. async fetchRealtimeAndUpdate() {
  338. try {
  339. console.log("this.jobInfo",this.jobInfo);
  340. const deviceId =this.deviceId
  341. if (!deviceId) return
  342. const res = await getRealtimeData(deviceId)
  343. const payload = res && res.data && (res.data.data || res.data)
  344. if (!payload) return
  345. // 检查作业状态,如果已完成则提示并退出
  346. if (payload.state === 3 || payload.state === 'FINISHED' ) {
  347. this.clearPolling()
  348. uni.showModal({
  349. title: '提示',
  350. content: '当前作业已完成,是否退出当前页面',
  351. success: (modalRes) => {
  352. if (modalRes.confirm) {
  353. uni.navigateBack()
  354. }
  355. }
  356. })
  357. return
  358. }
  359. // 可选:丢弃时间倒退的数据
  360. const reportTime = payload.reportTime
  361. if (reportTime && this.lastReportTime && reportTime < this.lastReportTime) {
  362. return
  363. }
  364. if (reportTime) this.lastReportTime = reportTime
  365. // 更新头部信息(如果接口有)
  366. if (payload.progress != null) this.jobProgress = payload.progress
  367. if (payload.speed != null) this.deviceSpeed = payload.speed
  368. if (payload.battery != null) this.batteryLevel = payload.battery
  369. const pt = payload.currentPoint
  370. if (!pt || pt.x == null || pt.y == null) return
  371. // currentPoint: {x,y} 这里按接口文档理解为 (lng,lat)
  372. const lngLat = [pt.x, pt.y]
  373. // 获取行驶方向
  374. const direction = payload.direction || 0
  375. this.updateDeviceMarker(lngLat, direction)
  376. this.appendTrackPoint(lngLat)
  377. this.updatePassedPolyline()
  378. } catch (e) {
  379. // eslint-disable-next-line no-console
  380. console.warn('[job-detail] fetchRealtimeAndUpdate failed', e)
  381. }
  382. },
  383. updateDeviceMarker(lngLat, direction = 0) {
  384. if (!this.amapLoaded || !this.map) return
  385. if (!lngLat || lngLat.length !== 2) return
  386. // 创建一个 Icon
  387. var startIcon = new AMap.Icon({
  388. // 图标尺寸
  389. size: new AMap.Size(45, 45),
  390. // 图标的取图地址
  391. image: '/static/icons/gecaoji.png',
  392. // 图标所用图片大小
  393. imageSize: new AMap.Size(45, 45),
  394. // 图标取图偏移量
  395. // imageOffset: new AMap.Pixel(-9, -3)
  396. });
  397. if (!this.deviceMarker) {
  398. this.deviceMarker = new AMap.Marker({
  399. map: this.map,
  400. position: lngLat,
  401. icon: startIcon,
  402. anchor: 'center', // 设置锚点为中心,使图标居中显示在路线上
  403. angle: direction // 设置初始旋转角度
  404. })
  405. } else {
  406. this.deviceMarker.setPosition(lngLat)
  407. this.deviceMarker.setAngle(direction) // 更新旋转角度
  408. }
  409. // 轻量跟随:中心跟随但不 fitView,避免每次抖动缩放
  410. this.map.setCenter(lngLat)
  411. },
  412. appendTrackPoint(lngLat) {
  413. if (!lngLat || lngLat.length !== 2) return
  414. const last = this.realtimeTrackPoints.length
  415. ? this.realtimeTrackPoints[this.realtimeTrackPoints.length - 1]
  416. : null
  417. // 去重:相同点不追加
  418. if (last && last[0] === lngLat[0] && last[1] === lngLat[1]) return
  419. this.realtimeTrackPoints.push(lngLat)
  420. },
  421. updatePassedPolyline() {
  422. if (!this.amapLoaded || !this.map) return
  423. // 至少两个点才画线
  424. if (!this.realtimeTrackPoints || this.realtimeTrackPoints.length < 2) return
  425. if (!this.passedPolyline) {
  426. this.passedPolyline = new AMap.Polyline({
  427. map: this.map,
  428. path: this.realtimeTrackPoints,
  429. strokeColor: '#AF5',
  430. strokeWeight: 6
  431. })
  432. } else {
  433. this.passedPolyline.setPath(this.realtimeTrackPoints)
  434. }
  435. },
  436. // 处理按钮操作
  437. async handleAction(action) {
  438. const actions = {
  439. pause: { api: pauseTask, msg: '作业已暂停' },
  440. resume: { api: startTask, msg: '作业已继续' },
  441. stop: { api: stopTask, msg: '作业已停止' },
  442. recall: { api: recallTask, msg: '召回指令已发送' }
  443. }
  444. const currentAction = actions[action]
  445. if (action === 'stop') {
  446. uni.showModal({
  447. title: '确认停止',
  448. content: '停止后无法恢复,确定吗?',
  449. success: res => res.confirm && this.executeAction(currentAction)
  450. })
  451. } else {
  452. this.executeAction(currentAction)
  453. }
  454. },
  455. async executeAction({ api, msg }) {
  456. try {
  457. const res = await api(this.jobId)
  458. if (res.data.code === 200) {
  459. uni.showToast({ title: msg, icon: 'success' })
  460. this.loadJobDetails()
  461. } else {
  462. throw new Error(res.data.msg || '操作失败')
  463. }
  464. } catch (err) {
  465. uni.showToast({ title: err.message, icon: 'none' })
  466. }
  467. },
  468. // 格式化时间
  469. formatTime(timeStr) {
  470. if (!timeStr) return '--'
  471. return timeStr.substring(0, 16)
  472. }
  473. }
  474. }
  475. </script>
  476. <style scoped>
  477. .page-container {
  478. display: flex;
  479. flex-direction: column;
  480. height: 100vh;
  481. background-color: #f4f6f8;
  482. }
  483. .header-section {
  484. display: flex;
  485. justify-content: space-between;
  486. align-items: center;
  487. padding: 20rpx 30rpx;
  488. background-color: #ffffff;
  489. box-shadow: 0 2rpx 10rpx rgba(0,0,0,0.05);
  490. }
  491. .header-left {
  492. display: flex;
  493. align-items: center;
  494. }
  495. .device-name {
  496. font-size: 32rpx;
  497. font-weight: 600;
  498. color: #333;
  499. margin-right: 16rpx;
  500. }
  501. .status-badge {
  502. font-size: 22rpx;
  503. padding: 4rpx 12rpx;
  504. border-radius: 16rpx;
  505. color: #fff;
  506. }
  507. .status-0 { background-color: #1890ff; } /* 未开始 */
  508. .status-1 { background-color: #52c41a; } /* 进行中 */
  509. .status-2 { background-color: #8c8c8c; } /* 已完成 */
  510. .status-3 { background-color: #fa8c16; } /* 已暂停 */
  511. .status-4 { background-color: #f5222d; } /* 已停止 */
  512. .status-5 { background-color: #bfbfbf; } /* 已取消 */
  513. .header-right {
  514. display: flex;
  515. /**gap: 24rpx; **/
  516. }
  517. .info-item {
  518. display: flex;
  519. align-items: baseline;
  520. font-size: 24rpx;
  521. padding-left: 10rpx;
  522. }
  523. .info-label {
  524. color: #888;
  525. margin-right: 8rpx;
  526. }
  527. .info-value {
  528. font-size: 24rpx;
  529. font-weight: 600;
  530. color: #333;
  531. }
  532. .main-content {
  533. flex: 1;
  534. position: relative;
  535. }
  536. .map-wrapper, .map-instance {
  537. width: 100%;
  538. height: 100%;
  539. }
  540. .control-panel {
  541. position: absolute;
  542. bottom: 30rpx;
  543. right: 20rpx;
  544. width: 320rpx;
  545. background-color: rgba(255, 255, 255, 0.9);
  546. border-radius: 20rpx;
  547. padding: 24rpx;
  548. box-shadow: 0 4rpx 20rpx rgba(0,0,0,0.1);
  549. backdrop-filter: blur(10px);
  550. }
  551. .job-info-card {
  552. margin-bottom: 20rpx;
  553. }
  554. .job-info-header {
  555. display: flex;
  556. justify-content: space-between;
  557. align-items: flex-start;
  558. margin-bottom: 8rpx;
  559. }
  560. .job-info-title {
  561. font-size: 28rpx;
  562. font-weight: 600;
  563. color: #333;
  564. flex: 1;
  565. }
  566. .job-info-tag {
  567. font-size: 20rpx;
  568. background-color: #e6f7ff;
  569. color: #1890ff;
  570. padding: 2rpx 8rpx;
  571. border-radius: 6rpx;
  572. margin-left: 10rpx;
  573. }
  574. .job-info-time {
  575. font-size: 22rpx;
  576. color: #888;
  577. }
  578. .action-buttons-group {
  579. display: flex;
  580. gap: 16rpx;
  581. margin-bottom: 16rpx;
  582. }
  583. .action-btn {
  584. flex: 1;
  585. font-size: 24rpx;
  586. padding: 12rpx 0;
  587. margin: 0;
  588. border-radius: 10rpx;
  589. background-color: #1890ff;
  590. color: #fff;
  591. }
  592. .action-btn:disabled {
  593. background-color: #d9d9d9;
  594. color: #8c8c8c;
  595. }
  596. .recall-btn {
  597. width: 100%;
  598. background-color: #faad14;
  599. margin-bottom: 16rpx;
  600. }
  601. .resume-btn {
  602. width: 100%;
  603. background-color: #52c41a;
  604. }
  605. </style>