| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677 |
- <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 setup>
- import { ref, computed } from 'vue'
- import { getInfo, pauseTask, startTask, stopTask, recallTask, getRealtimeData } from '@/api/services/job.js'
- // Props from route params (will be set in onLoad)
- const jobId = ref(null)
- const deviceId = ref(null)
- const deviceName = ref('') // 设备名称
- const jobInfo = ref({})
- // 地图相关
- const map = ref(null)
- const areaPolygon = ref(null) // 作业区域
- const routePolyline = ref(null) // 规划路线
- const passedPolyline = ref(null) // 已走路线
- const deviceMarker = ref(null) // 设备图标
- // 状态
- const amapLoaded = ref(false)
- const pollingTimer = ref(null)
- const realtimeTrackPoints = ref([]) // 实时轨迹点:[[lng,lat], ...]
- const lastReportTime = ref(null)
- // 模拟数据
- const jobProgress = ref(0)
- const deviceSpeed = ref(0.0)
- const batteryLevel = ref(0)
- // 字典
- const statusMap = {
- 0: '未开始',
- 1: '进行中',
- 2: '已完成',
- 3: '已暂停',
- 4: '已停止',
- 5: '已取消',
- default: '未知'
- }
- const areaTypeMap = {
- 1: '回字形',
- 2: '弓字形',
- 3: '自定义',
- 4: '垄沟',
- default: '未知区域'
- }
- // Computed properties
- const jobStatus = computed(() => jobInfo.value.taskStatus)
- const statusText = computed(() => statusMap[jobStatus.value] || statusMap.default)
- const statusClass = computed(() => `status-${jobStatus.value}`)
- const areaTypeText = computed(() => {
- const areaType = jobInfo.value.workAreas && jobInfo.value.workAreas.areaType || 0
- return areaTypeMap[areaType] || areaTypeMap.default
- })
- // 按钮可用状态
- const canPause = computed(() => jobStatus.value === 1)
- const canResume = computed(() => jobStatus.value === 3)
- const canStop = computed(() => [0, 1, 3].includes(jobStatus.value))
- const canRecall = computed(() => [0, 1, 3].includes(jobStatus.value))
- // Lifecycle hooks
- const onLoad = (options) => {
- if (options.id || options.deviceId) {
- jobId.value = options.id
- deviceId.value = options.deviceId
- deviceName.value = options.deviceName
- loadJobDetails()
- } else {
- uni.showToast({ title: '缺少作业ID', icon: 'none' })
- uni.navigateBack()
- }
- }
- const onReady = () => {
- initMap()
- }
- const onUnload = () => {
- clearPolling()
- }
- // Methods
- const loadJobDetails = async () => {
- uni.showLoading({ title: '加载中...' })
- try {
- const res = await getInfo(jobId.value)
- if (res.data.code === 200 &&res.data.data.workAreas) {
- jobInfo.value = res.data.data || {}
-
- updateMapElements()
- setupPolling()
- } else {
- throw new Error(res.data.msg || '加载失败')
- }
- } catch (err) {
- uni.showToast({ title: err.message, icon: 'none' })
- } finally {
- uni.hideLoading()
- }
- }
- const initMap = () => {
- // #ifdef H5
- if (window.AMap) {
- createMap()
- } else {
- const script = document.createElement('script')
- script.src = `https://webapi.amap.com/maps?v=2.0&key=9f2cac7ea18905dd3830cf7360a43a35`
- script.onload = createMap
- document.head.appendChild(script)
- }
- // #endif
- // #ifndef H5
- // 非 H5 平台使用 uni-app 地图组件
- console.warn('当前平台不支持动态加载高德地图脚本,请使用 uni-app 地图组件')
- // #endif
- }
- const createMap = () => {
- map.value = new AMap.Map('mapContainer', {
- zoom: 16,
- viewMode: '2D',
- pitch: 45
- })
- map.value.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()
- map.value.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)
- }
- amapLoaded.value = true
- // 地图准备好后,如果作业信息已加载,则渲染一次静态元素 & 启动轮询
- if (jobInfo.value && Object.keys(jobInfo.value).length) {
- updateMapElements()
- setupPolling()
- }
- }
- const updateMapElements = () => {
-
- // 你当前接口返回:jobInfo.workAreas.waypoints 直接就是点数组:[{lng,lat}, ...]
- // 同时兼容未来可能的结构:
- // - workAreas 是数组:[{ waypoints:[...] }, ...]
- // - workAreas 是对象:{ waypoints:[...] }
- if (!amapLoaded.value) return
- const workAreas = jobInfo.value && jobInfo.value.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 (!areaPolygon.value) {
- areaPolygon.value = new AMap.Polygon({
- map: map.value,
- path: waypoints,
- strokeColor: '#28F',
- strokeWeight: 2,
- fillColor: '#28F',
- fillOpacity: 0.1
- })
- } else {
- areaPolygon.value.setPath(waypoints)
- }
- // 2. 绘制规划路线
- // 注意:AMap.Polyline 的 path 需要是 [[lng,lat], ...] 或 AMap.LngLat[]。
- // 这里统一把 routePoints 归一化成 [[lng,lat], ...],避免出现 undefined[0] 这类错误。
- const rawRoutePoints = jobInfo.value.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 (!routePolyline.value) {
- routePolyline.value = new AMap.Polyline({
- map: map.value,
- path: routePath,
- showDir: true,
- strokeColor: '#28F',
- strokeWeight: 6
- })
- } else {
- routePolyline.value.setPath(routePath)
- }
- }
- // 3. 绘制已走轨迹 (假设后端返回 passedPoints)
- const rawPassedPoints = jobInfo.value.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 = (realtimeTrackPoints.value && realtimeTrackPoints.value.length)
- ? realtimeTrackPoints.value
- : passedPath
- if (!passedPolyline.value) {
- passedPolyline.value = new AMap.Polyline({
- map: map.value,
- strokeColor: '#AF5',
- strokeWeight: 6
- })
- }
- passedPolyline.value.setPath(finalPassedPath)
- // 4. 绘制设备位置
- const devicePosition = jobInfo.value.currentPosition
-
- if (devicePosition && devicePosition.lng) {
- if (!deviceMarker.value) {
- deviceMarker.value = new AMap.Marker({ map: map.value, position: [devicePosition.lng, devicePosition.lat]})
- } else {
- deviceMarker.value.setPosition([devicePosition.lng, devicePosition.lat])
- }
- map.value.setCenter([devicePosition.lng, devicePosition.lat])
- }
- // 首次渲染静态元素时再 fitView,实时更新时不要频繁 fitView
- map.value.setFitView()
- }
- const setupPolling = () => {
- // 先清一次,避免重复创建 interval
- clearPolling()
- // 没有 deviceId 就无法拉实时数据
- const deviceIdValue = deviceId.value
- if (!deviceIdValue) return
- // 仅在进行中/暂停时轮询(你也可以放开为所有状态)
- if (![1, 3].includes(jobStatus.value)) return
- // 立刻拉一次,避免要等第一轮 interval
- fetchRealtimeAndUpdate()
- // 3 秒轮询一次
- pollingTimer.value = setInterval(() => {
- fetchRealtimeAndUpdate()
- }, 3000)
- }
- const clearPolling = () => {
- if (pollingTimer.value) {
- clearInterval(pollingTimer.value)
- pollingTimer.value = null
- }
- }
- const fetchRealtimeAndUpdate = async () => {
- try {
- console.log("jobInfo.value",jobInfo.value);
-
- const deviceIdValue = deviceId.value
- if (!deviceIdValue) return
- const res = await getRealtimeData(deviceIdValue)
- const payload = res && res.data && (res.data.data || res.data)
- if (!payload) return
- // 检查作业状态,如果已完成则提示并退出
- if (payload.state === 3 || payload.state === 'FINISHED' ) {
- clearPolling()
- uni.showModal({
- title: '提示',
- content: '当前作业已完成,是否退出当前页面',
- success: (modalRes) => {
- if (modalRes.confirm) {
- uni.navigateBack()
- }
- }
- })
- return
- }
- // 可选:丢弃时间倒退的数据
- const reportTime = payload.reportTime
- if (reportTime && lastReportTime.value && reportTime < lastReportTime.value) {
- return
- }
- if (reportTime) lastReportTime.value = reportTime
- // 更新头部信息(如果接口有)
- if (payload.progress != null) jobProgress.value = payload.progress
- if (payload.speed != null) deviceSpeed.value = payload.speed
- if (payload.battery != null) batteryLevel.value = 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
- updateDeviceMarker(lngLat, direction)
- appendTrackPoint(lngLat)
- updatePassedPolyline()
- } catch (e) {
- // eslint-disable-next-line no-console
- console.warn('[job-detail] fetchRealtimeAndUpdate failed', e)
- }
- }
- const updateDeviceMarker = (lngLat, direction = 0) => {
- if (!amapLoaded.value || !map.value) 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 (!deviceMarker.value) {
- deviceMarker.value = new AMap.Marker({
- map: map.value,
- position: lngLat,
- icon: startIcon,
- anchor: 'center', // 设置锚点为中心,使图标居中显示在路线上
- angle: direction // 设置初始旋转角度
- })
- } else {
- deviceMarker.value.setPosition(lngLat)
- deviceMarker.value.setAngle(direction) // 更新旋转角度
- }
- // 轻量跟随:中心跟随但不 fitView,避免每次抖动缩放
- map.value.setCenter(lngLat)
- }
- const appendTrackPoint = (lngLat) => {
- if (!lngLat || lngLat.length !== 2) return
- const last = realtimeTrackPoints.value.length
- ? realtimeTrackPoints.value[realtimeTrackPoints.value.length - 1]
- : null
- // 去重:相同点不追加
- if (last && last[0] === lngLat[0] && last[1] === lngLat[1]) return
- realtimeTrackPoints.value.push(lngLat)
- }
- const updatePassedPolyline = () => {
- if (!amapLoaded.value || !map.value) return
- // 至少两个点才画线
- if (!realtimeTrackPoints.value || realtimeTrackPoints.value.length < 2) return
- if (!passedPolyline.value) {
- passedPolyline.value = new AMap.Polyline({
- map: map.value,
- path: realtimeTrackPoints.value,
- strokeColor: '#AF5',
- strokeWeight: 6
- })
- } else {
- passedPolyline.value.setPath(realtimeTrackPoints.value)
- }
- }
- const handleAction = async (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 && executeAction(currentAction)
- })
- } else {
- executeAction(currentAction)
- }
- }
- const executeAction = async ({ api, msg }) => {
- try {
- const res = await api(jobId.value)
- if (res.data.code === 200) {
- uni.showToast({ title: msg, icon: 'success' })
- loadJobDetails()
- } else {
- throw new Error(res.data.msg || '操作失败')
- }
- } catch (err) {
- uni.showToast({ title: err.message, icon: 'none' })
- }
- }
- const 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>
|