index.vue 18 KB

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