微信小程序开发考试答题监控系统

记录下两年前开发小程序答题监控功能的思路。因时间久远,同时删除了部分业务代码,没讲清楚的请多多包涵。

需求

  • 用户进入考试须知页面,人脸识别成功后,点击开始考试进入答题页面
  • 考试须知页面和答题页面控制考试总时长,控制每道题时长最多60秒
  • 考试过程中启用摄像头对用户进行抓拍监控,比对人脸数据

方案选择

        开始采用的方案是分别开发“考试须知”和“考试”两个独立页面,后因“考试”页面采用了tensorflow人脸识别,初始相机和加载模型耗时过长,遂将两个页面合并,让用户提前加载模型,同时两个页面是考试时长控制也统一起来了。其中最关键的视图容器就是page-container

主要容器和组件

1.page-container

功能描述:

        页面容器。

        小程序如果在页面内进行复杂的界面设计(如在页面内弹出半屏的弹窗、在页面内加载一个全屏的子页面等),用户进行返回操作会直接离开当前页面,不符合用户预期,预期应为关闭当前弹出的组件。 为此提供“假页”容器组件,效果类似于 popup 弹出层,页面内存在该容器时,当用户进行返回操作,关闭该容器不关闭页面。返回操作包括三种情形,右滑手势、安卓物理返回键和调用 navigateBack 接口。

        根据官方文档描述,我们使用page-container来构建一个类似弹出层的假的“子页面”。它的特性可以用于禁止用户在考试过程中返回上一页,如果是两个独立页面,很难实现这一点。

2.camera

功能描述:

        系统相机。扫码二维码功能,需升级微信客户端至6.7.3。需要用户授权 scope.camera。 2.10.0起 initdone 事件返回 maxZoom,最大变焦范围,相关接口 CameraContext.setZoom

        请注意原生组件使用限制

 页面结构预览

1.页面原型

2.整体结构 

<page-meta page-style="{{ popShow||containerShow ? 'overflow: hidden;' : '' }}">
<pageload id="pageload" bind:initPage="initPage" ...>
<view>
  考试须知页面
</view>
</pageload>

<page-container show="{{containerShow}}" z-index="100" id="container"
    duration="{{duration}}"
    position="right"
    bind:enter="pageenter"
    bind:beforeleave="beforeleave"
    bind:afterleave="afterleave"
  >

  <pageload bind:initPage="initExamPage" requesting="true" id="examPage">
    <block wx:if="{{cameraShow}}">
      <view class="camera-container-out">
        <view class="camera-container-in">
          <camera
            device-position="front"
            flash="off"
            frame-size="small"
            binderror="cameraError"
            class="camera"
          ></camera>
          <canvas id="mark-canvas" class="mark-canvas" type="2d"></canvas>
        </view>
      </view>
    </block>
    
    <view class="titlebar">
      <view>
              剩余答题时间:
      </view>
    </view>
    <view class="pageBody pageBody_sty">
      <view>
        考试题目
      </view>
      <scroll-view scroll-y="true" scroll-top="{{top}}" style="height:{{viewHeight}}rpx;" show-scrollbar="false">
        <view class="item_con" wx:for="{{curr_question.trunk.options}}">
              试题选项
        </view>
      </scroll-view>
    </view>

    <view class="foot-nav-panel">
      <view class="foot-nav-ul">	               
          <view class="li">
            <van-button type="primary" block custom-class="foot-btn"
            bind:click="showPop">
              答题板
            </van-button>                  
          </view>
          <view class="li leftBorder">
            <van-button type="primary" block custom-class="foot-btn" 
            data-id="{{curr_question.nextId}}"
            bind:click="nextQuestion">
              {{!curr_question.nextId?'交卷':'下一题'}}
            </van-button> 
          </view>
      </view>
    </view>

    <van-popup show="{{popShow}}" closeable position="bottom" custom-style="height: 60%" bind:close="hidPop">
      <view class="serialNum">
        答题板详情
      </view>
    </van-popup>
  </pageload>

</page-container>
</page-meta>

 3.局部分析

1)最外层组件page-meta

        页面属性配置节点,用于指定页面的一些属性、监听页面事件。只能是页面内的第一个节点。page-style属性:页面根节点样式,页面根节点是所有页面节点的祖先节点,相当于 HTML 中的 body 节点。

<page-meta page-style="{{ popShow||containerShow ? 'overflow: hidden;' : '' }}">

        当弹出层(答题板)或者子页面(答题页)显示时,父页面溢出隐藏,防止滑动操作时父页面滚动。

2)自定义组件pageload

功能描述

        页面初始化。打开页面后执行initPage方法,通过回调来判断展示正确或错误的页面信息

 wxml
<view>
  <view wx:if="{{requesting===false}}">
    <view wx:if="{{status===false}}" class="error">
      <image class="error_image" src="{{errImg}}"></image>
      <view class="error_text">{{errText?errText:'未知错误'}}</view>
      <view class="error_btn">
        <!-- <view bindtap="refresh" class="refresh">
          <image class="icon" src="/assets/image/replay.png"/>
        </view> -->
        <button bindtap="refresh" class="btnDefault">刷新</button>
        <button bindtap="goback" class="btnBack marginTop2">返回</button>
      </view>
    </view>
    <view wx:if="{{status===true}}">
      <slot>
      </slot>
    </view>
  </view>
</view>
 js
Component({
  options: {
    addGlobalClass: true
  },
  properties: {
    requesting: {
      type: Boolean,
      value: false,
      observer: 'requestingEnd',
    },
    status: {
      type: Boolean,
      value: false
    },
    errText: {
      type: String,
      value: ''
    },
    errImg: {
      type: String,
      value: '/assets/image/empty-image-error.png'
    },
    cancelLoading:{
      type: Boolean,
      value: false
    }
  },
  data: {

  },
  methods: {
    /**
		 * 监听 requesting 字段变化
		 */
		requestingEnd(newVal, oldVal) {
      if(!this.data.cancelLoading){
        if (oldVal === true && newVal === false) {
          if(this.data.status===false&&this.data.errText==''){
            //兼容request.js中拦截500和404响应,toast提示不能立马关闭
            setTimeout(() => {
              wx.hideLoading()  
            }, 2000);
          }else{
            wx.hideLoading()
          }
        } else {
          wx.showLoading({
            title: '加载中',
            mask: true
          })
        }
      }
    },
    init: function(){
      //防止重复调用
      if(this.data.requesting){
        return;
      }
      //插件初始化
      this.setData({
        requesting : true,
        status: false,
        errText: ''
      });
      this.network().then(() => {
        this.triggerEvent('initPage',{
          callback: res=>{
            this.setData(res);
          }
        });
      }).catch(res => {
        this.setData({
          requesting : false,
          status : false,
          errText : res
        });
      })
      
    },
    refresh: function(){
      this.init();
    },
    network: function() {
        return new Promise((resolve, reject) => {
            wx.getNetworkType({
                success: res => {
                    if (res.networkType != 'none') {
                      resolve();
                    } else {
                      reject("网络异常");
                    }
                },
            })
        })
    },
    goback: function(){
      wx.navigateBack();
    }
  },
  pageLifetimes: {
    show: function() {
      // 页面被展示
    },
    hide: function() {
      // 页面被隐藏
    }
  },
  lifetimes: {
    attached: function() {
      // 在组件实例进入页面节点树时执行
      if(!this.data.requesting){
        this.init();
      }
    },
  }
})

/* 
  使用该组件的页面js中添加如下方法:

  initPage(e){
    let that = this;
    let callback = e.detail.callback;
    let url = app.globalData.url + "/...";
    request.requestPostApi(url,{},this,succRes=>{
      callback({
        status : succRes.status,
        errText : succRes.message
      });
      if(succRes.status){
        doSuccess...
      }
    },failRes=>{
      callback({
        status : false,
        errText : '未知错误'
      });
    },completeRes=>{
      callback({requesting:false});
    });
  }
*/

3)倒计时组件van-count-down 

本文使用的UI组件为Vant Weapp - 轻量、可靠的小程序 UI 组件库

<view>剩余时间:
    <van-count-down use-slot
    class="control-count-down"
    time="{{ examInfo.remainingTime }}"
    bind:change="remainingTimeChange"
    bind:finish="remainingTimeFinish">
    <text class="time" wx:if="{{vantRemainingTime.days>0}}">{{ vantRemainingTime.days }}天</text>
    <text class="time" >{{ vantRemainingTime.hours>9?vantRemainingTime.hours:'0'+vantRemainingTime.hours }}时</text>
    <text class="time" >{{ vantRemainingTime.minutes>9?vantRemainingTime.minutes:'0'+vantRemainingTime.minutes }}分</text>
    <text class="time" >{{vantRemainingTime.seconds>9?vantRemainingTime.seconds:'0'+vantRemainingTime.seconds}}秒</text>
    </van-count-down>
</view>

通过组件绑定事件remainingTimeChange来处理倒计时问题。除了计算整场考试剩余时间,可调用自定义函数去控制单个题目显示时长。

remainingTimeChange(e) {
    this.setData({
      'vantRemainingTime':e.detail
    });
    //自定义函数执行业务逻辑
    this.timeGo(e.detail);
}

当小程序切换到后台,js定时器会停止,考试剩余时间就需要重新从服务端获取,可以在onShow中来触发。

onHide(){
    this.setData({pageHide:true})
},
onShow(){
    this.setData({pageHide:false});
    //请求服务端更新考试剩余时间
    this.updateRemainingTime();
}

4)父页面js

在onLoad中初始化子页面的滚动区域大小,在onReady中初始化tfjs插件(注:20230828,鸿蒙系统无法使用,暂未解决。该插件主要为在小程序端追踪人脸,实时提示用户保持头像居中,可整体移除。具体的考试监控由相机拍照上传至服务端后,异步执行)。tfjs插件使用教程可自行搜索。

const app = getApp();
const faces = require("../../../utils/face-storage.js");
const request = require("../../../utils/request.js");
const util = require("../../../utils/util.js");
const examApi = require("../../../utils/exam.js");
var fetchWechat = require('fetch-wechat');
var tf = require('@tensorflow/tfjs-core');
var webgl = require('@tensorflow/tfjs-backend-webgl');
var plugin = requirePlugin('tfjsPlugin');
const faceDetection = require('@tensorflow-models/face-detection');
const log = require('../../../utils/log.js')

onLoad(options) {
    util.triggerEvent('resetIsClick');
    this._cup2model = app.globalData.cup2model;
    var that = this;
    that.initViewHeight();
    //防止 setTimeout 和 setInterval 在页面返回后继续执行
    var taskId = setInterval(() => {
      let timeout;
      if(!util.isEmpty(that.data.examInfo)){
        timeout = that.data.examInfo.timeout;
      }
      if(timeout!=1){
        that.updateRemainingTime();
      }else{
        clearInterval(taskId);
      }
    }, 1000*60*10)
    that.setData({taskId,taskId});
  },
  initViewHeight: function(){
    var that = this;
    wx.getSystemInfoAsync({
      success: function(res) {
        let {windowHeight,windowWidth} = res;
        //页面高度减头部底部按钮高度,结合自身页面情况计算
        let rpxHeight = Math.floor(750*(windowHeight-105)/(windowWidth || 375))-280;
        that.setData({viewHeight:rpxHeight});
      }
    });
  },
async onReady(){
    if(this.data.useTfjs){
      this.initTfjsPlugin();
      this.loadmodel();
    }
  },
  initTfjsPlugin(){
    let config = {
      // polyfill fetch function
      fetchFunc: fetchWechat.fetchFunc(),
      // inject tfjs runtime
      tf,
      // inject webgl backend
      webgl,
      // provide webgl canvas
      canvas: wx.createOffscreenCanvas()
    }
    plugin.configPlugin(config);
  },
  async loadmodel(){
    try {
      let start = new Date();
      const FILE_STORAGE_PATH = 'zjk_face_model';
      const fileStorageHandler = plugin.fileStorageIO(FILE_STORAGE_PATH,wx.getFileSystemManager());
      const model = faceDetection.SupportedModels.MediaPipeFaceDetector;
      const detectorConfig = {
        maxFaces: 2,
        runtime: 'tfjs' 
      };
      detectorConfig.detectorModelUrl = this._modelUrl;
      this._model = await faceDetection.createDetector(model, detectorConfig);
      let end = new Date();
      log.info("下载模型成功,耗时:"+ (end-start)+" ms");
      if(!this._cup2model){
        this._model.estimateFaces(
          {
            data:new Uint8Array(),
            width:1,
            height:1
          }, 
          {flipHorizontal: false}
        ).then(e=>{
          log.info("模型运行成功,耗时:"+ (new Date()-end)+" ms")
          if(this._modelLoad){
            log.info("成功后关闭等待框")
            wx.hideLoading();
          }
          this._modelLoad = true;
          this._cup2model = true;
          app.globalData.cup2model = true;
          console.log("第一次模型运行成功")
        }).catch(e=>{
          log.error("模型运行失败,耗时:"+ (new Date()-end)+" ms")
          if(this._modelLoad){
            log.error("失败后关闭等待框")
            wx.hideLoading();
          }
          this._modelLoad = true;
          console.log("模型运行失败",e)
        })
      }
    } catch (error) {
      log.error("捕捉到异常信息"+JSON.stringify(error))
      if(this._modelLoad){
        log.error("捕捉到异常信息后关闭等待框")
        wx.hideLoading();
      }
      this._modelLoad = true;
    }
    //代码运行失败
    /* try{
      console.log("加载本地模型")
      detectorConfig.detectorModelUrl = fileStorageHandler;
      this._model = await faceDetection.createDetector(model, detectorConfig);
    }catch(e){
      console.log("加载本地模型失败",e)
      detectorConfig.detectorModelUrl = this._modelUrl;
      this._model = await faceDetection.createDetector(model, detectorConfig);
      console.log("加载网络模型成功")
      try{
        this._model.detectorModel.save(fileStorageHandler);
      }catch(e){
        console.log("保存本地模型失败",e)
      }
      
    } */
  }
  onShow(){
    this.setData({pageHide:false});
    this.updateRemainingTime();
  },
  onHide(){
    this.setData({pageHide:true})
  },
  onUnload(){
    if(this.data.taskId){
      clearInterval(this.data.taskId);
    }
    let countdown = this.selectComponent('.control-count-down');
    if(countdown){
      //返回上一页必须关闭定时器,否则会一直执行
      countdown.pause();
    }
  },
  /** 页面初始化,获取考试信息*/
  initPage(e){
    let that = this;
    let callback = e.detail.callback;
    let url = app.globalData.url + "/.../getExamInfo";
    request.requestPostApi(url,{},this,succRes=>{
      callback({
        status : succRes.status,
        errText : succRes.message
      });
      if(succRes.status){
        that.setData({
          examInfo : succRes.result
        });
        if(!that.data.examInfo.examing){//如果后台已经提交或者无考试,重置本地考试完成状态
          examApi.finish(false);
        }
        that.setData({localFinish:examApi.finish()})
      }
    },failRes=>{
      callback({
        status : false,
        errText : '未知错误'
      });
    },completeRes=>{
      callback({requesting:false});
      if(!this._cup2model&&!this._modelLoad){
        this._modelLoad = true;
        wx.showLoading({
          title: '加载中',
          mask: true
        })
      }
    });
  },
    timeBeforeChange(e) {
    this.setData({
      timeBefore: e.detail
    });
  },
  timeBeforeFinish(){
    this.setData({
      'examInfo.timeout':0
    })
  },
  remainingTimeChange(e) {
    this.setData({
      'vantRemainingTime':e.detail
    });
    this.timeGo(e.detail);
  },
  remainingTimeFinish(){
    this.setData({
      'examInfo.timeout':1
    })
    if(this.data.examInfo.examing){
      this.submitExam();
    }
  },
  updateRemainingTime(){
    var that = this;
    if(!util.isEmpty(that.data.examInfo)&&that.data.examInfo.timeout!=1&&!(that.data.examInfo.answerCountCurrent==that.data.examInfo.answerCountMax&&!that.data.examInfo.examing&&that.data.examInfo.isSubmit)){
        request.requestPostApi(app.globalData.url + "/.../validateRemainingTime",{},this,succRes=>{
          if(succRes.status){
            that.updateExamInfo(succRes.result);
          }else{
            console.log("刷新时间失败:"+succRes.message);
          }
        },failRes=>{
          console.log("刷新时间失败");
        });
    }
  }

5)子页面js

  pageenter(){
	//打开子页面,执行initPage绑定方法
    let examPage = this.selectComponent("#examPage");
    examPage.setData({requesting:false});
    examPage.init();//执行的是initExamPage
  },  
  beforeleave(){
	//隐藏子页面,停止摄像头
    this._listener&&this._listener.stop();
    this.setData({
      curr_question: {},
      containerShow:false
    })
  },
  afterleave(){
	//	可在该事件内阻止用户返回
    // if(!this.data.localFinish&&this.data.examInfo.timeout==0){
    //   this.setData({containerShow:true});
    //   setTimeout(() => {
    //     request.toast("请保持答题页面至提交试卷")
    //   }, 300);
    // }
  },
 initExamPage(e){
    let that = this;
    that.setData({isLoadingExam:false});
    let callback = e.detail.callback;
    let{questions,answers,currQuestion} = examApi.getAll();
    //中途退出,直接从本地缓存取题目
    if(!util.isEmpty(questions)&&!util.isEmpty(currQuestion)){
      console.log("从缓存中获取试题数据")
      var count = 0;
      for(let key in questions){
        count++;
      }
      that.setData({
        questions:questions,
        listCount: count,
        userAnswers: answers,
        curr_question:questions[currQuestion.id]
      })
      //作用:显示剩余时间后再callback
      if(!util.isEmpty(that.data.vantRemainingTime)){
        that.timeGo(that.data.vantRemainingTime);
      }
      callback({
        status : true,
        errText : '',
        requesting : false
      });
	  //初始化相机
      that.initCamera();
      return;
    }

    let url = app.globalData.url + "/.../getExamQuestions";
    request.requestPostApi(url,{},this,succRes=>{
      callback({
        status : succRes.status,
        errText : succRes.message
      });
      if(succRes.status){
        let qs = succRes.result;
        let temp_questions = {};
        for(var i=0;i<qs.length;i++){
          temp_questions[qs[i].id]=qs[i];
        }
        that.setData({
          questions:temp_questions,
          listCount: qs.length
        })
        that.showQuestion(qs[0]["id"]);//显示第一题,在本地保存questions和curr_question
        that.updateRemainingTime();
		//初始化相机
        that.initCamera();
      }
    },failRes=>{
      callback({
        status : false,
        errText : '未知错误'
      });
    },completeRes=>{
      callback({requesting:false});
    });
  }
  initCamera(){
    console.log("显示相机和画布");
    this.setData({cameraShow:true});
    setTimeout(() => {//必须setTimeout,等待页面渲染完成
      try {
        if(!this._camera){
          console.log("创建相机")
          this._camera = wx.createCameraContext();
          this._camera.setZoom({
            zoom:1
          })
        }else{
          this._camera.setZoom({
            zoom:1
          })
        }
        if(this.data.useTfjs){//判断是否使用tfjs
          this.initCanvas();//初始画布,用于显示人脸追踪框
        }
        this.addCameraListener();
        this._listener.start();
      } catch (error) {
        console.log("exception:",error)
      }
    }, 1500);
  },
  initCanvas(){
    if(!this._canvas){
      console.log("创建画布")
      const query = wx.createSelectorQuery();
      query.select('#mark-canvas')
        .fields({ node: true, size: true })
        .exec((res) => {
          this._canvas = res[0].node;
          const canvas = res[0].node;
          const canvasContext = canvas.getContext('2d')
          const systemInfo = wx.getSystemInfoSync()
          //设备像素比
          const dpr = systemInfo.pixelRatio
          //画布像素
          canvas.width = res[0].width * dpr
          this._canvasWidthPix = canvas.width;
          canvas.height = res[0].height * dpr
          this._canvasHeightPix = canvas.height
          this._dpr = dpr;
          canvasContext.lineWidth = 3
          canvasContext.strokeStyle = 'red'
          canvasContext.fillStyle = 'yellow'
          this._canvasContext = canvasContext;
        })
    }
  },
  addCameraListener(){
    if(!this._listener){
      console.log("创建相机监听")
      this._listener = this._camera.onCameraFrame(async frame=> {
		//当使用tfjs时,每隔1秒(60帧)判断一次人脸位置
        if(this.data.useTfjs&&this.data.containerShow){
          if(this._frameWidth!=frame.width || this._frameHeight != frame.height){
            this._frameWidth = frame.width;
            this._frameHeight = frame.height;
            this._xRatio = Math.round(this._canvasWidthPix*1000 / frame.width)/1000;
            //等比例缩放后画布高的像素值
            let tempCanvasnHeightPix = Math.floor(frame.height*this._xRatio);
            //画布沿y轴偏移量
            this._yoffset = Math.floor((this._canvasHeightPix-tempCanvasnHeightPix)/2);
            console.log("初始化缩放倍数和偏移量",this._frameWidth,this._frameHeight,this._xRatio,this._yoffset)
          }
          this._count++;
          if (this._count === 60) {      
            this.clearMarkCanvas();
            if(this._model){
              const res = await this.detectFace(frame);
              this.validateFace(res);
            }
            this._count = 0;
          }else if(this._count>60){
            this._count = 0;
          }
        }
      });
    }
  },
  async detectFace(frame) {
    const image = {
      data: new Uint8Array(frame.data),
      width: frame.width,
      height: frame.height
    }
    const estimationConfig = {flipHorizontal: false};
    return await this._model.estimateFaces(image, estimationConfig);
  },
  validateFace(res){
    //this._canvasContext.strokeRect(0,0,this._canvasWidthPix,this._canvasHeightPix);
    let msg = "";
    if(res.length<1){
      msg = '请保持头像居中';
      this.setData({faceError:msg});
      return;
    }
    /* if(res.length>1){
      msg = '检测到多人';
      this.setData({faceError:msg});
      return;
    } */
    
    const face = res[0];
    //画关键点
    // const keypoints = face.keypoints
    // for(let i=0; i<6; ++i){
    //   const point = this.transformPoint([keypoints[i].x,keypoints[i].y])
    //   this._canvasContext.fillRect(point[0],point[1],6,6)
    // }
    const box = face.box;
    const start = this.transformPoint([box.xMin,box.yMin]);
    const end = this.transformPoint([box.xMax,box.yMax]);
    let size = [end[0] - start[0], end[1] - start[1]]
    //画人脸预测框
    //this._canvasContext.strokeRect(start[0], start[1], size[0], size[1]);
    /* if (size[0] < 0.2 * this._canvasWidthPix){
      msg = '距离太远';
      this.setData({faceError:msg});
      return;
    } */
    if (size[0] > this._canvasWidthPix*1.05){
      msg = '距离太近';
      this.setData({faceError:msg});
      return;
    }
    if(start[0] < -0.06 * this._canvasWidthPix){
      msg = "头像偏左,";
    }else if(end[0] > 1.06 * this._canvasWidthPix){
      msg = "头像偏右,";
    }else if(start[1] < 0.14 * this._canvasHeightPix){
      msg = "头像偏上,";
    }else if(end[1] > 1.06 * this._canvasHeightPix){
      msg = "头像偏下,";
    }
    if(msg){
      this.setData({faceError:msg+'请保持头像居中'});
      return;
    }
    this.setData({faceError:""});
  },
  transformPoint(point){
    const x = Math.floor(point[0] * this._xRatio);
    const y = Math.floor(point[1] * this._xRatio)+this._yoffset;
    return [x,y]
  },
  clearMarkCanvas(){
    this._canvasContext&&this._canvasContext.clearRect(0,0,this._canvasWidthPix,this._canvasHeightPix)
  },
  cameraError(e){
    request.toast("摄像头打开失败,请退出重试或更换手机!")
  }

如果要使用tfjs,请参考下面摄像头和画布的关系。画布透明地覆盖在摄像头上。如果要求隐形监控的话,可以将相机组件长宽设置为1px,同时,在onCameraFrame中截取视频帧图片进行上传,以达到隐形效果。

因本案例无特殊要求,所以采用了最简单的方式实现监控:每间隔一道题拍一次照片上传至服务端。这样做的弊端是,苹果用户拍照会有系统提示声(甲方没说就懒得改了...)

    <block wx:if="{{cameraShow}}">
      <view class="camera-container-out">
        <view class="camera-container-in">
          <camera
            device-position="front"
            flash="off"
            frame-size="small"
            binderror="cameraError"
            class="camera"
          ></camera>
          <canvas id="mark-canvas" class="mark-canvas" type="2d"></canvas>
        </view>
      </view>
    </block>
.camera-container-out {
  position: absolute;
  top:100rpx;
  right: 30rpx;
  display: flex;
  flex-direction: column;
  align-items: center;
  z-index: 101;
}
.camera-container-in {
  display: flex;
  justify-content: flex-start;
}
.camera{
  width: 50px;
  height: 70px;
  z-index: 101;
}
.mark-canvas{
  position: absolute;
  width: 50px;
  height: 70px;
  z-index: 102;
}

6)拍照上传

图片保存使用阿里云oss服务。人脸比对也用的是阿里云服务。

  takePhoto() {
    const that = this;
    if(!this._camera){
      setTimeout(() => {
        that.takePhoto();
      }, 1100);
      return;
    }
    this._camera.takePhoto({
      quality: 'high',
      success: (res) => {
        let localSrc = res.tempImagePath;
        let type = localSrc.substring(localSrc.lastIndexOf("."));
        console.log("开始上传");
        let policy = this._policy;
        let signature = this._signature;
		let oSSAccessKeyId = this._OSSAccessKeyId;
        let date = new Date();
        let filePath = that.data.examInfo.themeId+"/"+that.data.examInfo.examUser+"/"+date.Format('yyyyMMddhhmmssS')+type;
        let uploadUrl = "https://域名.oss-cn-shanghai.aliyuncs.com";
        wx.uploadFile({
          url: uploadUrl, // 开发者服务器的URL。
          filePath: localSrc,
          name: 'file', // 必须填file。
          formData: {
            key: filePath,
            policy: policy,
            OSSAccessKeyId: oSSAccessKeyId,
            signature: signature
          },success: (res) => {
            if (res.statusCode === 204||res.statusCode === 200) {
              let photos = examApi.photos();
              photos.push("/"+filePath);
              let photoTimes = examApi.photoTimes();
              photoTimes.push(date.getTime());
              examApi.updatePhotoDatas(photos,photoTimes);
            }
          },
          fail: err => {
            console.log(err);
          }
        });
      },fail: err =>{
        console.log(err);
      }
    })
  }

7)注意事项

因tfjs依赖包比较大,考试模块建议采用分包开发。本案例中tfjs依赖包如下:

"dependencies": {

    "node-fetch": "2.6.1",

    "@mediapipe/face_detection": "0.4.1646425229",

    "@tensorflow-models/face-detection": "1.0.1",

    "@tensorflow/tfjs-backend-webgl": "3.20.0",

    "@tensorflow/tfjs-converter": "3.20.0",

    "@tensorflow/tfjs-core": "3.20.0",

    "fetch-wechat": "0.0.3"

  }

其他注意事项:

防止页面重复打开;防止页面返回后定时任务和相机监控继续执行;防止tfjs初始化失败对业务的影响;相机组件要在页面渲染完成后初始化,否则会错位。

最后

自认为这边文章写得很水,感谢阅读x3。如有问题请留意。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值