<template>
|
<div class="app-container">
|
<!-- 查询条件 -->
|
<el-form :model="queryParams" ref="queryForm" :inline="true" v-show="showSearch" label-width="80px">
|
<el-form-item label="车牌号" prop="vehicleNo">
|
<el-autocomplete
|
v-model="queryParams.vehicleNo"
|
:fetch-suggestions="queryVehicleSearch"
|
placeholder="输入车牌号搜索"
|
size="small"
|
style="width: 200px"
|
clearable
|
:trigger-on-focus="true"
|
value-key="vehicleNo"
|
@select="handleVehicleSelect"
|
@keyup.enter.native="handleQuery"
|
>
|
<template slot-scope="{ item }">
|
<span>{{ item.vehicleNo }}</span>
|
<span style="float:right;color:#909399;font-size:12px;margin-left:12px">{{ item.vehicleType || '' }}</span>
|
</template>
|
</el-autocomplete>
|
</el-form-item>
|
<el-form-item label="时间范围">
|
<el-date-picker
|
v-model="dateRange"
|
size="small"
|
style="width: 340px"
|
value-format="yyyy-MM-dd HH:mm:ss"
|
type="datetimerange"
|
range-separator="-"
|
start-placeholder="开始时间"
|
end-placeholder="结束时间"
|
:default-time="['00:00:00', '23:59:59']"
|
/>
|
</el-form-item>
|
<el-form-item>
|
<el-button type="primary" icon="el-icon-search" size="mini" :loading="loading" @click="handleQuery">查询</el-button>
|
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
|
</el-form-item>
|
</el-form>
|
|
<!-- 统计信息条 -->
|
<div class="stats-bar" v-if="gpsList.length > 0">
|
<el-tag type="info" size="small">共 {{ gpsList.length }} 个轨迹点</el-tag>
|
<el-tag type="success" size="small" style="margin-left:8px">起点时间:{{ gpsList[0] && gpsList[0].collectTime }}</el-tag>
|
<el-tag type="warning" size="small" style="margin-left:8px">终点时间:{{ gpsList[gpsList.length-1] && gpsList[gpsList.length-1].collectTime }}</el-tag>
|
<el-button-group style="margin-left:16px">
|
<el-button size="mini" @click="showPreviousSegment" :disabled="segmentIndex === 0">
|
<i class="el-icon-arrow-left"></i> 上一段
|
</el-button>
|
<el-button size="mini" @click="showNextSegment" :disabled="(segmentIndex + 1) * segmentSize >= gpsList.length">
|
下一段 <i class="el-icon-arrow-right"></i>
|
</el-button>
|
<el-button size="mini" style="margin-left:8px">
|
第 {{ segmentIndex + 1 }} / {{ Math.ceil(gpsList.length / segmentSize) }} 段
|
</el-button>
|
</el-button-group>
|
<el-button-group style="margin-left:8px">
|
<el-button size="mini" type="primary" icon="el-icon-video-play" :disabled="isPlaying" @click="startPlayback">回放</el-button>
|
<el-button size="mini" type="danger" icon="el-icon-video-pause" :disabled="!isPlaying" @click="stopPlayback">停止</el-button>
|
<el-select v-model="playSpeed" size="mini" style="width:90px;margin-left:4px">
|
<el-option label="慢速" :value="1500" />
|
<el-option label="正常" :value="800" />
|
<el-option label="快速" :value="300" />
|
<el-option label="极速" :value="80" />
|
</el-select>
|
</el-button-group>
|
</div>
|
|
<el-row :gutter="10" style="margin-top:10px">
|
<!-- 左侧轨迹列表 -->
|
<el-col :span="6">
|
<el-card class="track-list-card">
|
<div slot="header">
|
<span>轨迹点列表</span>
|
<el-tag size="mini" style="float:right" v-if="gpsList.length">{{ currentSegmentStart }}-{{ currentSegmentEnd }} / {{ gpsList.length }}</el-tag>
|
</div>
|
<el-table
|
v-loading="loading"
|
:data="currentSegmentList"
|
height="560"
|
size="mini"
|
highlight-current-row
|
@row-click="handleRowClick"
|
>
|
<el-table-column label="时间" prop="collectTime" min-width="100">
|
<template slot-scope="scope">
|
<span style="font-size:11px">{{ scope.row.collectTime }}</span>
|
</template>
|
</el-table-column>
|
<el-table-column label="速度" prop="speed" width="65" align="center">
|
<template slot-scope="scope">
|
<el-tag size="mini" :type="getSpeedTagType(scope.row.speed)">{{ formatSpeed(scope.row.speed) }}</el-tag>
|
</template>
|
</el-table-column>
|
<el-table-column label="经度" prop="longitude" width="80" align="center">
|
<template slot-scope="scope">
|
<span style="font-size:11px">{{ scope.row.longitude }}</span>
|
</template>
|
</el-table-column>
|
</el-table>
|
</el-card>
|
</el-col>
|
|
<!-- 右侧天地图 -->
|
<el-col :span="18">
|
<div style="position:relative">
|
<div id="tianMapContainer" style="height:620px;border-radius:4px;overflow:hidden"></div>
|
<!-- 地图加载中提示 -->
|
<div v-if="mapLoading" class="map-loading-mask">
|
<i class="el-icon-loading" style="font-size:32px;color:#409EFF"></i>
|
<p style="margin-top:8px;color:#409EFF">地图加载中...</p>
|
</div>
|
<!-- 无数据提示 -->
|
<div v-if="!mapLoading && gpsList.length === 0 && !loading" class="map-empty-tip">
|
<i class="el-icon-map-location" style="font-size:48px;color:#909399"></i>
|
<p style="margin-top:8px;color:#909399">请输入车牌号和时间范围查询轨迹</p>
|
</div>
|
</div>
|
</el-col>
|
</el-row>
|
</div>
|
</template>
|
|
<script>
|
import { getTracksByPlate } from '@/api/system/gps'
|
import { listVehicle } from '@/api/system/vehicle'
|
|
// 天地图 API Key(与后端配置保持一致)
|
const TIAN_DI_TU_TK = '079300b89bce333ead2df1476c43ecb0'
|
|
export default {
|
name: 'GpsTrackMap',
|
data() {
|
return {
|
loading: false,
|
mapLoading: true,
|
showSearch: true,
|
gpsList: [],
|
dateRange: [],
|
// 车牌号下拉搜索
|
vehicleOptions: [],
|
vehicleSearchLoading: false,
|
queryParams: {
|
vehicleNo: '',
|
beginTime: null,
|
endTime: null
|
},
|
// 地图对象
|
map: null,
|
// 轨迹覆盖物
|
trackPolyline: null,
|
markerStart: null,
|
markerEnd: null,
|
markerCurrent: null,
|
infoWindow: null,
|
// 分段
|
segmentIndex: 0,
|
segmentSize: 200,
|
// 回放
|
isPlaying: false,
|
playTimer: null,
|
playIndex: 0,
|
playSpeed: 800
|
}
|
},
|
computed: {
|
currentSegmentStart() {
|
return this.segmentIndex * this.segmentSize + 1
|
},
|
currentSegmentEnd() {
|
return Math.min((this.segmentIndex + 1) * this.segmentSize, this.gpsList.length)
|
},
|
currentSegmentList() {
|
return this.gpsList.slice(this.currentSegmentStart - 1, this.currentSegmentEnd)
|
}
|
},
|
mounted() {
|
// 初始化默认时间为今天
|
this.initDefaultDateRange()
|
// 加载天地图
|
this.loadTianDiTuScript().then(() => {
|
this.initMap()
|
})
|
},
|
methods: {
|
/** 车牌号自动完成搜索 */
|
queryVehicleSearch(queryStr, callback) {
|
const keyword = queryStr || ''
|
listVehicle({ vehicleNo: keyword, pageSize: 30, pageNum: 1 }).then(res => {
|
const list = (res.rows || []).map(item => ({
|
vehicleId: item.vehicleId,
|
vehicleNo: item.vehicleNo,
|
vehicleType: item.vehicleType,
|
value: item.vehicleNo
|
}))
|
callback(list)
|
}).catch(() => {
|
callback([])
|
})
|
},
|
/** 选中车牌号 */
|
handleVehicleSelect(item) {
|
this.queryParams.vehicleNo = item.vehicleNo
|
},
|
/** 远程搜索车牌号 */
|
remoteSearchVehicle(query) {
|
if (query === '' || query === null) {
|
this.vehicleOptions = []
|
return
|
}
|
this.vehicleSearchLoading = true
|
listVehicle({ vehicleNo: query, pageSize: 20, pageNum: 1 }).then(res => {
|
this.vehicleOptions = res.rows || []
|
this.vehicleSearchLoading = false
|
}).catch(() => {
|
this.vehicleSearchLoading = false
|
})
|
},
|
/** 初始化默认时间范围(今天) */
|
initDefaultDateRange() {
|
const now = new Date()
|
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0)
|
const end = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59)
|
this.dateRange = [this.formatDatetime(start), this.formatDatetime(end)]
|
},
|
/** 格式化日期为字符串 */
|
formatDatetime(date) {
|
const y = date.getFullYear()
|
const m = String(date.getMonth() + 1).padStart(2, '0')
|
const d = String(date.getDate()).padStart(2, '0')
|
const h = String(date.getHours()).padStart(2, '0')
|
const min = String(date.getMinutes()).padStart(2, '0')
|
const s = String(date.getSeconds()).padStart(2, '0')
|
return `${y}-${m}-${d} ${h}:${min}:${s}`
|
},
|
/** 格式化速度显示 */
|
formatSpeed(speed) {
|
if (speed == null) return '0'
|
return parseFloat(speed).toFixed(1)
|
},
|
/** 速度标签类型 */
|
getSpeedTagType(speed) {
|
const v = parseFloat(speed) || 0
|
if (v === 0) return 'info'
|
if (v < 60) return 'success'
|
if (v < 100) return 'warning'
|
return 'danger'
|
},
|
/** 加载天地图 JS API */
|
loadTianDiTuScript() {
|
return new Promise((resolve, reject) => {
|
if (window.T) {
|
resolve()
|
return
|
}
|
const script = document.createElement('script')
|
script.type = 'text/javascript'
|
script.src = `http://api.tianditu.gov.cn/api?v=4.0&tk=${TIAN_DI_TU_TK}`
|
script.onload = () => {
|
// 等待 T 对象可用
|
const checkT = setInterval(() => {
|
if (window.T) {
|
clearInterval(checkT)
|
resolve()
|
}
|
}, 100)
|
}
|
script.onerror = (err) => {
|
console.error('天地图脚本加载失败', err)
|
reject(err)
|
}
|
document.head.appendChild(script)
|
})
|
},
|
/** 初始化天地图 */
|
initMap() {
|
try {
|
this.map = new T.Map('tianMapContainer')
|
// 默认居中广州
|
this.map.centerAndZoom(new T.LngLat(113.33, 23.12), 11)
|
// 添加控件
|
this.map.addControl(new T.Control.Zoom())
|
this.map.addControl(new T.Control.Scale())
|
this.mapLoading = false
|
} catch (e) {
|
console.error('天地图初始化失败', e)
|
this.$message.error('地图初始化失败,请刷新重试')
|
this.mapLoading = false
|
}
|
},
|
/** 查询按钮 */
|
handleQuery() {
|
if (!this.queryParams.vehicleNo || !this.queryParams.vehicleNo.trim()) {
|
this.$message.warning('请输入车牌号')
|
return
|
}
|
if (!this.dateRange || this.dateRange.length !== 2) {
|
this.$message.warning('请选择时间范围')
|
return
|
}
|
this.queryParams.beginTime = this.dateRange[0]
|
this.queryParams.endTime = this.dateRange[1]
|
this.segmentIndex = 0
|
this.stopPlayback()
|
this.getTrackData()
|
},
|
/** 重置 */
|
resetQuery() {
|
this.$refs.queryForm.resetFields()
|
this.initDefaultDateRange()
|
this.gpsList = []
|
this.segmentIndex = 0
|
this.stopPlayback()
|
this.clearMap()
|
},
|
/** 获取轨迹数据 */
|
getTrackData() {
|
this.loading = true
|
getTracksByPlate(
|
this.queryParams.vehicleNo.trim(),
|
this.queryParams.beginTime,
|
this.queryParams.endTime
|
).then(res => {
|
this.gpsList = (res.rows || []).sort((a, b) => {
|
return new Date(a.collectTime) - new Date(b.collectTime)
|
})
|
this.loading = false
|
if (this.gpsList.length === 0) {
|
this.$message.info('该时间段内未查询到轨迹数据')
|
this.clearMap()
|
} else {
|
this.$message.success(`共查询到 ${this.gpsList.length} 个轨迹点`)
|
this.drawTrack()
|
}
|
}).catch(err => {
|
this.loading = false
|
this.$message.error('查询轨迹失败:' + (err.message || '未知错误'))
|
})
|
},
|
/** 清空地图覆盖物 */
|
clearMap() {
|
if (!this.map) return
|
this.map.clearOverLays()
|
this.trackPolyline = null
|
this.markerStart = null
|
this.markerEnd = null
|
this.markerCurrent = null
|
},
|
/** 绘制轨迹 */
|
drawTrack() {
|
if (!this.map) return
|
this.clearMap()
|
|
const segment = this.currentSegmentList
|
if (!segment || segment.length === 0) return
|
|
// 构建轨迹点数组
|
const points = segment
|
.filter(p => p.longitude != null && p.latitude != null)
|
.map(p => new T.LngLat(p.longitude, p.latitude))
|
|
if (points.length < 2) {
|
this.$message.warning('有效轨迹点不足,无法绘制')
|
return
|
}
|
|
// 绘制轨迹线
|
this.trackPolyline = new T.Polyline(points, {
|
color: '#3388ff',
|
weight: 4,
|
opacity: 0.85
|
})
|
this.map.addOverLay(this.trackPolyline)
|
|
// 起点标记
|
const startPoint = points[0]
|
const startMarker = this.createStartMarker(startPoint, segment[0])
|
this.map.addOverLay(startMarker)
|
this.markerStart = startMarker
|
|
// 终点标记(车辆图标)
|
const endIdx = segment.length - 1
|
const endPoint = points[points.length - 1]
|
const endMarker = this.createVehicleMarker(endPoint, segment[endIdx])
|
this.map.addOverLay(endMarker)
|
this.markerEnd = endMarker
|
|
// 自适应视野
|
this.map.setViewport(points)
|
},
|
/** 创建起点标记 */
|
createStartMarker(lngLat, data) {
|
const icon = new T.Icon({
|
iconUrl: this.createSvgIconUrl('#2ecc71', '起'),
|
iconSize: new T.Point(28, 28),
|
iconAnchor: new T.Point(14, 14)
|
})
|
const marker = new T.Marker(lngLat, { icon })
|
marker.addEventListener('click', () => {
|
this.showInfoWindow(lngLat, data, '起点')
|
})
|
return marker
|
},
|
/** 创建车辆标记 */
|
createVehicleMarker(lngLat, data) {
|
const direction = data.direction || 0
|
const icon = new T.Icon({
|
iconUrl: this.createCarIconUrl(direction),
|
iconSize: new T.Point(32, 32),
|
iconAnchor: new T.Point(16, 16)
|
})
|
const marker = new T.Marker(lngLat, { icon })
|
marker.addEventListener('click', () => {
|
this.showInfoWindow(lngLat, data, '当前位置')
|
})
|
return marker
|
},
|
/** 生成起点/终点 SVG 图标 URL */
|
createSvgIconUrl(color, text) {
|
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28">
|
<circle cx="14" cy="14" r="13" fill="${color}" stroke="white" stroke-width="2"/>
|
<text x="14" y="18" text-anchor="middle" fill="white" font-size="12" font-weight="bold">${text}</text>
|
</svg>`
|
return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg)
|
},
|
/** 生成汽车图标 SVG URL(带方向旋转) */
|
createCarIconUrl(direction) {
|
const deg = direction || 0
|
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
<g transform="rotate(${deg}, 16, 16)">
|
<ellipse cx="16" cy="16" rx="10" ry="13" fill="#3388ff" stroke="white" stroke-width="2"/>
|
<polygon points="16,3 21,12 11,12" fill="white"/>
|
<rect x="11" y="22" width="4" height="3" rx="1" fill="white" opacity="0.8"/>
|
<rect x="17" y="22" width="4" height="3" rx="1" fill="white" opacity="0.8"/>
|
</g>
|
</svg>`
|
return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg)
|
},
|
/** 显示信息窗口 */
|
showInfoWindow(lngLat, data, label) {
|
if (!this.map) return
|
if (this.infoWindow) {
|
this.map.closeInfoWindow()
|
}
|
const content = `
|
<div style="padding:8px;min-width:180px;line-height:1.8">
|
<b style="color:#3388ff">${label || ''}</b><br/>
|
<span>车牌:${data.vehicleNo || '-'}</span><br/>
|
<span>时间:${data.collectTime || '-'}</span><br/>
|
<span>速度:${this.formatSpeed(data.speed)} km/h</span><br/>
|
<span>方向:${data.direction || 0}°</span><br/>
|
<span>经度:${data.longitude}</span><br/>
|
<span>纬度:${data.latitude}</span>
|
</div>
|
`
|
this.infoWindow = new T.InfoWindow(content, {
|
offset: new T.Point(0, -15)
|
})
|
this.map.openInfoWindow(this.infoWindow, lngLat)
|
},
|
/** 点击列表行 */
|
handleRowClick(row) {
|
if (!row || row.longitude == null) return
|
const lngLat = new T.LngLat(row.longitude, row.latitude)
|
this.map.panTo(lngLat)
|
this.map.setZoom(15)
|
this.showInfoWindow(lngLat, row, '选中点')
|
},
|
/** 上一段 */
|
showPreviousSegment() {
|
if (this.segmentIndex > 0) {
|
this.segmentIndex--
|
this.stopPlayback()
|
this.drawTrack()
|
}
|
},
|
/** 下一段 */
|
showNextSegment() {
|
if ((this.segmentIndex + 1) * this.segmentSize < this.gpsList.length) {
|
this.segmentIndex++
|
this.stopPlayback()
|
this.drawTrack()
|
}
|
},
|
/** 开始回放 */
|
startPlayback() {
|
if (this.isPlaying || !this.gpsList.length) return
|
this.isPlaying = true
|
this.playIndex = this.currentSegmentStart - 1
|
this.playNextPoint()
|
},
|
/** 回放下一个点 */
|
playNextPoint() {
|
if (!this.isPlaying) return
|
const allList = this.gpsList
|
if (this.playIndex >= this.currentSegmentEnd) {
|
this.stopPlayback()
|
this.$message.success('轨迹回放完成')
|
return
|
}
|
const item = allList[this.playIndex]
|
if (item && item.longitude != null) {
|
const lngLat = new T.LngLat(item.longitude, item.latitude)
|
// 移除旧的回放标记
|
if (this.markerCurrent) {
|
this.map.removeOverLay(this.markerCurrent)
|
}
|
const marker = this.createVehicleMarker(lngLat, item)
|
this.map.addOverLay(marker)
|
this.markerCurrent = marker
|
this.map.panTo(lngLat)
|
}
|
this.playIndex++
|
this.playTimer = setTimeout(() => this.playNextPoint(), this.playSpeed)
|
},
|
/** 停止回放 */
|
stopPlayback() {
|
if (this.playTimer) {
|
clearTimeout(this.playTimer)
|
this.playTimer = null
|
}
|
this.isPlaying = false
|
}
|
},
|
beforeDestroy() {
|
this.stopPlayback()
|
}
|
}
|
</script>
|
|
<style scoped>
|
.stats-bar {
|
display: flex;
|
align-items: center;
|
flex-wrap: wrap;
|
gap: 4px;
|
padding: 8px 12px;
|
background: #f5f7fa;
|
border-radius: 4px;
|
margin-bottom: 4px;
|
}
|
.track-list-card {
|
height: 660px;
|
}
|
.track-list-card >>> .el-card__body {
|
padding: 0;
|
overflow: hidden;
|
}
|
.map-loading-mask {
|
position: absolute;
|
top: 0; left: 0; right: 0; bottom: 0;
|
display: flex;
|
flex-direction: column;
|
align-items: center;
|
justify-content: center;
|
background: rgba(255,255,255,0.85);
|
z-index: 999;
|
}
|
.map-empty-tip {
|
position: absolute;
|
top: 50%;
|
left: 50%;
|
transform: translate(-50%, -50%);
|
text-align: center;
|
pointer-events: none;
|
z-index: 10;
|
}
|
</style>
|