【毛靳卷】微信小程序足迹打卡功能实现思路分享

微信小程序足迹打卡功能实现思路分享

最近在维护一个移动端工具小程序时,把「足迹打卡」模块从 0 到 1 做了一遍。这篇文章主要记录地图页的逻辑设计、位置授权处理、打卡数据落库等关键点,希望对做类似 LBS 打卡需求的同学有所帮助。全部功能已全部上架到微信小程序:【毛靳卷】中,可扫码体验。

技术栈:uni-app + Vue3 + wot-ui,后端 Spring Boot + MyBatis


效果图

欢迎扫码进入系统体验

在这里插入图片描述

在这里插入描述
在这里插入图片描述
在这里插入图片描述

一、功能目标

打开打卡页面后:

  1. 自动获取当前位置,并在地图上标记;
  2. 支持「修改位置」——拖拽地图或搜索地点来微调打卡点;
  3. 地图上叠加三种图层:推荐公司、已打卡公司、附近公司;
  4. 附近公司支持按距离/行业筛选、分页加载;
  5. 点击底部「在此位置打卡」填写信息并提交,后端自动聚合客户、生成拜访记录。

二、页面布局逻辑:全屏地图 + 悬浮组件

打卡页本质上是一个 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 中做了三件事:

  1. 强制绑定当前登录用户和部门;
  2. 根据公司名称匹配或创建客户;
  3. 插入拜访记录,并更新客户统计。
@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 自动绑定用户部门、匹配/创建客户、更新统计

大概就是这些,希望对大家有帮助。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值