注意:封装的地图运动轨迹只测试了微信小程序,其他平台未测试
不过实现功能的核心API就是这几个:uniapp.dcloud.net.cn/api/locatio...
展示的核心组件是:uniapp.dcloud.net.cn/component/m...
不同平台根据文档中的差异说明修改代码即可
功能效果:

开始前先在配置文件manifest.json中找到 mp-weixin 的配置项,然后加上下面的配置
json
"permission": {
"scope.userLocation": {
"desc": "你的位置信息将用于小程序位置接口的效果展示"
}
},
"requiredPrivateInfos": [
"getLocation",
"startLocationUpdate",
"startLocationUpdateBackground",
"onLocationChange"
],
"requiredBackgroundModes": ["location"],
代码
xml
<template>
<view class="container">
<!-- 地图容器 -->
<map
id="map"
class="map"
:latitude="mapCenter.latitude"
:longitude="mapCenter.longitude"
:scale="16"
:markers="markers"
:polyline="polyline"
:enable-3D="true"
:show-compass="false"
:enable-zoom="true"
:enable-scroll="true"
:enable-rotate="true"
:enable-overlooking="true"
:enable-satellite="false"
:enable-traffic="false"
:show-location="true"
></map>
<div class="operate-box">
<!-- 定位模式切换 -->
<view class="mode-panel" v-if="!isTracking">
<view class="mode-item">
<text class="mode-label">定位模式 :</text>
<view class="mode-switch">
<text class="mode-text" :class="{ active: !isBackgroundMode }">前台定位</text>
<switch
:checked="isBackgroundMode"
:disabled="!isLocationBackgroundAuth"
@change="toggleBackgroundMode"
color="#007AFF"
/>
<text class="mode-text" :class="{ active: isBackgroundMode }">后台定位</text>
</view>
</view>
</view>
<!-- 控制按钮 -->
<view class="control-panel">
<view class="control-btn" :class="{ active: isTracking }" @click="toggleTracking">
{{ isTracking ? '停止记录' : '开始记录' }}
</view>
<view
class="control-btn clear-btn"
@click="clearTrack"
v-if="!isTracking && trackPoints.length > 0"
>
清除轨迹
</view>
<view class="control-btn" v-if="!isTracking && trackPoints.length > 0" @click="saveTrack">
保存轨迹记录
</view>
</view>
</div>
<!-- 运动信息面板 -->
<view class="info-panel" v-if="trackPoints.length > 0">
<view class="info-item">
<text class="info-label">总距离:</text>
<text class="info-value">{{ totalDistance }}km</text>
</view>
<view class="info-item">
<text class="info-label">运动时长:</text>
<text class="info-value">{{ formatDuration }}</text>
</view>
<view class="info-item">
<text class="info-label">平均速度:</text>
<text class="info-value">{{ averageSpeed }}km/h</text>
</view>
</view>
</view>
</template>
<script setup>
import { ref, onMounted, computed, watchEffect, getCurrentInstance, onBeforeUnmount } from 'vue';
import { onLoad } from '@dcloudio/uni-app';
const { proxy } = getCurrentInstance();
const eventChannel = proxy.getOpenerEventChannel();
import { getSetting } from '@/utils/index';
onShareAppMessage((res) => {
return {
title: '运动轨迹',
path: `/pages/home/index`,
};
});
onLoad((res) => {
if (res.isViewMode) {
isViewMode.value = true;
let trackData = uni.getStorageSync('trackData');
if (trackData) {
trackData = JSON.parse(trackData);
mapCenter.value = trackData.mapCenter;
markers.value = trackData.markers;
polyline.value = trackData.polyline;
}
}
if (isViewMode.value) return;
// 检查后台定位权限
getSetting('scope.userLocationBackground', '请在设置中开启后台定位权限', () => {
isLocationBackgroundAuth.value = true;
isBackgroundMode.value = true;
});
// 检查前台定位权限
getSetting('scope.userLocation', '请在设置中开启地理位置权限', () => {
isLocationAuth.value = true;
getCurrentLocation();
});
});
onBeforeUnmount(() => {
// 停止位置监听
stopLocationTracking();
});
const isViewMode = ref(false); // 是否是查看模式
const isLocationAuth = ref(false); // 是否授权地理位置权限
const isLocationBackgroundAuth = ref(false); // 是否授权后台定位权限
const isTracking = ref(false); // 是否正在记录
const isBackgroundMode = ref(false); // 是否使用后台定位模式
const trackPoints = ref([]); // 轨迹点
const markers = ref([]); // 地图标记点
const polyline = ref([]); // 轨迹线
// 地图中心点
const mapCenter = ref({
latitude: 39.909,
longitude: 116.397,
});
let timer = null; // 定时器
const startTime = ref(null); // 开始时间
const currentTime = ref(Date.now()); // 当前时间,用于实时更新
// 计算两点间距离(米)
function calculateDistance(lat1, lng1, lat2, lng2) {
const R = 6371000; // 地球半径(米)
const dLat = ((lat2 - lat1) * Math.PI) / 180;
const dLng = ((lng2 - lng1) * Math.PI) / 180;
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos((lat1 * Math.PI) / 180) *
Math.cos((lat2 * Math.PI) / 180) *
Math.sin(dLng / 2) *
Math.sin(dLng / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
// 总距离
const totalDistance = computed(() => {
if (trackPoints.value.length < 2) return 0;
let distance = 0;
for (let i = 1; i < trackPoints.value.length; i++) {
distance += calculateDistance(
trackPoints.value[i - 1].latitude,
trackPoints.value[i - 1].longitude,
trackPoints.value[i].latitude,
trackPoints.value[i].longitude
);
}
return (distance / 1000).toFixed(2);
});
// 运动时长
const formatDuration = computed(() => {
if (!startTime.value) return '00:00:00';
const duration = currentTime.value - startTime.value;
const hours = Math.floor(duration / 3600000);
const minutes = Math.floor((duration % 3600000) / 60000);
const seconds = Math.floor((duration % 60000) / 1000);
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds
.toString()
.padStart(2, '0')}`;
});
// 平均速度
const averageSpeed = computed(() => {
if (totalDistance.value <= 0 || !startTime.value) return 0;
const duration = (currentTime.value - startTime.value) / 3600000; // 小时
return (totalDistance.value / duration).toFixed(2);
});
// 获取当前位置
function getCurrentLocation() {
return new Promise((resolve, reject) => {
uni.getLocation({
type: 'gcj02',
success: (res) => {
mapCenter.value = {
latitude: res.latitude,
longitude: res.longitude,
};
resolve();
},
fail: (err) => {
console.error('获取位置失败:', err);
reject(err);
},
});
});
}
// 添加地图标记点
function addMarker(data) {
markers.value.push({
id: data.id,
latitude: data.latitude,
longitude: data.longitude,
// title: title,
width: 20,
height: 20,
callout: {
content: data.content,
color: '#ffffff',
fontSize: 12,
borderRadius: 4,
bgColor: '#007AFF',
padding: 4,
display: 'ALWAYS',
},
});
}
// 启动定时器
function startTimer() {
if (timer) return;
timer = setInterval(() => {
currentTime.value = Date.now();
}, 1000); // 每秒更新一次
}
// 停止定时器
function stopTimer() {
if (timer) {
clearInterval(timer);
timer = null;
}
}
// 开始/停止记录
function toggleTracking() {
if (isTracking.value) {
stopLocationTracking();
} else {
startLocationTracking();
}
}
// 切换后台定位模式
function toggleBackgroundMode(e) {
isBackgroundMode.value = e.detail.value;
console.log('切换定位模式:', isBackgroundMode.value ? '后台定位' : '前台定位');
}
// 开始位置监听
function startLocationTracking() {
if (isBackgroundMode.value) {
// 后台定位模式
if (isLocationAuth.value && isLocationBackgroundAuth.value) {
initLocationTracking();
} else {
if (!isLocationAuth.value) {
uni.showModal({
title: '需要地理位置权限',
content: '请在设置中开启地理位置权限',
showCancel: false,
});
} else if (!isLocationBackgroundAuth.value) {
uni.showModal({
title: '需要后台定位权限',
content: '请在设置中开启后台定位权限',
showCancel: false,
});
}
}
} else {
// 前台定位模式
if (isLocationAuth.value) {
initLocationTracking();
} else {
uni.showModal({
title: '需要地理位置权限',
content: '请在设置中开启地理位置权限',
showCancel: false,
});
}
}
}
// 初始化位置监听
async function initLocationTracking() {
// 根据模式选择不同的位置监听方式
const locationConfig = {
success: () => {
console.log('开始监听位置变化');
isTracking.value = true;
startTime.value = Date.now();
startTimer(); // 开始定时器
// 添加位置变化监听
uni.onLocationChange((res) => {
handleLocationChange(res);
});
},
fail: (err) => {
console.error('开始监听位置变化失败:', err);
uni.showToast({
title: '开始监听失败',
icon: 'none',
});
},
};
if (isBackgroundMode.value) {
// 使用后台定位
console.log('使用后台定位模式');
uni.startLocationUpdateBackground(locationConfig);
} else {
// 使用前台定位
console.log('使用前台定位模式');
uni.startLocationUpdate(locationConfig);
}
await getCurrentLocation();
addMarker({
latitude: mapCenter.value.latitude,
longitude: mapCenter.value.longitude,
id: new Date().getTime(),
content: '起始点',
});
}
// 处理位置变化
function handleLocationChange(location) {
console.log('位置变化', location);
// 添加轨迹点
const point = {
latitude: location.latitude,
longitude: location.longitude,
timestamp: Date.now(),
};
if (checkLocation(point)) {
trackPoints.value.push(point);
// 更新轨迹线
updatePolyline();
}
// 更新地图中心点(跟随用户位置)
mapCenter.value = {
latitude: location.latitude,
longitude: location.longitude,
};
}
// 校验返回的经纬度是否合法:和上一个经纬度相差不能超过100米
function checkLocation(location) {
if (trackPoints.value.length === 0) return true;
const lastPoint = trackPoints.value[trackPoints.value.length - 1];
const distance = calculateDistance(
lastPoint.latitude,
lastPoint.longitude,
location.latitude,
location.longitude
);
console.log('距离上次位置', distance + '米');
return distance < 100;
}
// 更新轨迹线
function updatePolyline() {
if (trackPoints.value.length < 2) return;
polyline.value = [
{
points: trackPoints.value.map((point) => ({
latitude: point.latitude,
longitude: point.longitude,
})),
color: '#007AFF',
width: 4,
arrowLine: true,
colorList: [
'#FF0000',
'#FF7F00',
'#FFFF00',
'#00FF00',
'#00FFFF',
'#0000FF',
'#8A2BE2',
'#8B00FF',
],
},
];
}
// 清除轨迹
function clearTrack() {
uni.showModal({
title: '确认清除',
content: '确定要清除所有轨迹记录吗?',
success: (res) => {
if (res.confirm) {
trackPoints.value = [];
polyline.value = [];
startTime.value = null;
currentTime.value = Date.now(); // 清除时也重置当前时间
uni.showToast({
title: '轨迹已清除',
icon: 'success',
});
}
},
});
}
// 停止位置监听
function stopLocationTracking() {
uni.stopLocationUpdate({
success: async () => {
console.log('停止监听位置变化');
isTracking.value = false;
stopTimer(); // 停止定时器
// 移除位置变化监听
uni.offLocationChange();
await getCurrentLocation();
addMarker({
latitude: mapCenter.value.latitude,
longitude: mapCenter.value.longitude,
id: new Date().getTime(),
content: '结束点',
});
},
fail: (err) => {
console.error('停止监听位置变化失败:', err);
},
});
}
// 保存轨迹记录
function saveTrack() {
console.log('保存轨迹记录');
let saveData = {
mapCenter: mapCenter.value,
markers: markers.value,
polyline: polyline.value,
};
uni.setStorageSync('trackData', JSON.stringify(saveData));
uni.showToast({
title: '轨迹记录已保存',
icon: 'success',
});
setTimeout(() => {
uni.reLaunch({
url: `/pages/home/index?isViewMode=1`,
});
}, 1000);
}
</script>
<style lang="scss" scoped>
.container {
position: relative;
width: 100%;
height: 100vh;
}
.map {
width: 100%;
height: 100%;
}
.operate-box {
position: absolute;
top: 0;
left: 0;
z-index: 100;
width: 100%;
padding: 20rpx;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
}
.mode-panel {
background: rgba(255, 255, 255, 0.95);
border-radius: 12rpx;
padding: 15rpx;
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.1);
margin-bottom: 20rpx;
}
.mode-item {
display: flex;
align-items: center;
margin-bottom: 10rpx;
&:last-child {
margin-bottom: 0;
}
}
.mode-label {
font-size: 26rpx;
color: #666;
margin-right: 10rpx;
}
.mode-switch {
display: flex;
align-items: center;
background: #f0f0f0;
border-radius: 15rpx;
padding: 6rpx 10rpx;
switch {
margin: 0 10rpx;
}
}
.mode-text {
font-size: 24rpx;
color: #333;
padding: 4rpx 10rpx;
border-radius: 10rpx;
&.active {
background: #007aff;
color: white;
}
}
.control-panel {
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 10rpx;
width: 100%;
}
.control-btn {
padding: 10rpx 20rpx;
background: rgba(255, 255, 255, 0.9);
border: 1px solid #ddd;
border-radius: 22rpx;
font-size: 32rpx;
color: #333;
display: flex;
justify-content: center;
align-items: center;
&.active {
background: #007aff;
color: white;
border-color: #007aff;
}
&:disabled {
opacity: 0.5;
}
}
.clear-btn {
background: rgba(255, 59, 48, 0.9);
color: white;
border-color: #ff3b30;
}
.info-panel {
position: absolute;
bottom: 20rpx;
left: 20rpx;
right: 20rpx;
background: rgba(255, 255, 255, 0.95);
border-radius: 12rpx;
padding: 15rpx;
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.1);
z-index: 100;
}
.info-item {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8rpx;
&:last-child {
margin-bottom: 0;
}
}
.info-label {
font-size: 28rpx;
color: #666;
}
.info-value {
font-size: 32rpx;
color: #333;
font-weight: 500;
}
</style>