仿某钉打卡 UniApp 版

仿某钉打卡 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 生成)

相关推荐
超绝大帅哥1 小时前
RAG检索策略及划分策略
前端
小盼江1 小时前
Uniapp小程序鲜花商城推荐系统 买家卖家双端(web+uniapp)
前端·小程序·uni-app
lihaozecq1 小时前
Agent 工具系统搭建:4 个内置工具让 Agent 学会写代码
前端
问心无愧05131 小时前
ctf show web入门48
android·前端·笔记
guchen661 小时前
WPF的启动机制
前端·后端
盈建云系统1 小时前
小程序表单提交、input 双向绑定,最简洁写法
前端·小程序·apache
XiYang-DING2 小时前
【Java EE】Cookie
服务器·前端·java-ee
问心无愧05132 小时前
CTF show web入门45
android·前端·笔记
廖松洋(Alina)2 小时前
03主入口页面与导航结构-鸿蒙PC端Electron开发
前端·javascript·华为·electron·开源·harmonyos·鸿蒙