shudonghui 1 gadu atpakaļ
vecāks
revīzija
de4eafcfd3

+ 11 - 0
src/api/ding.js

@@ -0,0 +1,11 @@
+import {http} from '@/service/request.js'
+//考勤信息
+export const getDingInfo = (data) => http.post('/api/admin/tb/ding/ding_info', data, {custom: {loading: true}})
+//考勤打卡
+export const dingSave = (data) => http.post('/api/admin/tb/ding/ding_save', data, {custom: {loading: true}})
+//考勤结果统计
+export const dingStatistic = (data) => http.post('/api/admin/tb/ding/ding_statistic', data, {custom: {loading: true}})
+//考勤信息
+export const dingFindRunningSop = (data) => http.post('/api/admin/tb/ding/ding_find_running_sop', data, {custom: {loading: true}})
+
+export const locAddr = (data) => http.get('https://apis.map.qq.com/ws/geocoder/v1/?location=' + data.latitude + ',' + data.longitude + '&key=ORUBZ-OXNW4-HMGUJ-KMTZJ-46N37-YWFVF');

+ 55 - 0
src/components/radius-select.vue

@@ -0,0 +1,55 @@
+<template>
+  <view class="radius-cell flex items-center">
+    <view class="flex-1 title">{{ title }}</view>
+    <view class="title mr-2" v-show="selectName" @click="show = !show">{{ selectName }}</view>
+    <view class="placeholder mr-2" v-show="!selectName" @click="show = !show">{{ placeholder }}</view>
+    <u-icon name="arrow-right" color="#8C8C8C" size="28" @click="show = !show"></u-icon>
+    <u-select v-model="show" :list="list" @confirm="confirm"></u-select>
+  </view>
+</template>
+
+<script>
+export default {
+  name: 'RadiusSelect',
+  props: ['value', 'title', 'placeholder', 'list','onChange'],
+  computed: {
+    selectName() {
+      return this.list && this.list.find((item) => item.value === this.value)?.label || null
+    }
+  },
+  data() {
+    return {
+      show: false
+    }
+  },
+  methods: {
+    confirm(arr) {
+      this.onChange(  arr[0].value )
+      this.$emit('update:value', arr[0].value)
+    }
+  },
+}
+</script>
+
+<style lang="scss" scoped>
+.radius-cell {
+  height: 84rpx;
+  border-radius: 12rpx;
+  background-color: #fff;
+  padding: 0 24rpx;
+  margin-bottom: 24rpx;
+
+  .title {
+    color: #262626;
+    font-size: 28rpx;
+  }
+
+  .placeholder {
+    color: #c0c4cc;
+  }
+
+  &:active {
+    background-color: #eee;
+  }
+}
+</style>

+ 3 - 3
src/pages.json

@@ -57,7 +57,7 @@
       }
     },
     {
-      "path": "pages/home/home",
+      "path": "pages/ding/ding",
       "style": {
         "navigationBarTitleText": "考勤",
         "enablePullDownRefresh": false
@@ -104,11 +104,11 @@
         "text": "SOP"
       },
       {
-        "pagePath": "pages/home/home",
+        "pagePath": "pages/ding/ding",
         "iconPath": "static/icon/tab/tab3.png",
         "selectedIconPath": "static/icon/tab/tab3_active.png",
         "text": "考勤"
       }
     ]
   }
-}
+}

+ 186 - 0
src/pages/ding/ding-face.vue

@@ -0,0 +1,186 @@
+<template>
+  <view class="home">
+     <camera class="camera" device-position="front" flash="off"></camera>
+  </view>
+</template>
+
+<script>
+import {DEVICE_ID, getAuthorization} from "@/utils/crypto";
+import SparkMD5 from "spark-md5";
+
+  export default {
+    name: 'DingFace',
+    props: ['onChange'],
+    data() {
+      return {
+        cameraAuth: false,
+        locationAuth: false,
+        VKSession: null,
+        videoCtx: null,
+        listener: null,
+        header: {
+          'Content-Type': 'multipart/form-data',
+          md5: '92eeda52d99adc1e4640520e20bda65f',
+          deviceId: DEVICE_ID,
+          platform: 'WEB',
+          Authorization: '',
+          time: ''
+        }
+      }
+    },
+    mounted() {
+      this.getCameraAuth()
+    },
+    onUnload() {
+      this.VKSession?.destroy()
+      this.listener?.stop({
+        complete: (res) => {
+          console.log('listener.stop', res)
+        }
+      })
+      this.VKSession = null
+      this.listener = null
+    },
+    methods: {
+      //获取摄像头权限,按需调用,如果该用户被配置为需要人脸打卡,再引导用户开启
+      getCameraAuth() {
+        const _this = this
+        uni.getSetting({
+          success(res) {
+            if (res.authSetting['scope.camera']) {
+              // 用户已经授权
+              _this.cameraAuth = true
+              _this.initFaceData()
+            } else {
+              uni.authorize({
+                scope: 'scope.camera',
+                success() {
+                  // 用户同意授权
+                  _this.cameraAuth = true
+                  _this.initFaceData()
+                },
+                fail() {
+                  // 用户不同意授权
+                  // _this.openSetting().then((res) => {
+                  //   console.log('终于授权了')
+                  //   _this.cameraAuth = true
+                  // })
+                  _this.cameraAuth = false
+                }
+              })
+            }
+          }
+        })
+      },
+      async detectFace(frame) {
+        this.VKSession.detectFace({
+          frameBuffer: frame.data,
+          width: frame.width,
+          height: frame.height,
+          scoreThreshold: 0.8,
+          sourceType: 0,
+          modelMode: 1
+        })
+      },
+      initFaceData() {
+        if (this.VKSession && this.listener) {
+          return
+        }
+        this.videoCtx = wx.createCameraContext()
+        console.log('videoCtx:', this.videoCtx)
+        let count = 0
+        this.listener = this.videoCtx.onCameraFrame((frame) => {
+          count++
+          if (count === 10) {
+            this.detectFace(frame)
+            count = 0
+          }
+        })
+        this.VKSession = wx.createVKSession({
+          version: 'v2',
+          track: {
+            plane: {
+              mode: 3
+            },
+            face: { mode: 2 }
+          }
+        })
+        const _this = this
+        this.VKSession.on('updateAnchors', (anchors) => {
+          console.log('anchors', anchors)
+          if (anchors.length && !this.tempImg) {
+            this.videoCtx.takePhoto({
+              quality: 'high',
+              success: async (res) => {
+                const path = res.tempImagePath
+                let user = uni.getStorageSync('user')
+                let timestamp = Date.now()
+                const authorization = getAuthorization(
+                    {
+                      method: 'post',
+                      uri: '/api/admin/common/file/upload',
+                      timestamp,
+                      sessionId: user.sessionId,
+                      token: user.accessToken
+                    },
+                    'token'
+                )
+                _this.header.Authorization = authorization
+                _this.header.time = timestamp
+                _this.header = {..._this.header}
+                await new Promise((resolve, reject) => {
+                  wx.getFileSystemManager().readFile({
+                    filePath: path,
+                    encoding: 'binary',
+                    success: (res) => {
+                      let spark = new SparkMD5()
+                      spark.appendBinary(res.data)
+                      var md5 = spark.end()
+                      _this.header.md5 = md5
+                      setTimeout(() => {
+                        resolve()
+                      }, 0)
+                    }
+                  })
+                })
+
+                wx.uploadFile({
+                  url: process.env.VUE_APP_BASE_API + '/api/admin/common/file/upload',
+                  filePath: path,
+                  name: 'file',
+                  formData: this.dingObj,
+                  header: this.header,
+                  success(res) {
+                    this.onChange(res.url)
+                  }
+                })
+
+
+              }
+            })
+          }
+        })
+        this.VKSession.start((error) => {
+          if (error) {
+            this.$u.toast('VKSession start error')
+            // 如果失败,将返回 errno
+          }
+        })
+        this.listener.start()
+      }
+    }
+  }
+</script>
+
+<style lang="scss" scoped>
+  .home {
+    width: 100%;
+    height: 100%;
+    text-align: center;
+    .camera {
+      width: 400upx;
+      height: 400upx;
+      border-radius: 50%;
+    }
+  }
+</style>

+ 362 - 0
src/pages/ding/ding.vue

@@ -0,0 +1,362 @@
+<template>
+  <view class="ding flex flex-col">
+    <RadiusSelect title="服务单元名称" placeholder="请选择服务单元" :list="serviceUnit" :value.sync="params.serviceId" :on-change="getSopList"></RadiusSelect>
+    <RadiusSelect title="SOP" placeholder="请选择SOP" :list="sopList" :value.sync="params.sopNo" :on-change="getDingInfo"></RadiusSelect>
+    <view class="msg-item">
+      <view class="m-head flex justify-between">
+        <view class="m-title flex-col">
+          <text class="title">签到{{ dingInfo.signInTimeScope ||'' }}</text>
+          <view class="flex ">
+            <image src="../../static/icon/ding-success.png" class="ding-success mr2" v-show="dingInfo.signInTime"/>
+            <view class="sub-title mr2">{{dingInfo.signInTime?dateFormat(dingInfo.signInTime, 'hh:mm')+'已打卡':'未打卡'}}</view>
+            <a @click="checkDingObj('IN')" class="a-title" v-show="dingInfo.signInTime">更新打卡</a>
+          </view>
+        </view>
+        <view class="m-title flex-col">
+          <text class="title">签退{{ dingInfo.signOutTimeScope ||'' }}</text>
+          <view class="flex ">
+            <image src="../../static/icon/ding-success.png" class="ding-success mr2" v-show="dingInfo.signOutTime"/>
+            <view class="sub-title mr2">{{dingInfo.signOutTime?dateFormat(dingInfo.signOutTime, 'hh:mm')+'已打卡':'未打卡'}}</view>
+            <a @click="checkDingObj('OUT')" class="a-title" v-show="dingInfo.signOutTime">更新打卡</a>
+          </view>
+        </view>
+      </view>
+      <view class="m-body flex items-center" >
+        <view class="m-box flex flex-col items-center" v-show="signOutEnable||signInEnable" @click="checkDingObj(signOutEnable?'OUT':(signInEnable?'IN':''))" >
+          <text class="m-box-1">{{signOutEnable?'签退':(signInEnable?'签到':'')}}打卡</text>
+          <text class="m-box-2" v-show="dingInfo.faceOpen">(人脸识别)</text>
+          <text class="m-box-3">{{dateFormat(currentTime, 'hh:mm')}}</text>
+        </view>
+        <view class="m-box-disable m-box flex flex-col items-center" v-show="!signOutEnable&&!signInEnable">
+          <text class="m-box-1">无法打卡</text>
+          <text class="m-box-2" v-show="dingInfo.faceOpen">(人脸识别)</text>
+          <text class="m-box-3">{{dateFormat(currentTime, 'hh:mm')}}</text>
+        </view>
+        <view class="m-box-success flex flex-col items-center" v-show="dingSuccess">
+          <image src="../../static/icon/ding-success.png" class="image-success"/>
+          <text class="title-success">打卡成功,打卡时间{{dateFormat(dingObj.signTime, 'hh:mm')}}</text>
+        </view>
+      </view>
+      <view class="m-foot flex">
+        <u-icon name="map" color="#8C8C8C" size="28"></u-icon>
+        <text class="sub-title">{{dingObj.signAddress}}</text>
+      </view>
+    </view>
+    <view class="msg-item">
+      <view class="m-head flex justify-between">
+        <view class="msg-foot flex flex-col">
+          <u-icon name="error-circle" color="#8C8C8C" size="50"></u-icon>
+          <text class="sub-title">异常处理</text>
+        </view>
+        <view class="msg-separator"></view>
+        <view class="msg-foot flex flex-col">
+          <u-icon name="clock" color="#8C8C8C" size="50"></u-icon>
+          <text class="sub-title">统计</text>
+        </view>
+      </view>
+    </view>
+
+    <u-toast ref="uToast"/>
+
+    <u-popup v-model="show" mode="center" border-radius="14">
+      <view class="popup-main flex flex-col justify-between items-center">
+          <text class="title">打卡提醒</text>
+          <text class="sub-title">请选择你是签到还是签退</text>
+          <u-button type="primary" @click="checkDingObj('IN')">签到</u-button>
+          <u-button type="primary" plain @click="checkDingObj('OUT')">签退</u-button>
+          <u-button type="default" @click="show=false">取消</u-button>
+      </view>
+    </u-popup>
+    <u-popup v-if="faceModel" mode="center" border-radius="200" :mask-close-able="false">
+      <DingFace :on-change="dingFace"></DingFace>
+    </u-popup>
+  </view>
+</template>
+
+<script>
+import {dingFindRunningSop, getDingInfo, dingSave, locAddr} from '@/api/ding'
+import {getServiceUnit} from '@/api/common'
+import {dateFormat, getAppWxLatLon, timeCompare} from '@/utils/utils'
+import RadiusSelect from "@/components/radius-select.vue";
+import DingFace from "./ding-face.vue";
+
+
+export default {
+  name: 'ding',
+  components: {RadiusSelect,DingFace},
+  data() {
+    return {
+      dateFormat,
+      loadingFlag: 0,
+      serviceUnit: [],
+      sopList: [],
+      params: {serviceId: '', sopNo: null},
+      show: false,
+      faceModel: false,
+      signOutEnable: false,
+      signInEnable: false,
+      dingSuccess: false,
+      dingInfo: {},
+      setTime:null,
+      currentTime:null,
+      dingObj:{
+            signTime: null,
+            sopNo:null,
+            signType: "OUT",
+            signAddress: "",
+            axisX: "",
+            axisY: ""
+      }
+    }
+  },
+  mounted() {
+    const _this=this;
+    getAppWxLatLon(async (res) => {
+      const loc=await locAddr(res);
+      _this.dingObj.signAddress=loc.result.address
+      _this.dingObj.axisX = res.longitude;
+      _this.dingObj.axisY = res.latitude;
+    })
+    getServiceUnit().then((res) => {
+      this.serviceUnit = (res || []).map((item) => {
+        return {
+          value: item.id,
+          label: item.name
+        }
+      })
+    })
+    this.setTime = setInterval( ()=> {
+      this.currentTime = new Date().getTime()
+    }, 1000)
+
+  },
+  onHide(){
+    clearInterval(this.setTime)
+  },
+  methods: {
+    getSopList(serviceId) {
+      dingFindRunningSop({serviceUnitId: serviceId}).then((res) => {
+        this.sopList = (res || []).map((item) => {
+          return {
+            value: item.sopNo,
+            label: item.sopNo + item.customName
+          }
+        })
+      })
+    },
+    getDingInfo(sopNo) {
+      getDingInfo({sopNo: sopNo}).then((res) => {
+        this.dingInfo = res;
+        if (this.dingInfo.signInTimeScope) {
+          this.signInEnable=timeCompare(this.dingInfo.signInTimeScope.split("~")[0],this.currentTime,this.dingInfo.signInTimeScope.split("~")[1]);
+        }
+        if (this.dingInfo.signOutTimeScope) {
+          this.signOutEnable=timeCompare(this.dingInfo.signOutTimeScope.split("~")[0],this.currentTime,this.dingInfo.signOutTimeScope.split("~")[1]);
+        }
+        if(!this.dingInfo.signInTimeScope||!this.dingInfo.signOutTimeScope){
+          this.show=true;
+        }
+      })
+    },
+    checkDingObj(signType) {
+      if(!signType){
+        this.show=true;return;
+      }
+      this.dingObj.sopNo = this.params.sopNo;
+      this.dingObj.signType = signType;
+      this.dingObj.signTime = this.currentTime;
+      this.dingObj.facePass = false;
+      if (this.dingInfo.faceOpen) {
+          this.faceModel=true;
+      }else{
+        const _this=this;
+        getAppWxLatLon(async (res) => {
+          const loc = await locAddr(res);
+          _this.dingObj.signAddress = loc.result.address
+          _this.dingObj.axisX = res.longitude;
+          _this.dingObj.axisY = res.latitude;
+          dingSave(_this.dingObj).then((res) => {
+            _this.dingSuccess = true;
+            _this.signInEnable = false;
+            _this.signOutEnable = false;
+          })
+        })
+      }
+    },
+    dingFace(facePhotoPath){
+      const _this=this;
+      getAppWxLatLon(async (res) => {
+        const loc = await locAddr(res);
+        _this.dingObj.signAddress = loc.result.address
+        _this.dingObj.facePhotoPath = facePhotoPath;
+        _this.dingObj.axisX = res.longitude;
+        _this.dingObj.axisY = res.latitude;
+        dingSave(_this.dingObj).then((res) => {
+          _this.dingSuccess = true;
+          _this.signInEnable = false;
+          _this.signOutEnable = false;
+        })
+      })
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.ding {
+  height: calc(100% - 80rpx);
+  padding: 24rpx;
+}
+.msg-item {
+  padding: 24rpx;
+  border-radius: 12rpx;
+  background: #fff;
+  margin-bottom: 24rpx;
+  .m-body {
+    margin-top: 16rpx;
+    justify-content: center;
+    .m-box{
+      width: 272rpx;
+      height: 272rpx;
+      justify-content: center;
+      background: linear-gradient(180deg, #3196FF 0%, #165DFF 100%);
+      box-shadow: 0rpx 0rpx 24rpx 0rpx rgba(22,93,255,0.5);
+      border-radius: 136rpx;
+      margin-top: 80rpx;
+      .m-box-1{
+        height: 56rpx;
+        font-size: 40rpx;
+        font-family: PingFangSC-Medium, PingFang SC;
+        font-weight: 500;
+        color: #FFFFFF;
+        line-height: 56rpx;
+      }
+      .m-box-2{
+        height: 44rpx;
+        font-size: 28rpx;
+        font-family: PingFangSC-Regular, PingFang SC;
+        font-weight: 400;
+        color: rgba(255,255,255,0.5);
+        line-height: 44rpx;
+      }
+      .m-box-3{
+        height: 48rpx;
+        font-size: 32rpx;
+        font-family: PingFangSC-Medium, PingFang SC;
+        font-weight: 500;
+        color: #FFFFFF;
+        line-height: 48rpx;
+      }
+    }
+    .m-box-disable{
+      width: 272rpx;
+      height: 272rpx;
+      justify-content: center;
+      background: linear-gradient(180deg, #C0C0C0 0%, #8C8C8C 100%);
+      box-shadow: 0px 0px 12px 0px #BFBFBF;
+      border-radius: 136rpx;
+      margin-top: 80rpx;
+    }
+    .m-box-success{
+      margin-top: 80rpx;
+      .title-success{
+        width: 188px;
+        height: 24px;
+        font-size: 16px;
+        font-family: PingFangSC-Regular, PingFang SC;
+        font-weight: 400;
+        color: #262626;
+        line-height: 24px;
+      }
+      .image-success{
+
+        width: 60px;
+        height: 60px;
+      }
+    }
+  }
+  .m-foot {
+    padding-top: 40rpx;
+    padding-bottom: 70rpx;
+    justify-content: center;
+  }
+  .m-head {
+    .msg-foot{
+      min-width: 48%;
+      align-items: center;
+    }
+    .msg-separator{
+      width: 2rpx;
+      background: #E5E5E5;
+    }
+    .m-title {
+      min-width: 48%;
+      background: #f0f0f0;
+      align-items: center;
+      border-radius: 12rpx;
+      padding: 10rpx;
+      .title {
+        height: 48rpx;
+        font-size: 32rpx;
+        font-family: PingFangSC-Regular, PingFang SC;
+        font-weight: 400;
+        color: #262626;
+        line-height: 48rpx;
+      }
+      .ding-success{
+        margin: 3px 0 3px 0;
+        width: 14px;
+        height: 14px;
+       }
+      .a-title{
+        height: 40rpx;
+        font-size: 24rpx;
+        font-family: PingFangSC-Regular, PingFang SC;
+        font-weight: 400;
+        color: #165DFF;
+        line-height: 40rpx;
+      }
+      .sub-title{
+        height: 40rpx;
+        font-size: 24rpx;
+        font-family: PingFangSC-Regular, PingFang SC;
+        font-weight: 400;
+        color: #8C8C8C;
+        line-height: 40rpx;
+      }
+    }
+  }
+}
+.popup-main {
+  min-width: 240px;
+  min-height: 254px;
+  padding: 25rpx;
+  border-radius: 6px;
+  background: linear-gradient(180deg, #D7EAFF 0%, #FFFFFF 100%);
+  backdrop-filter: blur(10px);
+  .title {
+    width: 160px;
+    height: 24px;
+    font-size: 16px;
+    font-family: PingFangSC-Semibold, PingFang SC;
+    font-weight: 600;
+    color: #262626;
+    line-height: 24px;
+    text-align: center;
+  }
+  .sub-title {
+    width: 168px;
+    height: 20px;
+    font-size: 14px;
+    font-family: PingFangSC-Regular, PingFang SC;
+    font-weight: 400;
+    color: #595959;
+    line-height: 20px;
+    text-align: center;
+  }
+  u-button{
+    width: -webkit-fill-available;
+  }
+
+}
+</style>

BIN
src/static/icon/ding-success.png


+ 4 - 0
src/styles/global.scss

@@ -95,3 +95,7 @@
 .u-button + .u-button {
   margin-left: 24rpx;
 }
+
+.mr-2{
+  margin-right: 2px;
+}

+ 7 - 0
src/utils/utils.js

@@ -649,3 +649,10 @@ export function objTypeOf(obj) {
   }
   return map[toString.call(obj)]
 }
+
+/* 打卡时间比较 */
+export const timeCompare = (startStr, time, endStr) => {
+  let start = new Date(startStr) //开始时间
+  let end = new Date(endStr) //结束时间
+  return start.getTime() > time && time < end.getTime();
+}