微信小程序实现考勤打卡功能
本文档说明在微信小程序中实现「进入指定范围才能打卡」的考勤功能的完整步骤、代码与注意事项等内容。
一、功能概述
打卡签到需要同时满足三件事:
- 知道打卡点在哪:从业务接口拿到打卡点的经纬度与允许的打卡半径。
- 知道用户在哪:持续获取用户当前位置。
- 判断是否在范围内:计算两点距离,与半径比较;在范围内才允许签到,并把签到提交到服务端。
页面表现:地图上以圆形展示打卡范围,标记显示当前位置,页面中心提供签到按钮与实时时间。
二、整体流程
| 步骤 | 内容 | 关键点 |
|---|---|---|
| 前置 | 开通小程序位置接口权限、完成申报与隐私协议 | 否则定位接口直接调用失败 |
| 步骤一 | 编写页面结构:地图 + 打卡按钮 + 实时时间 | <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 }
})
十、注意事项
- 接口权限必须先开通 :地理位置类接口需在公众平台后台上架申请并审核通过,同时在 manifest.json(app.json)的
requiredPrivateInfos中声明,两者缺一不可。 - 授权被拒要能兜底 :用户拒绝
scope.userLocation后,定位直接失败。应给出明确提示,并提供跳转wx.openSetting重新授权的能力,否则页面会一直停留在「未进入打卡范围」。 - 隐私协议先行:需在小程序后台声明「位置信息」用途;涉及隐私接口时,先完成隐私授权再调用定位。
- 持续定位要及时关闭 :当前实现只在首次收到位置后
wx.offLocationChange,未调用wx.stopLocationUpdate()。建议在页面卸载/隐藏时补上wx.stopLocationUpdate(),否则持续定位会持续耗电。 - 小程序 API 与跨端兼容 :
wx.startLocationUpdate、wx.onLocationChange、wx.offLocationChange仅微信小程序可用;H5 端需用uni.getLocation(或uni.onLocationChange)配合条件编译,否则 H5 上定位逻辑不可用。 - 地图组件与定位权限 :
<map>组件在小程序端展示需要位置权限相关的用户授权;show-location等能力也依赖授权状态。 - 距离与半径的口径 :半径由后端
punchRadius给出,单位需与计算结果(米)一致;Haversine 计算受定位精度影响,建议在范围边界附近留出容差(如半径 + 定位误差),减少「差几米打不上卡」的投诉。 - 坐标顺序容易写反:接口返回是「经度,纬度」,而小程序 map 的 latitude/longitude、以及 circles/markers 都是纬度在前,务必统一,否则打卡点会偏到千里之外。
- 时间以服务端为准:前端展示的服务器时间只用于界面展示;真正的打卡时间与是否允许打卡,应以后端记录与校验为准,防止绕过前端逻辑直接调接口。
- 地图与键盘/滚动:签到页使用固定高度布局,不同机型的安全区(safeArea)高度不同,布局需做适配,避免按钮被遮挡。
- 定时器必须清理 :实时时间与服务器时间同步都使用定时器,页面卸载时务必
clearInterval,否则会内存泄漏并持续发请求。 - 接口失败要有降级 :服务器时间同步失败时应回退使用本地时间(
serverTimeOffset保持 0),不影响用户完成打卡。