仿某钉打卡 UniApp 版
支持:定位打卡、腾讯地图逆地址解析、公司电子围栏、距离计算、迟到早退判定、云端存储(兼容 uniCloud)、上下班切换、打卡历史记录。
一、项目基础配置
1. manifest.json 配置(权限 + 地图)
json
"mp-weixin": {
"permission": {
"scope.userLocation": {
"desc": "用于上下班定位打卡"
}
}
},
"app-plus": {
"permission": {
"LOCATION": {
"desc": "定位权限用于考勤打卡"
}
}
}
2. pages.json 页面路由
json
{
"pages": [
"pages/index/index",
"pages/record/record"
],
"globalStyle": {
"navigationBarBackgroundColor": "#2878ff",
"navigationBarTitleText": "企业考勤打卡",
"navigationBarTextStyle": "white",
"background": "#f5f7fa"
}
}
二、全局全局常量(uni-app 全局挂载)
uni-app 根目录 common/config.js
JavaScript
export default {
// 公司经纬度
companyLat: 39.9042,
companyLng: 116.4074,
// 允许打卡范围 米
checkRange: 500,
// 腾讯地图Key
mapKey: "你的腾讯地图小程序Key",
// 上下班时间
workTime: "09:00",
offTime: "18:00"
}
main.js 全局引入
js
import config from '@/common/config.js'
Vue.prototype.$config = config
三、首页打卡页面 pages/index/index
index.vue 完整代码
JavaScript
<template>
<view class="container">
<!-- 实时时间 -->
<view class="time-box">
<view class="date">{{date}}</view>
<view class="current-time">{{time}}</view>
</view>
<!-- 打卡状态卡片 -->
<view class="status-card">
<view class="status-text">{{statusText}}</view>
<view class="tip" v-if="checkTip">{{checkTip}}</view>
<view class="address" v-if="address">📍 {{address}}</view>
</view>
<!-- 打卡按钮 -->
<view class="btn-box">
<button class="check-btn" :disabled="isChecked" @click="onCheckIn">
{{btnText}}
</button>
</view>
<!-- 今日打卡记录 -->
<view class="today-record">
<view class="title">今日打卡</view>
<view class="item" v-for="(item,index) in todayRecord" :key="index">
<text>{{item.type}}</text>
<text>{{item.time}}</text>
<text style="color:#666;font-size:24rpx">{{item.status}}</text>
</view>
</view>
<!-- 跳转记录页 -->
<view class="link" @click="goRecord">查看所有打卡记录 →</view>
</view>
</template>
<script>
export default {
data() {
return {
date: '',
time: '',
statusText: '待打卡',
btnText: '上班打卡',
isChecked: false,
address: '',
todayRecord: [],
hasWork: false,
hasOff: false,
checkTip: ''
}
},
onLoad() {
this.startTime()
this.loadTodayRecord()
},
onUnload() {
clearInterval(this.timer)
},
methods: {
// 实时时钟
startTime() {
this.timer = setInterval(() => {
const now = new Date()
let y = now.getFullYear()
let m = now.getMonth() + 1
let d = now.getDate()
let h = now.getHours()
let mi = String(now.getMinutes()).padStart(2, '0')
this.date = `${y}-${m}-${d}`
this.time = `${h}:${mi}`
}, 1000)
},
// 计算两点距离(米)
getDistance(lat1, lng1, lat2, lng2) {
const rad = d => d * Math.PI / 180
const R = 6371000
const radLat1 = rad(lat1)
const radLat2 = rad(lat2)
const dLat = rad(lat2 - lat1)
const dLng = rad(lng2 - lng1)
const a = Math.sin(dLat/2)**2 + Math.cos(radLat1)*Math.cos(radLat2)*Math.sin(dLng/2)**2
return Math.round(2 * R * Math.asin(Math.sqrt(a)))
},
// 腾讯地图逆地址解析
async getAddress(lat, lng) {
const key = this.$config.mapKey
return new Promise(resolve => {
uni.request({
url: `https://apis.map.qq.com/ws/geocoder/v1/?location=${lat},${lng}&key=${key}`,
success: res => {
if(res.data.status === 0){
resolve(res.data.result.address)
} else {
resolve("未知位置")
}
},
fail: () => resolve("位置解析失败")
})
})
},
// 判断迟到早退
checkIsLate(type, nowTime) {
const work = this.$config.workTime
const off = this.$config.offTime
if(type === "上班打卡"){
return nowTime > work ? "迟到" : "正常"
}else{
return nowTime < off ? "早退" : "正常"
}
},
// 加载今日记录(本地缓存版,可无缝改uniCloud)
loadTodayRecord() {
let list = uni.getStorageSync('checkList') || []
let today = new Date().toDateString()
let todayList = list.filter(item => new Date(item.createTime).toDateString() === today)
let hasWork = todayList.some(i => i.type === '上班打卡')
let hasOff = todayList.some(i => i.type === '下班打卡')
this.todayRecord = todayList
this.hasWork = hasWork
this.hasOff = hasOff
this.btnText = hasWork ? '下班打卡' : '上班打卡'
this.statusText = hasOff ? '今日打卡完成' : hasWork ? '上班已打卡' : '待上班打卡'
this.isChecked = hasOff
},
// 打卡核心逻辑
async onCheckIn() {
uni.showLoading({ title: '定位校验中...' })
// 获取当前定位
uni.getLocation({
type: 'gcj02',
success: async (res) => {
const { latitude, longitude } = res
// 计算距离
const dis = this.getDistance(
latitude, longitude,
this.$config.companyLat,
this.$config.companyLng
)
const inRange = dis <= this.$config.checkRange
// 解析地址
const address = await this.getAddress(latitude, longitude)
const now = new Date()
const nowTime = `${now.getHours()}:${String(now.getMinutes()).padStart(2, '0')}`
const type = this.hasWork ? "下班打卡" : "上班打卡"
const status = this.checkIsLate(type, nowTime)
// 判断是否在打卡范围
if(!inRange){
uni.hideLoading()
uni.showModal({
title: '不在打卡范围',
content: `距离公司${dis}米,超出允许打卡范围`
})
return
}
// 组装打卡数据
const record = {
type,
time: nowTime,
address,
distance: dis,
status,
latitude,
longitude,
createTime: now.getTime()
}
// 存入本地缓存(后续可直接替换为 uniCloud 数据库)
let list = uni.getStorageSync('checkList') || []
list.unshift(record)
uni.setStorageSync('checkList', list)
uni.hideLoading()
uni.showToast({ title: `打卡成功 ${status}` })
this.address = address
this.checkTip = `距公司${dis}米 ${status}`
this.loadTodayRecord()
},
fail: () => {
uni.hideLoading()
uni.showModal({
title: '定位失败',
content: '请开启定位权限后重试'
})
}
})
},
// 跳转记录页
goRecord() {
uni.navigateTo({
url: '/pages/record/record'
})
}
}
}
</script>
<style scoped>
page {
background: #f5f7fa;
}
.time-box {
text-align: center;
margin: 60rpx 0 30rpx;
}
.date {
font-size: 28rpx;
color: #666;
}
.current-time {
font-size: 60rpx;
font-weight: bold;
margin-top: 10rpx;
}
.status-card {
background: #fff;
border-radius: 20rpx;
padding: 40rpx;
margin: 30rpx;
text-align: center;
}
.status-text {
font-size: 36rpx;
font-weight: bold;
color: #2878ff;
}
.tip {
font-size: 26rpx;
color: #f59e3b;
margin: 10rpx 0;
}
.address {
font-size: 26rpx;
color: #999;
margin-top: 10rpx;
}
.btn-box {
padding: 0 60rpx;
}
.check-btn {
background: #2878ff;
color: #fff;
border-radius: 50rpx;
}
.check-btn[disabled] {
background: #ccc !important;
}
.today-record {
margin: 30rpx;
background: #fff;
border-radius: 20rpx;
padding: 30rpx;
}
.title {
font-size: 30rpx;
font-weight: bold;
margin-bottom: 20rpx;
}
.item {
display: flex;
justify-content: space-between;
padding: 15rpx 0;
border-bottom: 1rpx solid #f0f0f0;
font-size: 28rpx;
}
.link {
text-align: center;
color: #2878ff;
font-size: 28rpx;
margin: 40rpx 0;
}
</style>
四、打卡记录页 pages/record/record.vue
vue
<template>
<view class="container">
<view class="title">打卡历史记录</view>
<view class="list">
<view class="item" v-for="(item,index) in list" :key="index">
<view class="top">
<text class="type">{{item.type}}</text>
<text class="time">{{item.time}} {{item.status}}</text>
</view>
<view class="addr">📍 {{item.address}}</view>
<view class="distance">距离公司:{{item.distance}} 米</view>
</view>
</view>
<view class="empty" v-if="list.length === 0">暂无打卡记录</view>
</view>
</template>
<script>
export default {
data() {
return {
list: []
}
},
onShow() {
this.getRecordList()
},
methods: {
getRecordList() {
let data = uni.getStorageSync('checkList') || []
this.list = data
}
}
}
</script>
<style scoped>
.container {
padding: 30rpx;
}
.title {
font-size: 32rpx;
font-weight: bold;
margin-bottom: 30rpx;
}
.item {
background: #fff;
border-radius: 20rpx;
padding: 30rpx;
margin-bottom: 20rpx;
}
.top {
display: flex;
justify-content: space-between;
margin-bottom: 15rpx;
}
.type {
font-size: 30rpx;
font-weight: bold;
color: #2878ff;
}
.time {
font-size: 26rpx;
color: #666;
}
.addr {
font-size: 26rpx;
color: #999;
margin-bottom: 10rpx;
}
.distance {
font-size: 24rpx;
color: #ccc;
}
.empty {
text-align: center;
color: #999;
margin-top: 100rpx;
}
</style>
五、功能说明
✅ UniApp 原生语法,微信小程序 / APP / H5 通用 ✅ 保留全部核心:定位、电子围栏、距离计算、逆地址解析 ✅ 自动判断正常 / 迟到 / 早退 ✅ 本地缓存存储,可一键无缝切换为 uniCloud 云数据库 ✅ 界面完全仿钉钉风格,配色、布局一致 ✅ 适配多端尺寸,rpx 自适应
(注:文档部分内容可能由 AI 生成)