需求
要求实现用户上传身份证照片,自定义上传画面,自动截取身份证图片,并获取身份证上面的信息。
如果机型不支持则留有保底手段:相册上传图片,获取身份证上的信息。
大概思路
自定义上传画面
主要依赖 navigator.mediaDevices.getUserMedia API
自动截取身份证图片
根据 canvas.drawImage 去生成画布,随后进行 canvas.toDataURL 获取base64格式的图片
获取身份证上面的信息
这个先将base64图片上传oss,获取到图片url,再根据百度ocr身份证识别,获取到对应正反面的信息。
效果
实现
此功能为组件形式注入到上传图片的页面中,为保存一些数据
一些utils内的方法,redux中的操作也都在下面展示。
核心代码
import React, { memo, useEffect, useRef, useState } from 'react'
import PropsType from 'prop-types'
import styled from 'styled-components'
import { withRouter } from 'react-router-dom'
import { detectDeviceType } from 'zzy-javascript-devtools'
import { connect } from 'react-redux'
import globalSty from '../../api/global-style'
import { getUserMedia, getXYRatio, cameraErrorMsg } from '../../api/utils'
import { getIdCardMsg, setCameraType } from './store/actionCreators'
import HeaderBar from '../../components/HeaderBar'
import Toast from '../../components/Toast'
import { uploadFileBase64Req } from '../../api'
let captrueTimer
let getStreamTimer
let isStart = false
let videoStreams
let pageTimer
const Camera = (props) => {
const { type, close } = props
const {
setCameraTypeDispatch,
getIdCardMsgDispatch,
changeIdCardMsg,
uploadFile
} = props
// photo这个值是用来开发测试时在截取区域上方黑色地方展示当前截取base64图片的。
// const [photo, setPhoto] = useState('')
// 1-正面 2-反面
const [idCardType, setIdCardType] = useState(1)
useEffect(() => {
// 页面进入时,正反面状态以父组件传值为准
setIdCardType(type)
// 开始计时,超过指定时间就关闭页面,保证不会长期在此页面调用接口
startTimeing()
return () => {
clearTimeout(pageTimer)
}
}, [])
const videoRef = useRef()
// 截取区域 ref
const rectangle = useRef()
// 弹窗组件ref,下面的方法都为弹窗提示组件方法,可忽略。
const toastRef = useRef()
useEffect(() => {
if (videoRef.current && !isStart) {
isStart = true
// 获取视频流
getUserMediaStream(videoRef.current).then(() => {
getStreamTimer = setTimeout(() => {
// 成功后延迟500ms开始进行图片截取
startCaptrue()
clearTimeout(getStreamTimer)
}, 500)
})
}
return () => {
clearInterval(captrueTimer)
// 页面销毁时进行整体销毁
destoryStates()
}
}, [videoRef])
// 备用方案,相册icon,点击后销毁当前页
const clickUploadFileHandle = () => {
uploadFile()
destoryStates()
close()
}
// 必须在https下才可以使用,否则直接报错
// 开始获取用户媒体流
const getUserMediaStream = (videoNode) => {
const params1 = {
video: { facingMode: { exact: 'environment' } } // 设置true为获取前置,{ facingMode: { exact: 'environment' } } 为后置摄像头
}
const params2 = {
video: {
facingMode: 'user'
}
}
return getUserMedia({
audio: false,
// 判断为移动端还是pc端
...(detectDeviceType() === 'Mobile' ? params1 : params2)
})
.then((res) => {
videoStreams = res
return getStreamRes(res, videoNode)
})
.catch((error) => {
console.log('访问用户媒体设备失败:', error.name, error.message, error)
toastRef.current.warnToast({
// cameraErrorMsg 为错误信息
title: `${cameraErrorMsg(error.name)},请返回上级页面重新拍照上传`,
cancel: '返回',
confirm: '确认',
onFinally() {
// 失败后状态管理器内设置当前上传方式为图片上传,再次点击上传不进入此处,并销毁当前页面
setCameraTypeDispatch('upload')
destoryStates()
close()
}
})
return Promise.reject()
})
}
// 调用成功
const getStreamRes = (stream, video) => {
return new Promise((resolve) => {
video.srcObject = stream
// 在指定视频/音频(audio/video)的元数据加载后触发
video.onloadedmetadata = () => {
// 获取成功之后等待元数据加载后进行播放
video.play()
resolve()
}
})
}
// 截取裁剪框
const startCaptrue = () => {
const _canvas = document.createElement('canvas')
_canvas.style.display = 'block'
// 根据video的xy比率,并提供外部比率进行换算
const { YRatio, XRatio } = getXYRatio(videoRef.current)
// 获取到截图面的数据
const { left, top, width, height } =
rectangle.current.getBoundingClientRect()
_canvas.height = height
_canvas.width = width
const context = _canvas.getContext('2d')
captrue()
function captrue() {
// 这里 drawImage 的参数有点问题,不是很严丝合缝,待后期调试,目前够用
captrueTimer = setInterval(async () => {
// 微信下沿偏多,但是其余浏览器中正好,保留参数
// left,top 各扩展20px,width,height翻倍
// top,height各累加20px 降低
context.drawImage(
videoRef.current,
XRatio(left + window.scrollX - 20),
YRatio(top + window.scrollY - 20) - 20,
XRatio(width + 40),
height + 40 + 20,
0,
0,
width,
height
)
// 获取当前截图的base64编码
const base64 = _canvas.toDataURL('image/jpeg')
console.log(base64, 'base64')
clearTimeout(captrueTimer)
// 上传base64获取oss地址
const { url } = await uploadFileBase64Req(base64, true)
// 从ocr中获取身份证信息,getIdCardMsgDispatch 方法里面就已经包含了数据的校验
const idCardsMsg = await getIdCardMsgDispatch(url, true)
if (idCardsMsg.type === type) {
// 校对成功,属于正确的一面
close()
changeIdCardMsg(idCardsMsg)
} else {
// 否则继续校验
captrue()
}
// setPhoto(base64)
}, 1500)
}
}
const startTimeing = () => {
pageTimer = setTimeout(() => {
destoryStates()
toastRef.current.warnToast({
title: '页面停留过久,请返回上级页面重新拍照上传',
cancel: '返回',
confirm: '确认',
onFinally() {
close()
}
})
}, 3 * 60 * 1000) // 三分钟
}
// 关闭流
const closeCameras = () => {
videoStreams && videoStreams.getTracks()[0].stop()
}
// 销毁页面数据
const destoryStates = () => {
closeCameras()
isStart = false
clearTimeout(getStreamTimer)
getStreamTimer = null
clearInterval(captrueTimer)
captrueTimer = null
videoStreams = null
}
return (
<CameraContainer id="Camera_component_Container">
<HeaderBar clickArrow={() => close()} />
<video id="video" ref={videoRef} autoPlay muted playsInline></video>
<div className="shadowView">
<div className="rectangle" ref={rectangle}>
{idCardType === 1 && (
<img
src={require('./image/renxiang.svg')}
alt=""
className="renxiang"
/>
)}
{idCardType === 2 && (
<img
src={require('./image/guohui.svg')}
alt=""
className="guohui"
/>
)}
<span className="say">请将身份证置于取景框内</span>
<div className="handleBar">
<img
src={require('@/static/image/photo.svg')}
alt=""
className="photoIcon"
onClick={clickUploadFileHandle}
/>
</div>
</div>
{/* <img
className="photo"
src={photo}
style={{ position: 'absolute', top: 0 }}
></img> */}
</div>
<Toast ref={toastRef} />
</CameraContainer>
)
}
Camera.propTypes = {
type: PropsType.number,
close: PropsType.func,
changeIdCardMsg: PropsType.func,
uploadFile: PropsType.func,
setCameraTypeDispatch: PropsType.func,
getIdCardMsgDispatch: PropsType.func
}
const mapDispatchToProps = (dispatch) => ({
setCameraTypeDispatch(type) {
dispatch(setCameraType(type))
},
async getIdCardMsgDispatch(url, isCamera) {
const data = await dispatch(getIdCardMsg(url, isCamera))
return data
}
})
export default connect(null, mapDispatchToProps)(withRouter(memo(Camera)))
const CameraContainer = styled.div`
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
z-index: 11;
overflow: hidden;
background-color: #000;
#video {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
z-index: 1;
}
.shadowView {
/* ${globalSty.positionCenter()}; */
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
z-index: 2;
/* background-color: rgba(0, 0, 0, 0.4); */
display: flex;
align-items: center;
justify-content: center;
.rectangle {
width: 80vw;
/* 身份证件长宽比例 1.58:1 */
height: calc(80vw / 1.58);
border-radius: 1.5rem;
border: 0.1rem solid rgba(243, 243, 243, 1);
box-shadow: 0 0 0 2000rem rgba(0, 0, 0, 0.7);
position: relative;
.renxiang {
position: absolute;
right: 1rem;
top: 45%;
transform: translate(0, -50%);
width: 15rem;
}
.guohui {
position: absolute;
top: 1.5rem;
left: 2rem;
width: 7.5rem;
}
.say {
position: absolute;
font-size: 1.5rem;
font-family: PingFangSC-Regular, PingFang SC;
font-weight: 400;
color: rgba(255, 255, 255, 0.5);
left: 50%;
bottom: -3.5rem;
transform: translate(-50%, -50%);
}
.handleBar {
position: absolute;
left: 0;
right: 0;
bottom: -9rem;
.photoIcon {
width: 4rem;
height: 4rem;
}
}
}
}
`
utils.js
//访问用户媒体设备的兼容方法
const getUserMedia = (constrains) => {
if (navigator.mediaDevices?.getUserMedia) {
//最新标准API
return navigator.mediaDevices.getUserMedia(constrains)
} else if (navigator.webkitGetUserMedia) {
//webkit内核浏览器
return navigator.webkitGetUserMedia(constrains)
} else if (navigator.mozGetUserMedia) {
//Firefox浏览器
return navigator.mozGetUserMedia(constrains)
} else if (navigator.getUserMedia) {
//旧版API
return navigator.getUserMedia(constrains)
}
}
const hasUserMedia = () => {
if (navigator.mediaDevices?.getUserMedia) {
//最新标准API
return true
} else if (navigator.webkitGetUserMedia) {
//webkit内核浏览器
return true
} else if (navigator.mozGetUserMedia) {
//Firefox浏览器
return true
} else if (navigator.getUserMedia) {
//旧版API
return true
}
return false
}
const cameraErrorMsg = (name) => {
if (name === 'AbortError') {
return '操作被终止'
} else if (name === 'NotAllowedError') {
return '权限被拒绝'
} else if (name === 'NotFoundError') {
return '无法满足操作'
} else if (name === 'NotReadableError') {
return '读取失败'
} else if (name === 'OverconstrainedError') {
return '设备无法被满足'
} else if (name === 'SecurityError') {
return '权限被禁止'
} else if (name === 'TypeError') {
return '传值错误'
} else {
return '操作失败'
}
}
// 获取video的xy比率,并提供外部比率进行换算
function getXYRatio(video) {
// videoHeight为video 真实高度
// offsetHeight为video css高度
const {
videoHeight: vh,
videoWidth: vw,
offsetHeight: oh,
offsetWidth: ow
} = video
return {
YRatio: (height) => {
return (vh / oh) * height
},
XRatio: (width) => {
return (vw / ow) * width
}
}
}
// 判断身份证信息来自正/反
const isIdCardType = (msg) => {
if (msg.name && msg.sex && msg.idcard) {
return 1
} else if (msg.authority && msg.validDate) {
return 2
} else return 0
}
store
store中使用了immutable格式,不清楚的朋友可以先看一下文档,只是改变数据结构,其余的没什么变化。
reducer.js
import { fromJS } from 'immutable'
import {
CHANGE_IDCARD_MSG,
SET_CAMERA_TYPE,
} from './constants'
const defaultState = fromJS({
cameraType: 'camera', // upload-拍照上传 camera-实时传输
idCardMsg: {}
})
const reducer = (state = defaultState, action) => {
switch (action.type) {
case SET_CAMERA_TYPE:
return state.set('cameraType', action.data)
case CHANGE_IDCARD_MSG:
return state.set('idCardMsg', action.data)
default:
return state
}
}
export default reducer
actionCreators.js
import { Toast } from 'antd-mobile'
import { fromJS } from 'immutable'
import { isIdCard, isName } from 'zzy-javascript-devtools'
import { idCardAndlysisReq } from '../../../api'
import { getAge, isIdCardType } from '../../../api/utils'
import { CHANGE_IDCARD_MSG, SET_CAMERA_TYPE } from './constants'
export const setCameraType = (data) => ({
type: SET_CAMERA_TYPE,
data: fromJS(data)
})
const changeIdCardMsg = (data) => ({
type: CHANGE_IDCARD_MSG,
data: fromJS(data)
})
export const getIdCardMsg = (url, isCamera = false) => {
return async (dispatch) => {
const res = await idCardAndlysisReq(url, isCamera)
const type = isIdCardType(res)
const { name, idcard, sex, authority, validDate } = res
if (type === 1) {
if (isName(name) && isIdCard(idcard)) {
const age = getAge(idcard)
const obj = { name, idcard, sex, age, type: 1, url }
dispatch(changeIdCardMsg(obj))
return obj
} else {
return isCamera ? '' : Toast.offline('信息获取错误,请重新上传!', 3)
}
} else if (type === 2) {
const obj = { authority, validDate, type: 2, url }
dispatch(changeIdCardMsg(obj))
return obj
} else {
return isCamera ? '' : Toast.offline('信息获取错误,请重新上传!', 3)
}
}
}
export const clearIdCardMsg = () => {
return (dispatch) => {
dispatch(changeIdCardMsg({}))
}
}
插件版本
{
"devDependencies": {
"react-redux": "^7.2.4",
"redux": "^4.1.0",
"zzy-javascript-devtools": "^1.5.2"
},
"dependencies": {
"html2canvas": "^1.4.1",
"immutable": "^4.0.0",
"redux-immutable": "^4.0.0",
"redux-thunk": "^2.4.1",
"styled-components": "^5.3.5"
}
}
想法
在之后用了一次html2canvas的节点截图,感觉可以将canvas.drawImage步骤改为html2canvas会更好一些,但是没有进行尝试,觉得可行。