【微信小程序】uni-app + Vue3 + Vite 的小程序项目实现「进入指定范围才能打卡」的考勤功能

微信小程序实现考勤打卡功能

本文档说明在微信小程序中实现「进入指定范围才能打卡」的考勤功能的完整步骤、代码与注意事项等内容。

一、功能概述

打卡签到需要同时满足三件事:

  1. 知道打卡点在哪:从业务接口拿到打卡点的经纬度与允许的打卡半径。
  2. 知道用户在哪:持续获取用户当前位置。
  3. 判断是否在范围内:计算两点距离,与半径比较;在范围内才允许签到,并把签到提交到服务端。

页面表现:地图上以圆形展示打卡范围,标记显示当前位置,页面中心提供签到按钮与实时时间。

二、整体流程

步骤 内容 关键点
前置 开通小程序位置接口权限、完成申报与隐私协议 否则定位接口直接调用失败
步骤一 编写页面结构:地图 + 打卡按钮 + 实时时间 <map> 的 circles、markers
步骤二 获取打卡点位与打卡半径 由业务接口返回 punchLngLat、punchRadius
步骤三 持续获取当前位置 wx.startLocationUpdate + wx.onLocationChange
步骤四 计算距离并判断是否在范围内 Haversine 公式,与半径比较
步骤五 签到提交 调签到接口,成功后可跳转
步骤六 打卡实时时间取服务器时间 防本地时间被篡改

三、前置准备:小程序接口权限与配置(重要)

1. 小程序管理后台申请接口权限

自 2022-07-14 起,微信对地理位置类接口 实行权限管控。以下接口需要在「微信公众平台 → 开发 → 开发管理 → 接口设置」中申请开通,填写使用场景与理由,审核通过后才能调用:

  • wx.getLocation(获取当前地理位置)
  • wx.startLocationUpdate(开启前台持续定位)
  • wx.onLocationChange(监听实时位置变化)
  • wx.startLocationUpdateBackground(后台持续定位,考勤常需要)
  • wx.chooseLocation / wx.choosePoi(选择位置)
  • wx.chooseAddress(选择地址)

只声明不开通,真机上会返回「接口未授权/未开通」类错误;只开通不声明,调用会直接报错。

2. 在 app.json 中声明使用的接口(uni-app 对应 manifest.json)

需要在 manifest.json 的 mp-weixin 节点声明 requiredPrivateInfos,列出实际用到的位置接口(本项目已配置):

json 复制代码
"requiredPrivateInfos": [
  "getLocation",
  "chooseAddress",
  "chooseLocation",
  "onLocationChange",
  "startLocationUpdate",
  "startLocationUpdateBackground",
  "choosePoi"
]

同时配置用户授权的说明文案,微信会在授权弹窗中展示 desc:

json 复制代码
"permission": {
  "scope.userLocation": {
    "desc": "获取资产位置"
  }
}

3. 隐私保护指引与隐私授权

  • 需在小程序后台「设置 → 服务内容声明 → 用户隐私保护指引」中声明收集「位置信息」,并说明用途;未声明时相关接口可能不可用。
  • 涉及隐私接口时,需在调用前完成隐私授权流程(监听 wx.onNeedPrivacyAuthorization / 调用 wx.requirePrivacyAuthorize,或在 app.json 中开启隐私相关配置),确保用户已同意后再调用定位接口。

4. 真机调试

  • 持续定位(startLocationUpdate / onLocationChange)仅在真机有效,开发者工具模拟器可能不回调位置。
  • 首次进入会弹出授权弹窗;用户拒绝后需引导到 wx.openSetting 手动开启(见第十节注意事项)。

四、步骤一:页面结构

地图用于展示打卡范围(circles)与当前位置(markers),下方是签到按钮和实时时间:

vue 复制代码
<template>
  <view class="page home">
    <custom-header headColor="linear-gradient( 134deg, #1E61FE 0%, #5CB0F1 100%)" text-color="#fff" show-back-btn title="打卡签到" />
    <view class="grayBg"></view>
    <map v-if="showMap" id="map" style="width: 100%;" :latitude="myLatitude" :longitude="myLongitude" :scale="scale" :circles="circles" :markers="markers"> </map>
    <view style="padding: 24rpx 18rpx;position: relative">
      <view class="checkIn-box" :style="{backgroundImage: `url(${staticUrl}images/check-in-bg.png)`}">
        <wd-text v-if="onLocal" :text="inRange? '已进入打卡范围' : '未进入打卡范围'" size="34rpx" color="#1570FD" />
        <view class="checkIn-ball" :class="{'onLocal': onLocal, 'disabled': !inRange}">
          <view class="checkIn-ball-main" @tap.stop="checkIn()">
            <wd-text text="签到" size="34rpx" color="#fff" />
            <wd-text :text="time" size="48rpx" color="#fff" />
          </view>
        </view>
      </view>
    </view>
    <wd-toast />
  </view>
</template>

说明:是否显示地图由业务开关(ifPunch)决定;inRange 决定按钮可用状态(不在范围内时按钮半透明禁用)。

五、步骤二:获取打卡点位与打卡半径

进入页面时先拉取巡查/考勤详情,拿到打卡点坐标与半径,并据此绘制范围圆:

js 复制代码
const detail = ref({})
const checkInPoint = ref([])
const punchRadius = ref(0)

const getDetails = async () => {
  inspectionDetails(detailId.value).then(res => {
    if (res.code === 200) {
      detail.value = res.data;
      inRange.value = res.data.ifPunch !== '1';
      showMap.value = res.data.ifPunch === '1';
      let punchLngLat = res.data.punchLngLat ? res.data.punchLngLat.split(',') : [];
      checkInPoint.value = [punchLngLat[1], punchLngLat[0]];
      punchRadius.value = res.data.punchRadius;
      circles.value = [{
        latitude: Number(checkInPoint.value[0]),
        longitude: Number(checkInPoint.value[1]),
        color: '#69BFBE6A',
        fillColor: '#69BFBE6A',
        radius: punchRadius.value,
        strokeWidth: 2
      }]
    } else {
      toast.error(res.msg)
    }
  });
}

要点:接口返回的坐标是「经度,纬度」字符串,需要拆开后按 [纬度, 经度] 存放;地图的 circles 用半径直接画圆。

六、步骤三:持续获取当前位置

小程序端持续定位由 wx.startLocationUpdate 开启,wx.onLocationChange 监听变化,拿到位置后回调距离计算:

js 复制代码
const getLocation = () => {
  return new Promise((resolve, reject) => {
    const _locationChangeFn = (res) => {
      myLongitude.value = res.longitude
      myLatitude.value = res.latitude
      getDistance([myLatitude.value, myLongitude.value], checkInPoint.value)
      uni.hideLoading()
      wx.offLocationChange(_locationChangeFn)
    }
    wx.startLocationUpdate({
      success: (res) => {
        wx.onLocationChange(_locationChangeFn)
        resolve(res)
      },
      fail: (err) => {
        uni.hideLoading()
        reject()
      },
    })
  })
}

要点:

  • 必须在获得用户授权后才能开启;失败时要给出提示并引导授权。
  • wx 前缀的 API 仅微信小程序可用,H5 端需用 uni.getLocation 另做兼容(见注意事项)。
  • 页面卸载时应停止监听与持续定位,避免耗电。

七、步骤四:计算距离、判断是否进入范围

用 Haversine 公式计算两点球面距离(米),与打卡半径比较:

js 复制代码
const distance = ref(0)

const rad = (d) => {
  return (d * Math.PI) / 180.0
}

const getDistance = (point1, point2) => {
  let [x1, y1] = point1
  let [x2, y2] = point2
  let Lat1 = rad(x1)
  let Lat2 = rad(x2)
  let a = Lat1 - Lat2
  let b = rad(y1) - rad(y2)
  let s = 2 * Math.asin(Math.sqrt(Math.pow(Math.sin(a / 2), 2) + Math.cos(Lat1) * Math.cos(Lat2) * Math.pow(Math.sin(b / 2), 2)))
  s = s * 6378137.0
  s = Math.round(s * 10000) / 10000

  distance.value = s
  inRange.value = distance.value <= punchRadius.value;
}

八、步骤五:签到提交

只有 inRange 为真才允许签到,提交后按接口结果提示并跳转:

js 复制代码
const checkIn = () => {
  if (!inRange.value) {
    toast.error('请进入打卡范围')
    return
  }
  inspectionSignIn({
    id: detailId.value,
  }).then(res => {
    if (res.code === 200) {
      toast.success('签到成功')
      setTimeout(() => {
        uni.navigateTo({
          url: '/pagesA/inspection/index'
        })
      }, 1000)
    } else {
      toast.error(res.msg || '签到失败')
    }
  })
}

对应的接口封装(src/services/inspection.js):

js 复制代码
export function inspectionSignIn(data) {
  return http({
    url: 'mpauth/inspection/signIn',
    method: 'post',
    data
  })
}

export function inspectionDetails(id) {
  return http({
    url: 'mpauth/inspection/detail/' + id,
    method: 'get'
  })
}

说明:签到时间以服务端接收时间为准(后端落库),前端不传时间参数,避免被篡改。

九、步骤六(补充):打卡实时时间取服务器时间

1. 为什么必须用服务器时间

页面上展示的实时时间如果直接用 new Date(),取的是手机本地时间 ,用户可以随意修改手机时间,存在打卡作弊风险,也会出现多端时间不一致。因此:只要进入打卡范围、需要展示打卡时间,就应改用服务器时间。

推荐做法:同步一次服务器时间,计算「服务器时间 - 本地时间」的偏移量,之后本地定时器每秒刷新时用「本地时间 + 偏移量」,这样既避免每秒请求服务端,又能长时间保持准确。

2. 方式一:复用接口响应头 Date(推荐,无需后端改动)

HTTP 响应头中的 Date 就是服务器时间(GMT,精度到秒)。任意一次请求都能拿到,无需新增接口。

注意:项目现有的 http 封装(src/utils/http.ts)只 resolve(res.data),丢弃了响应头 ,所以要么直接用 uni.request,要么扩展封装把 res.header 一并返回。

js 复制代码
const baseURL = import.meta.env.VITE_APP_BASE_API || '/api'

const syncServerTimeByHeader = () => {
  return new Promise((resolve) => {
    const localBefore = Date.now()
    uni.request({
      url: baseURL + 'mpauth/inspection/detail/' + detailId.value,
      method: 'GET',
      header: { 'Authorization': 'Bearer ' + userStore.token },
      success: (res) => {
        const localAfter = Date.now()
        const headerDate = res.header && (res.header.date || res.header.Date)
        const server = headerDate ? new Date(headerDate).getTime() : 0
        const localMid = Math.round((localBefore + localAfter) / 2)
        serverTimeOffset.value = server > 0 ? server - localMid : 0
        updateRealTime()
        resolve(serverTimeOffset.value)
      },
      fail: () => resolve(0)
    })
  })
}

3. 方式二:新增后端时间接口(更直观,需后端配合)

在通用控制器上加一个返回服务器毫秒时间戳的接口(可放到 CommonController):

java 复制代码
@GetMapping("common/serverTime")
public AjaxResult serverTime()
{
    return AjaxResult.success(System.currentTimeMillis());
}

前端调用并计算偏移:

js 复制代码
const syncServerTime = () => {
  return new Promise((resolve) => {
    const localBefore = Date.now()
    uni.request({
      url: baseURL + 'common/serverTime',
      method: 'GET',
      header: { 'Authorization': 'Bearer ' + userStore.token },
      success: (res) => {
        const localAfter = Date.now()
        const server = Number(res.data && res.data.data) || 0
        const localMid = Math.round((localBefore + localAfter) / 2)
        serverTimeOffset.value = server > 0 ? server - localMid : 0
        updateRealTime()
        resolve(serverTimeOffset.value)
      },
      fail: () => resolve(0)
    })
  })
}

4. 前端整合:偏移量 + 定时刷新 + 同步时机

js 复制代码
const serverTimeOffset = ref(0)
let timer = null
let syncTimer = null

const formatTime = (date) => {
  const hours = String(date.getHours()).padStart(2, '0')
  const minutes = String(date.getMinutes()).padStart(2, '0')
  const seconds = String(date.getSeconds()).padStart(2, '0')
  return `${hours}:${minutes}:${seconds}`
}

const updateRealTime = () => {
  const now = new Date(Date.now() + serverTimeOffset.value)
  time.value = formatTime(now)
}

同步时机与防漂移建议:

  • 页面挂载后先做一次同步(拿到打卡点与位置之后)。
  • 满足打卡条件(inRange 变为 true)时立即同步一次,确保展示的就是服务器时间。
  • 用户长时间停留在页面时,建议每 60 秒重新同步一次,避免本地时钟漂移。
  • 同步过程中用「请求前本地时间」和「请求后本地时间」取中间值作为基准,可抵消网络耗时带来的误差。
js 复制代码
watch(inRange, (val) => {
  if (val) {
    syncServerTime()
  }
})

onMounted(async () => {
  await getDetails()
  await getLocation()
  syncServerTime()
  timer = setInterval(updateRealTime, 1000)
  syncTimer = setInterval(syncServerTime, 60000)
})

onUnmounted(() => {
  if (timer) { clearInterval(timer); timer = null }
  if (syncTimer) { clearInterval(syncTimer); syncTimer = null }
})

十、注意事项

  1. 接口权限必须先开通 :地理位置类接口需在公众平台后台上架申请并审核通过,同时在 manifest.json(app.json)的 requiredPrivateInfos 中声明,两者缺一不可。
  2. 授权被拒要能兜底 :用户拒绝 scope.userLocation 后,定位直接失败。应给出明确提示,并提供跳转 wx.openSetting 重新授权的能力,否则页面会一直停留在「未进入打卡范围」。
  3. 隐私协议先行:需在小程序后台声明「位置信息」用途;涉及隐私接口时,先完成隐私授权再调用定位。
  4. 持续定位要及时关闭 :当前实现只在首次收到位置后 wx.offLocationChange,未调用 wx.stopLocationUpdate()。建议在页面卸载/隐藏时补上 wx.stopLocationUpdate(),否则持续定位会持续耗电。
  5. 小程序 API 与跨端兼容 :wx.startLocationUpdate、wx.onLocationChange、wx.offLocationChange 仅微信小程序可用;H5 端需用 uni.getLocation(或 uni.onLocationChange)配合条件编译,否则 H5 上定位逻辑不可用。
  6. 地图组件与定位权限 :<map> 组件在小程序端展示需要位置权限相关的用户授权;show-location 等能力也依赖授权状态。
  7. 距离与半径的口径 :半径由后端 punchRadius 给出,单位需与计算结果(米)一致;Haversine 计算受定位精度影响,建议在范围边界附近留出容差(如半径 + 定位误差),减少「差几米打不上卡」的投诉。
  8. 坐标顺序容易写反:接口返回是「经度,纬度」,而小程序 map 的 latitude/longitude、以及 circles/markers 都是纬度在前,务必统一,否则打卡点会偏到千里之外。
  9. 时间以服务端为准:前端展示的服务器时间只用于界面展示;真正的打卡时间与是否允许打卡,应以后端记录与校验为准,防止绕过前端逻辑直接调接口。
  10. 地图与键盘/滚动:签到页使用固定高度布局,不同机型的安全区(safeArea)高度不同,布局需做适配,避免按钮被遮挡。
  11. 定时器必须清理 :实时时间与服务器时间同步都使用定时器,页面卸载时务必 clearInterval,否则会内存泄漏并持续发请求。
  12. 接口失败要有降级 :服务器时间同步失败时应回退使用本地时间(serverTimeOffset 保持 0),不影响用户完成打卡。
相关推荐
西柚小萌新3 小时前
【LLM&&AI应用开发 八股文】--4.2.Agent智能体(中)
前端·javascript·react.js
逐米时代3 小时前
远程运维诊断减少到场率
java·服务器·前端
Java后端的Ai之路4 小时前
03_React_JSX
前端·react.js·前端框架
颜进强4 小时前
26 · NestJS 基础篇 · 全专栏总结:五个阶段,一次收拢
前端·后端·ai编程
风寄巴山秋4 小时前
OpenBMC:Web 页面功能异常排查
运维·服务器·前端·架构
程序员Sunday4 小时前
Promise.all、allSettled、race、any 怎么选,失败后其他请求会怎样
开发语言·前端·javascript
niucloud-admin4 小时前
JAVA V6 多商户商城 开发文档——管理端前端
前端
weixin_422201304 小时前
如何解决小程序图标点击热区小,落点不准问题?
前端·小程序·样式·点击热区·扩大
szial5 小时前
JavaScript 的 call、apply 和 bind:如何控制函数调用
开发语言·前端·javascript