| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683 |
- <template>
- <view class="page-container">
- <!-- 顶部状态栏 -->
- <view class="header-section">
- <view class="header-left">
- <text class="device-name">{{ deviceName || '设备名称' }}</text>
- <view class="status-badge" :class="statusClass">{{ statusText }}</view>
- </view>
- <view class="header-right">
- <view class="info-item">
- <text class="info-label">进度</text>
- <text class="info-value">{{ jobProgress}}%</text>
- </view>
- <view class="info-item">
- <text class="info-label">速度</text>
- <text class="info-value">{{ deviceSpeed}}m/s</text>
- </view>
- <view class="info-item">
- <text class="info-label">电量</text>
- <text class="info-value">{{ batteryLevel}}%</text>
- </view>
- </view>
- </view>
- <!-- 地图与控制面板 -->
- <view class="main-content">
- <view class="map-wrapper">
- <view id="mapContainer" class="map-instance"></view>
- </view>
-
- <view class="control-panel">
- <view class="job-info-card">
- <view class="job-info-header">
- <text class="job-info-title">{{ jobInfo.taskName || '作业任务名称' }}</text>
- <text class="job-info-tag">{{ areaTypeText }}</text>
- </view>
- <text class="job-info-time">创建于 {{ formatTime(jobInfo.createTime) }}</text>
- </view>
- <view class="action-buttons-group">
- <button class="action-btn" @click="handleAction('pause')" :disabled="!canPause">暂停作业</button>
- <button class="action-btn" @click="handleAction('stop')" :disabled="!canStop">停止作业</button>
- </view>
- <button class="action-btn recall-btn" @click="handleAction('recall')" :disabled="!canRecall">召回设备</button>
- <button class="action-btn resume-btn" v-if="jobStatus === 3" @click="handleAction('resume')">继续作业</button>
- </view>
- </view>
- </view>
- </template>
- <script>
- import { getInfo, pauseTask, startTask, stopTask, recallTask, getRealtimeData } from '@/api/services/job.js'
- import coordinateUtils from '@/utils/coordinateUtils.js'
- export default {
- data() {
- return {
- jobId: null,
- deviceId:null,
- jobInfo: {},
- map: null,
- deviceName:'', // 设备名称
- // 地图元素
- areaPolygon: null, // 作业区域
- routePolyline: null, // 规划路线
- passedPolyline: null, // 已走路线
- deviceMarker: null, // 设备图标
- // 状态
- amapLoaded: false,
- pollingTimer: null,
- realtimeTrackPoints: [], // 实时轨迹点:[[lng,lat], ...]
- lastReportTime: null,
- // 模拟数据
- jobProgress: 0,
- deviceSpeed: 0.0,
- batteryLevel: 0,
- // 字典
- statusMap: {
- 0: '未开始',
- 1: '进行中',
- 2: '已完成',
- 3: '已暂停',
- 4: '已停止',
- 5: '已取消',
- default: '未知'
- },
- areaTypeMap: {
- 1: '回字形',
- 2: '弓字形',
- 3: '自定义',
- 4: '垄沟',
- default: '未知区域'
- }
- }
- },
- computed: {
- jobStatus() {
- return this.jobInfo.taskStatus
- },
- statusText() {
- return this.statusMap[this.jobStatus] || this.statusMap.default
- },
- statusClass() {
- return `status-${this.jobStatus}`
- },
- areaTypeText() {
- return this.areaTypeMap[this.jobInfo.workAreas && this.jobInfo.workAreas.areaType || 0] || this.areaTypeMap.default
- },
- // 按钮可用状态
- canPause() {
- return this.jobStatus === 1
- },
- canResume() {
- return this.jobStatus === 3
- },
- canStop() {
- return [0, 1, 3].includes(this.jobStatus)
- },
- canRecall() {
- return [0, 1, 3].includes(this.jobStatus)
- }
- },
- onLoad(options) {
- if (options.id || options.deviceId) {
- this.jobId = options.id
- this.deviceId = options.deviceId
- this.deviceName = options.deviceName
- this.loadJobDetails()
- } else {
- uni.showToast({ title: '缺少作业ID', icon: 'none' })
- uni.navigateBack()
- }
- },
- onReady() {
- this.initMap()
- },
- onUnload() {
- this.clearPolling()
- },
- methods: {
- // 加载作业详情
- async loadJobDetails() {
- uni.showLoading({ title: '加载中...' })
- try {
- const res = await getInfo(this.jobId)
- if (res.data.code === 200 &&res.data.data.workAreas) {
- this.jobInfo = res.data.data || {}
- // 更新模拟数据
- // this.jobProgress = this.jobInfo.progress || 0
- // this.deviceSpeed = this.jobInfo.speed || 0.0
- // this.batteryLevel = this.jobInfo.battery || 0
-
- this.updateMapElements()
- this.setupPolling()
- } else {
- throw new Error(res.data.msg || '加载失败')
- }
- } catch (err) {
- uni.showToast({ title: err.message, icon: 'none' })
- } finally {
- uni.hideLoading()
- }
- },
- // 初始化地图
- initMap() {
- if (window.AMap) {
- this.createMap()
- } else {
- const script = document.createElement('script')
- script.src = `https://webapi.amap.com/maps?v=2.0&key=9f2cac7ea18905dd3830cf7360a43a35`
- script.onload = this.createMap
- document.head.appendChild(script)
- }
- },
- createMap() {
- this.map = new AMap.Map('mapContainer', {
- zoom: 16,
- viewMode: '2D',
- pitch: 45
- })
- this.map.add(new AMap.TileLayer.Satellite())
- // ToolBar 在 v2 需要通过 plugin 异步加载;直接 new AMap.ToolBar() 会报:AMap.ToolBar is not a constructor
- try {
- AMap.plugin(['AMap.ToolBar'], () => {
- try {
- const toolBar = new AMap.ToolBar()
- this.map.addControl(toolBar)
- } catch (e) {
- // eslint-disable-next-line no-console
- console.warn('[job-detail] init toolbar failed', e)
- }
- })
- } catch (e) {
- // eslint-disable-next-line no-console
- console.warn('[job-detail] AMap.plugin ToolBar failed', e)
- }
- this.amapLoaded = true
- // 地图准备好后,如果作业信息已加载,则渲染一次静态元素 & 启动轮询
- if (this.jobInfo && Object.keys(this.jobInfo).length) {
- this.updateMapElements()
- this.setupPolling()
- }
- },
- // 更新地图元素
- updateMapElements() {
-
- // 你当前接口返回:jobInfo.workAreas.waypoints 直接就是点数组:[{lng,lat}, ...]
- // 同时兼容未来可能的结构:
- // - workAreas 是数组:[{ waypoints:[...] }, ...]
- // - workAreas 是对象:{ waypoints:[...] }
- if (!this.amapLoaded) return
- const workAreas = this.jobInfo && this.jobInfo.workAreas
- let points = []
- // 情况A:workAreas.waypoints = [{lng,lat}, ...](你现在的情况)
- // 注意:waypoints 有可能被后端返回成字符串 / 对象(单点)/ null,先做数组兜底
- if (workAreas && workAreas.waypoints) {
- try {
- // 如果是字符串,尝试解析为 JSON
- points = typeof workAreas.waypoints === 'string'
- ? JSON.parse(workAreas.waypoints)
- : workAreas.waypoints;
-
- // 确保解析后是数组
- if (!Array.isArray(points)) {
- console.warn('[job-detail] workAreas.waypoints is not an array after parsing:', workAreas.waypoints);
- points = [];
- }
- } catch (e) {
- console.error('[job-detail] Failed to parse workAreas.waypoints:', e);
- points = [];
- }
- }
- if (!points.length) return
- console.log("points",points);
- const waypoints = points
- .filter(p => p && p.lng != null && p.lat != null)
- .map(p => [p.lng, p.lat])
- console.log("waypoints",waypoints);
-
- if (waypoints.length <= 2) return
- // 1. 绘制作业区域
- if (!this.areaPolygon) {
- this.areaPolygon = new AMap.Polygon({
- map: this.map,
- path: waypoints,
- strokeColor: '#28F',
- strokeWeight: 2,
- fillColor: '#28F',
- fillOpacity: 0.1
- })
- } else {
- this.areaPolygon.setPath(waypoints)
- }
- // 2. 绘制规划路线
- // 注意:AMap.Polyline 的 path 需要是 [[lng,lat], ...] 或 AMap.LngLat[]。
- // 这里统一把 routePoints 归一化成 [[lng,lat], ...],避免出现 undefined[0] 这类错误。
- const rawRoutePoints = this.jobInfo.routePoints || []
- let routePath = []
- if (Array.isArray(rawRoutePoints) && rawRoutePoints.length) {
- // 可能是 [{lng,lat},...] 或 [[lng,lat],...]
- const first = rawRoutePoints[0]
- if (Array.isArray(first)) {
- routePath = rawRoutePoints
- } else {
- routePath = rawRoutePoints
- .filter(p => p && p.lng != null && p.lat != null)
- .map(p => [p.lng, p.lat])
- }
- } else {
- // 没有 routePoints 就用区域边界 waypoints(它已经是 [[lng,lat],...])
- routePath = waypoints
- }
- if (routePath.length >= 2) {
- if (!this.routePolyline) {
- this.routePolyline = new AMap.Polyline({
- map: this.map,
- path: routePath,
- showDir: true,
- strokeColor: '#28F',
- strokeWeight: 6
- })
- } else {
- this.routePolyline.setPath(routePath)
- }
- }
- // 3. 绘制已走轨迹 (假设后端返回 passedPoints)
- const rawPassedPoints = this.jobInfo.passedPoints || []
- let passedPath = []
- if (Array.isArray(rawPassedPoints) && rawPassedPoints.length) {
- const first = rawPassedPoints[0]
- if (Array.isArray(first)) {
- passedPath = rawPassedPoints
- } else {
- passedPath = rawPassedPoints
- .filter(p => p && p.lng != null && p.lat != null)
- .map(p => [p.lng, p.lat])
- }
- }
- // 3. 绘制已走轨迹:优先用实时轮询轨迹;否则使用后端历史 passedPoints
- const finalPassedPath = (this.realtimeTrackPoints && this.realtimeTrackPoints.length)
- ? this.realtimeTrackPoints
- : passedPath
- if (!this.passedPolyline) {
- this.passedPolyline = new AMap.Polyline({
- map: this.map,
- strokeColor: '#AF5',
- strokeWeight: 6
- })
- }
- this.passedPolyline.setPath(finalPassedPath)
- // 4. 绘制设备位置
- const devicePosition = this.jobInfo.currentPosition
-
- if (devicePosition && devicePosition.lng) {
- if (!this.deviceMarker) {
- this.deviceMarker = new AMap.Marker({ map: this.map, position: [devicePosition.lng, devicePosition.lat]})
- } else {
- this.deviceMarker.setPosition([devicePosition.lng, devicePosition.lat])
- }
- this.map.setCenter([devicePosition.lng, devicePosition.lat])
- }
- // 首次渲染静态元素时再 fitView,实时更新时不要频繁 fitView
- this.map.setFitView()
- },
- // 设置轮询(实时位置 + 轨迹)
- setupPolling() {
- // 先清一次,避免重复创建 interval
- this.clearPolling()
- // 没有 deviceId 就无法拉实时数据
- const deviceId = this.deviceId
- if (!deviceId) return
- // 仅在进行中/暂停时轮询(你也可以放开为所有状态)
- if (![1, 3].includes(this.jobStatus)) return
- // 立刻拉一次,避免要等第一轮 interval
- this.fetchRealtimeAndUpdate()
- // 3 秒轮询一次
- this.pollingTimer = setInterval(() => {
- this.fetchRealtimeAndUpdate()
- }, 3000)
- },
- clearPolling() {
- if (this.pollingTimer) {
- clearInterval(this.pollingTimer)
- this.pollingTimer = null
- }
- },
- async fetchRealtimeAndUpdate() {
- try {
- console.log("this.jobInfo",this.jobInfo);
-
- const deviceId =this.deviceId
- if (!deviceId) return
- const res = await getRealtimeData(deviceId)
- const payload = res && res.data && (res.data.data || res.data)
- if (!payload) return
- // 检查作业状态,如果已完成则提示并退出
- if (payload.state === 3 || payload.state === 'FINISHED' ) {
- this.clearPolling()
- uni.showModal({
- title: '提示',
- content: '当前作业已完成,是否退出当前页面',
- success: (modalRes) => {
- if (modalRes.confirm) {
- uni.navigateBack()
- }
- }
- })
- return
- }
- // 可选:丢弃时间倒退的数据
- const reportTime = payload.reportTime
- if (reportTime && this.lastReportTime && reportTime < this.lastReportTime) {
- return
- }
- if (reportTime) this.lastReportTime = reportTime
- // 更新头部信息(如果接口有)
- if (payload.progress != null) this.jobProgress = payload.progress
- if (payload.speed != null) this.deviceSpeed = payload.speed
- if (payload.battery != null) this.batteryLevel = payload.battery
- const pt = payload.currentPoint
- if (!pt || pt.x == null || pt.y == null) return
- // currentPoint: {x,y} 这里按接口文档理解为 (lng,lat)
- const lngLat = [pt.x, pt.y]
-
- // 获取行驶方向
- const direction = payload.direction || 0
- this.updateDeviceMarker(lngLat, direction)
- this.appendTrackPoint(lngLat)
- this.updatePassedPolyline()
- } catch (e) {
- // eslint-disable-next-line no-console
- console.warn('[job-detail] fetchRealtimeAndUpdate failed', e)
- }
- },
- updateDeviceMarker(lngLat, direction = 0) {
- if (!this.amapLoaded || !this.map) return
- if (!lngLat || lngLat.length !== 2) return
-
- // 创建一个 Icon
- var startIcon = new AMap.Icon({
- // 图标尺寸
- size: new AMap.Size(45, 45),
- // 图标的取图地址
- image: '/static/icons/gecaoji.png',
- // 图标所用图片大小
- imageSize: new AMap.Size(45, 45),
- // 图标取图偏移量
- // imageOffset: new AMap.Pixel(-9, -3)
- });
-
- if (!this.deviceMarker) {
- this.deviceMarker = new AMap.Marker({
- map: this.map,
- position: lngLat,
- icon: startIcon,
- anchor: 'center', // 设置锚点为中心,使图标居中显示在路线上
- angle: direction // 设置初始旋转角度
- })
- } else {
- this.deviceMarker.setPosition(lngLat)
- this.deviceMarker.setAngle(direction) // 更新旋转角度
- }
- // 轻量跟随:中心跟随但不 fitView,避免每次抖动缩放
- this.map.setCenter(lngLat)
- },
- appendTrackPoint(lngLat) {
- if (!lngLat || lngLat.length !== 2) return
- const last = this.realtimeTrackPoints.length
- ? this.realtimeTrackPoints[this.realtimeTrackPoints.length - 1]
- : null
- // 去重:相同点不追加
- if (last && last[0] === lngLat[0] && last[1] === lngLat[1]) return
- this.realtimeTrackPoints.push(lngLat)
- },
- updatePassedPolyline() {
- if (!this.amapLoaded || !this.map) return
- // 至少两个点才画线
- if (!this.realtimeTrackPoints || this.realtimeTrackPoints.length < 2) return
- if (!this.passedPolyline) {
- this.passedPolyline = new AMap.Polyline({
- map: this.map,
- path: this.realtimeTrackPoints,
- strokeColor: '#AF5',
- strokeWeight: 6
- })
- } else {
- this.passedPolyline.setPath(this.realtimeTrackPoints)
- }
- },
- // 处理按钮操作
- async handleAction(action) {
- const actions = {
- pause: { api: pauseTask, msg: '作业已暂停' },
- resume: { api: startTask, msg: '作业已继续' },
- stop: { api: stopTask, msg: '作业已停止' },
- recall: { api: recallTask, msg: '召回指令已发送' }
- }
- const currentAction = actions[action]
- if (action === 'stop') {
- uni.showModal({
- title: '确认停止',
- content: '停止后无法恢复,确定吗?',
- success: res => res.confirm && this.executeAction(currentAction)
- })
- } else {
- this.executeAction(currentAction)
- }
- },
- async executeAction({ api, msg }) {
- try {
- const res = await api(this.jobId)
- if (res.data.code === 200) {
- uni.showToast({ title: msg, icon: 'success' })
- this.loadJobDetails()
- } else {
- throw new Error(res.data.msg || '操作失败')
- }
- } catch (err) {
- uni.showToast({ title: err.message, icon: 'none' })
- }
- },
- // 格式化时间
- formatTime(timeStr) {
- if (!timeStr) return '--'
- return timeStr.substring(0, 16)
- }
- }
- }
- </script>
- <style scoped>
- .page-container {
- display: flex;
- flex-direction: column;
- height: 100vh;
- background-color: #f4f6f8;
- }
- .header-section {
- display: flex;
- justify-content: space-between;
- align-items: center;
- padding: 20rpx 30rpx;
- background-color: #ffffff;
- box-shadow: 0 2rpx 10rpx rgba(0,0,0,0.05);
- }
- .header-left {
- display: flex;
- align-items: center;
- }
- .device-name {
- font-size: 32rpx;
- font-weight: 600;
- color: #333;
- margin-right: 16rpx;
- }
- .status-badge {
- font-size: 22rpx;
- padding: 4rpx 12rpx;
- border-radius: 16rpx;
- color: #fff;
- }
- .status-0 { background-color: #1890ff; } /* 未开始 */
- .status-1 { background-color: #52c41a; } /* 进行中 */
- .status-2 { background-color: #8c8c8c; } /* 已完成 */
- .status-3 { background-color: #fa8c16; } /* 已暂停 */
- .status-4 { background-color: #f5222d; } /* 已停止 */
- .status-5 { background-color: #bfbfbf; } /* 已取消 */
- .header-right {
- display: flex;
- /**gap: 24rpx; **/
- }
- .info-item {
- display: flex;
- align-items: baseline;
- font-size: 24rpx;
- padding-left: 10rpx;
- }
- .info-label {
- color: #888;
- margin-right: 8rpx;
- }
- .info-value {
- font-size: 24rpx;
- font-weight: 600;
- color: #333;
- }
- .main-content {
- flex: 1;
- position: relative;
- }
- .map-wrapper, .map-instance {
- width: 100%;
- height: 100%;
- }
- .control-panel {
- position: absolute;
- bottom: 30rpx;
- right: 20rpx;
- width: 320rpx;
- background-color: rgba(255, 255, 255, 0.9);
- border-radius: 20rpx;
- padding: 24rpx;
- box-shadow: 0 4rpx 20rpx rgba(0,0,0,0.1);
- backdrop-filter: blur(10px);
- }
- .job-info-card {
- margin-bottom: 20rpx;
- }
- .job-info-header {
- display: flex;
- justify-content: space-between;
- align-items: flex-start;
- margin-bottom: 8rpx;
- }
- .job-info-title {
- font-size: 28rpx;
- font-weight: 600;
- color: #333;
- flex: 1;
- }
- .job-info-tag {
- font-size: 20rpx;
- background-color: #e6f7ff;
- color: #1890ff;
- padding: 2rpx 8rpx;
- border-radius: 6rpx;
- margin-left: 10rpx;
- }
- .job-info-time {
- font-size: 22rpx;
- color: #888;
- }
- .action-buttons-group {
- display: flex;
- gap: 16rpx;
- margin-bottom: 16rpx;
- }
- .action-btn {
- flex: 1;
- font-size: 24rpx;
- padding: 12rpx 0;
- margin: 0;
- border-radius: 10rpx;
- background-color: #1890ff;
- color: #fff;
- }
- .action-btn:disabled {
- background-color: #d9d9d9;
- color: #8c8c8c;
- }
- .recall-btn {
- width: 100%;
- background-color: #faad14;
- margin-bottom: 16rpx;
- }
- .resume-btn {
- width: 100%;
- background-color: #52c41a;
- }
- </style>
|