微信小程序足迹打卡功能实现思路分享
最近在维护一个移动端工具小程序时,把「足迹打卡」模块从 0 到 1 做了一遍。这篇文章主要记录地图页的逻辑设计、位置授权处理、打卡数据落库等关键点,希望对做类似 LBS 打卡需求的同学有所帮助。全部功能已全部上架到微信小程序:【毛靳卷】中,可扫码体验。
技术栈:uni-app + Vue3 + wot-ui,后端 Spring Boot + MyBatis。
效果图
欢迎扫码进入系统体验




一、功能目标
打开打卡页面后:
- 自动获取当前位置,并在地图上标记;
- 支持「修改位置」——拖拽地图或搜索地点来微调打卡点;
- 地图上叠加三种图层:推荐公司、已打卡公司、附近公司;
- 附近公司支持按距离/行业筛选、分页加载;
- 点击底部「在此位置打卡」填写信息并提交,后端自动聚合客户、生成拜访记录。
二、页面布局逻辑:全屏地图 + 悬浮组件
打卡页本质上是一个 100vw × 100vh 的地图容器,所有操作 UI 都悬浮在地图之上。层级规划如下:
| 层级 | 元素 | z-index |
|---|---|---|
| 最底层 | map 组件 | 1 |
| 顶层 | 顶部位置卡片 / 搜索 / 筛选 | 10 |
| 顶层 | 右侧刷新/定位按钮 | 11 |
| 顶层 | 底部打卡按钮 | 100 |
| 顶层 | 附近公司浮动面板 | 101 |
| 弹窗层 | 筛选弹窗 / 打卡弹窗 / 成功提示 | 901+ |
.map-page {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
overflow: hidden;
}
.map-wrap {
position: absolute;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 1;
.map {
width: 100vw;
height: 100vh;
}
}
注意:微信小程序的
map组件是原生组件,部分浮层需要用cover-view / cover-image才能正确覆盖;H5 端则正常用view / image即可。因此页面里对两种环境做了条件编译。
三、整体交互流程
进入页面
│
▼
获取定位授权 ──失败──▶ 引导去设置页
│
▼
获取当前经纬度 + 逆地理编码(地址)
│
▼
加载推荐公司、已打卡公司
│
▼
用户操作:
├─ 点击「附近公司」筛选 ▶ 请求地图 POI ▶ 展开浮动面板
├─ 点击「修改位置」▶ 拖拽地图 / 搜索地点 ▶ 更新中心点
└─ 点击「在此位置打卡」▶ 填写表单 ▶ 提交
│
▼
后端自动匹配/创建客户,生成拜访记录,更新客户统计
四、位置获取与授权处理
打卡的第一步是拿到用户位置。这里要处理三种状态:
- 已授权:直接
uni.getLocation; - 未询问过:调用
uni.authorize主动弹窗; - 已拒绝:弹模态框引导用户去设置页手动开启。
function getCurrentPosition() {
locationStatus.value = "pending";
// H5 没有 getSetting/authorize,直接获取
// #ifdef H5
doGetLocation();
return;
// #endif
// #ifndef H5
uni.getSetting({
success: (res) => {
const hasAuth = res.authSetting["scope.userLocation"];
if (hasAuth === true) {
doGetLocation();
} else if (hasAuth === false) {
locationStatus.value = "fail";
showOpenSettingModal(); // 引导去设置页
} else {
uni.authorize({
scope: "scope.userLocation",
success: doGetLocation,
fail: () => {
locationStatus.value = "fail";
showOpenSettingModal();
},
});
}
},
});
// #endif
}
function doGetLocation() {
uni.getLocation({
type: "gcj02",
success: (res) => {
currentLat.value = res.latitude;
currentLng.value = res.longitude;
centerLat.value = res.latitude;
centerLng.value = res.longitude;
reverseGeocode(res.longitude, res.latitude); // 转地址
fetchRecommendList();
fetchCheckedInList();
updateMarkers();
},
fail: (err) => {
locationStatus.value = "fail";
// 根据 errMsg 判断是权限问题还是系统定位关闭
},
});
}
拿到经纬度后,通过服务端代理调用高德逆地理编码接口,把经纬度转成可读地址:
function reverseGeocode(lng, lat) {
uni.request({
url: config.baseUrl + "/app/visit/regeo",
header: { Authorization: "Bearer " + getToken() },
data: { lat, lng },
success: (res) => {
if (res.data.code === 200 && res.data.data?.regeocode) {
currentAddress.value = res.data.data.regeocode.formatted_address;
}
},
});
}
五、地图编辑模式:允许用户微调位置
实际业务中,GPS 定位可能偏差较大,因此提供了「修改位置」功能:
- 进入编辑模式后,地图中心显示一个固定定位针;
- 用户拖拽地图,停止后通过
mapContext.getCenterLocation()获取中心点坐标; - 同步更新当前经纬度和地址。
function toggleEditLocation() {
isEditLocation.value = !isEditLocation.value;
}
function onRegionChange(e) {
if (!isEditLocation.value || !mapContext.value) return;
const type = e.detail?.type || e.type;
if (type !== "end") return;
clearTimeout(regionChangeTimer);
regionChangeTimer = setTimeout(() => {
mapContext.value.getCenterLocation({
success: (res) => {
currentLat.value = res.latitude;
currentLng.value = res.longitude;
reverseGeocode(res.longitude, res.latitude);
updateMarkers();
},
});
}, 200);
}
同时支持顶部搜索框搜索地点,选择后把当前位置切到搜索结果:
function selectLocation(item) {
if (item.location) {
const [lng, lat] = item.location.split(",");
currentLng.value = parseFloat(lng);
currentLat.value = parseFloat(lat);
centerLat.value = parseFloat(lat);
centerLng.value = parseFloat(lng);
currentAddress.value = item.address || item.name;
updateMarkers();
}
}
六、地图标记与图层叠加
页面用 markers 数组统一管理地图上的点。为了避免 id 冲突,三类数据使用不同的 id 区间:
- 当前位置:
0 - 推荐公司:
100000 + poiId - 已打卡公司:
200000 + poiId - 附近公司:
300000 + poiId
function updateMarkers() {
const list = [];
// 当前位置
if (!isEditLocation.value && currentLat.value && currentLng.value) {
list.push({
id: 0,
latitude: currentLat.value,
longitude: currentLng.value,
iconPath: "xxx/marker.png",
width: 40,
height: 40,
title: currentAddress.value || "当前位置",
});
}
// 推荐公司
if (layerChecks.value.includes("recommend")) {
recommendList.value.forEach((item) => {
list.push({
id: 100000 + Number(item.poiId),
latitude: Number(item.lat),
longitude: Number(item.lng),
iconPath: "xxx/recommendationMarker.png",
title: item.name || "推荐公司",
label: buildCompanyLabel(item.name),
});
});
}
// 已打卡(根据评分显示红/黄/绿 marker)
if (layerChecks.value.includes("checked")) {
checkedList.value.forEach((item) => {
list.push({
id: 200000 + Number(item.poiId),
latitude: Number(item.lat),
longitude: Number(item.lng),
iconPath: getRatingMarker(item.rating),
title: item.name || "已打卡",
label: buildCompanyLabel(item.name),
});
});
}
// 附近公司
if (layerChecks.value.includes("nearby")) {
nearbyList.value.forEach((item) => {
list.push({
id: 300000 + Number(item.poiId),
latitude: Number(item.lat),
longitude: Number(item.lng),
iconPath: "xxx/recommendationMarker.png",
title: item.name || "附近公司",
label: buildCompanyLabel(item.name),
});
});
}
markers.value = list;
}
点击 marker 时,根据 id 区间反查对应数据,弹出导航确认:
function onMarkerTap(e) {
const markerId = e.detail.markerId;
let item = null;
if (markerId >= 300000) {
item = nearbyList.value.find((i) => Number(i.poiId) === markerId - 300000);
} else if (markerId >= 200000) {
item = checkedList.value.find((i) => Number(i.poiId) === markerId - 200000);
} else if (markerId >= 100000) {
item = recommendList.value.find((i) => Number(i.poiId) === markerId - 100000);
}
// ...uni.openLocation 导航
}
七、附近公司搜索与浮动面板
附近公司调用的是第三方地图 POI 搜索(同时支持腾讯/高德,默认腾讯),后端做了一层代理,前端只需要传经纬度、距离、行业、分页参数。
function fetchNearbyList(isReset = true) {
if (isReset) {
nearbyPageNum.value = 1;
nearbyList.value = [];
nearbyHasMore.value = true;
}
nearbyLoading.value = true;
uni.request({
url: config.baseUrl + "/app/visit/customer/nearby",
header: { Authorization: "Bearer " + getToken() },
data: {
lat: currentLat.value,
lng: currentLng.value,
distance: nearbyDistance.value,
industry: nearbyIndustry.value,
pageNum: nearbyPageNum.value,
pageSize: nearbyPageSize.value,
mapType: "tencent",
},
success: (res) => {
const list = (res.data.data?.list || []).map((item) => {
const distance = item.distance
? item.distance / 1000 // 后端返回米,前端转公里
: getDistance(currentLat.value, currentLng.value, item.lat, item.lng);
return { ...item, _distance: distance, _distanceText: formatDistanceText(distance) };
});
nearbyTotal.value = res.data.data?.total || 0;
nearbyList.value = [...nearbyList.value, ...list].sort((a, b) => a._distance - b._distance);
nearbyHasMore.value = nearbyList.value.length < nearbyTotal.value;
updatePanelAnchors(); // 根据内容动态调整面板高度锚点
},
});
}
为了提高体验,附近公司搜索结果做了本地缓存。切回页面时先恢复缓存,再刷新:
const NEARBY_CACHE_KEY = "footprint_nearby_cache";
function saveNearbyCache() {
uni.setStorageSync(NEARBY_CACHE_KEY, {
list: nearbyList.value,
total: nearbyTotal.value,
params: { distance: nearbyDistance.value, industry: nearbyIndustry.value },
showRecommend: showRecommend.value,
showChecked: showChecked.value,
showNearby: showNearby.value,
});
}
function restoreNearbyCache() {
const cache = uni.getStorageSync(NEARBY_CACHE_KEY);
if (!cache) return;
nearbyList.value = cache.list || [];
nearbyTotal.value = cache.total || 0;
// ...
showNearbyPanel.value = nearbyList.value.length > 0;
}
关闭面板时,同步清空数据、取消图层勾选、移除缓存,避免下次进页面还显示旧数据:
function closeNearbyPanel() {
uni.showModal({
title: "提示",
content: "关闭面板后地图上的附近公司点位将会一起清除,是否确认关闭?",
success: (res) => {
if (res.confirm) {
showNearbyPanel.value = false;
nearbyList.value = [];
nearbyTotal.value = 0;
showNearby.value = false;
uni.removeStorageSync(NEARBY_CACHE_KEY);
updateMarkers();
}
},
});
}
八、打卡提交与媒体上传
点击底部「在此位置打卡」,弹出表单。表单字段包括:公司名称、具体位置、评分、备注、图片/视频。
图片上传支持小程序 chooseMedia(同时选图/视频)和 H5 chooseImage:
function handleChooseMedia() {
const remaining = 9 - checkInForm.value.imageList.length;
// #ifdef MP-WEIXIN
uni.chooseMedia({
count: remaining,
mediaType: ["image", "video"],
sourceType: ["album", "camera"],
success: (res) => uploadTempFiles(res.tempFiles, remaining),
});
// #endif
// #ifdef H5
uni.chooseImage({
count: remaining,
success: (res) => {
const tempFiles = res.tempFilePaths.map((path) => ({ tempFilePath: path }));
uploadTempFiles(tempFiles, remaining);
},
});
// #endif
}
上传采用占位 + 替换策略:先塞空字符串占坑,上传成功后再替换为真实 URL,避免并发时顺序错乱。
async function uploadTempFiles(tempFiles, remaining) {
const files = tempFiles.slice(0, remaining);
const startIndex = checkInForm.value.imageList.length;
files.forEach(() => checkInForm.value.imageList.push(""));
for (let i = 0; i < files.length; i++) {
const filePath = files[i].tempFilePath || files[i].path;
try {
const res = await upload({ url: "/common/upload", filePath, name: "file" });
if (res.code === 200 && res.url) {
checkInForm.value.imageList.splice(startIndex + i, 1, res.url);
} else {
throw new Error(res.msg || "上传失败");
}
} catch (e) {
checkInForm.value.imageList.splice(startIndex + i, 1);
// ...
}
}
}
最后提交打卡:
function submitCheckIn() {
if (!checkInForm.value.companyName.trim()) {
uni.showToast({ title: "请输入公司名称", icon: "none" });
return;
}
const payload = {
companyName: checkInForm.value.companyName,
remark: checkInForm.value.remark,
lat: currentLat.value,
lng: currentLng.value,
address: checkInForm.value.address,
rating: checkInForm.value.rating,
visitTime: formatDateTime(new Date()),
images: checkInForm.value.imageList.filter(Boolean).join(","),
};
uni.request({
url: config.baseUrl + "/app/visit/checkIn",
method: "POST",
header: {
"Content-Type": "application/json",
Authorization: "Bearer " + getToken(),
},
data: payload,
success: (res) => {
if (res.data.code === 200) {
showCheckIn.value = false;
showSuccess.value = true;
fetchCheckedInList(); // 刷新已打卡图层
} else {
uni.showToast({ title: res.data.msg || "打卡失败", icon: "none" });
}
},
});
}
九、后端打卡逻辑:自动聚合客户
打卡接口的核心是「一条拜访记录自动对应一个客户」。后端在 VisitRecordServiceImpl.checkIn 中做了三件事:
- 强制绑定当前登录用户和部门;
- 根据公司名称匹配或创建客户;
- 插入拜访记录,并更新客户统计。
@Override
@Transactional(rollbackFor = Exception.class)
public VisitRecord checkIn(VisitRecord visitRecord) {
visitRecord.setCreateTime(DateUtils.getNowDate());
visitRecord.setVisitTime(new Date());
visitRecord.setStatus("0");
bindCurrentUser(visitRecord); // 自动关联 userId / deptId
autoBindCustomer(visitRecord); // 自动匹配/创建客户
visitRecordMapper.insertVisitRecord(visitRecord);
if (visitRecord.getCustomerId() != null) {
customerMapper.updateCustomerVisitStats(visitRecord.getCustomerId());
}
return visitRecord;
}
客户绑定逻辑:
private void autoBindCustomer(VisitRecord visitRecord) {
if (visitRecord.getCompanyName() == null || visitRecord.getCompanyName().trim().isEmpty()) {
return;
}
String companyName = visitRecord.getCompanyName().trim();
Customer customer = customerMapper.selectCustomerByCompanyName(companyName);
if (customer != null) {
visitRecord.setCustomerId(customer.getCustomerId());
} else {
// 尝试恢复已逻辑删除的同名客户
Customer deletedCustomer = customerMapper.selectCustomerByCompanyNameIgnoreDelFlag(companyName);
if (deletedCustomer != null) {
deletedCustomer.setDelFlag("0");
deletedCustomer.setStatus("0");
deletedCustomer.setAddress(visitRecord.getAddress());
deletedCustomer.setLat(visitRecord.getLat());
deletedCustomer.setLng(visitRecord.getLng());
customerMapper.updateCustomer(deletedCustomer);
visitRecord.setCustomerId(deletedCustomer.getCustomerId());
} else {
// 创建新客户
Customer newCustomer = new Customer();
newCustomer.setCompanyName(companyName);
newCustomer.setAddress(visitRecord.getAddress());
newCustomer.setLat(visitRecord.getLat());
newCustomer.setLng(visitRecord.getLng());
// ...
customerMapper.insertCustomer(newCustomer);
visitRecord.setCustomerId(newCustomer.getCustomerId());
}
}
}
这样做的好处是:前端只需要传「公司名称 + 经纬度 + 地址」,后端自动维护客户表,多次打卡同一公司会聚合到同一个客户下,删除所有拜访记录后客户也会自动清理。
十、踩坑记录
1. 微信小程序 map 组件层级特殊
map 在不同端表现差异较大:
- 微信小程序中,
map内部浮层优先用cover-view / cover-image; - H5 端可以把浮层放在
map-wrap外部,避免被地图遮挡; - 页面整体固定
100vw × 100vh,导航栏通过navigationStyle: custom隐藏原生导航。
2. 位置授权要分状态处理
不能一上来就 uni.getLocation,需要先 uni.getSetting 判断授权状态,否则用户之前拒绝后再次调用会直接失败,体验很差。
3. marker id 区间隔离
推荐公司、已打卡、附近公司的 poiId 可能重复,直接用作 marker id 会冲突。采用 100000+ / 200000+ / 300000+ 分段编码,点击时反解。
4. 附近公司面板高度自适应
用 wd-floating-panel 时,锚点需要根据内容动态计算。内容少时避免留大白边,内容多时按屏幕比例限制最大高度:
function updatePanelAnchors() {
const windowHeight = sysInfo.windowHeight || 600;
const contentHeightPx = panelHeaderPx + cardHeaderPx + nearbyList.value.length * itemHeightPx + paddingPx;
const expandPx = Math.max(minPx + 100, Math.min(contentHeightPx, Math.round(0.4 * windowHeight)));
panelAnchors.value = [minPx, expandPx, maxPx];
}
5. 图片/视频上传占位策略
多文件并发上传时,如果直接 push 真实 URL,顺序可能错乱。先 push 空字符串占坑,按索引替换,可以保证列表顺序与选择顺序一致。
十一、写在最后
这套打卡逻辑目前已经跑通了一段时间,核心感受是:LBS 类功能最大的成本不在地图本身,而在权限处理、数据一致性、多端兼容这三块。把位置授权流程做细、把客户聚合逻辑放在后端、把 marker 层级和 ID 规划好,后面迭代就会轻松很多。
如果你也在做类似的外勤/足迹/拜访类小程序,欢迎一起交流。我们这款小程序还在持续打磨更多实用的移动办公能力,后面有机会再分享其他模块的实现思路。
十二、总结
| 能力 | 实现要点 |
|---|---|
| 全屏地图 | 100vw × 100vh,悬浮组件按 z-index 分层 |
| 定位授权 | 已授权 / 未询问 / 已拒绝 三种状态分别处理 |
| 位置微调 | 编辑模式 + 中心定位针 + getCenterLocation |
| 地图图层 | marker id 分段,避免推荐/已打卡/附近公司冲突 |
| 附近公司 | 后端代理腾讯/高德 POI,前端分页 + 缓存 |
| 打卡提交 | 表单 + 媒体上传占位替换策略 |
| 后端聚合 | checkIn 自动绑定用户部门、匹配/创建客户、更新统计 |
大概就是这些,希望对大家有帮助。

4929

被折叠的 条评论
为什么被折叠?



