Vue拍照上传组件(重拍、切换已有摄像头)
背景
由于业务需求,需要进行拍照上传,百度了一遍组件都不太合适。自己结合已有案例封装了一下,可以把这个组件嵌套到el-dialog里面就可以使用。
实现功能
- 实时加载预览画面
- 点击拍照截取照片
- 不满意可以重拍,不会中断之前的视频流
- 加载当前设备的所有摄像头,可以进行选择切换
依赖
- ElementPlus
- @iconify/vue
- tailwindCSS
使用方式
<template>
<el-dialog
width="90%"
title="拍照"
v-model="visiable.camera"
@open="handlePhotoPreviewOpen"
@close="handlePhotoPreviewClose"
>
<take-photo ref="takePhotoRef" @upload="handleImageUpload" />
</el-dialog>
</template>
<script setup>
import { ref } from "vue";
import TakePhoto from "./take-photo.vue";
const visiable = ref({ camera: false });
const takePhotoRef = ref(null);
/** 拍照弹窗打开时 */
function handlePhotoPreviewOpen() {
takePhotoRef.value?.openCamera();
}
/** 拍照弹窗关闭时 */
function handlePhotoPreviewClose() {
takePhotoRef.value?.stop();
visiable.value.camera = false;
}
</script>
组件代码
// take-photo.vue
<template>
<div class="main">
<!-- 摄像头列表 -->
<div class="camera-list" v-if="cameraList.length">
<p class="text-xl">摄像头列表(点击可切换):</p>
<el-radio-group v-model="selectedCamera" class="mt-4">
<el-radio-button
style="--el-font-size-base: 18px"
v-for="(camera, index) in cameraList"
:key="camera.deviceId"
:label="index"
:disabled="!isCameraAvailable"
@change="handleCameraSwitchChange"
>
{{ camera.label }}
</el-radio-button>
</el-radio-group>
</div>
<!-- 预览区域 -->
<div
v-loading="isLoadingCamera"
element-loading-text="正在加载摄像头..."
class="preview-area mt-4 flex rounded-lg overflow-hidden relative justify-center items-center"
>
<!-- 画布用来拍照 -->
<canvas ref="canvasRef" class="mt-4 hidden" />
<!-- 摄像头画面 -->
<video
ref="videoRef"
v-show="!imageURL"
class="w-0 h-0 flex-1"
:class="{ '!w-full !h-full': isCameraAvailable }"
/>
<!-- 摄像头不可用提示 -->
<el-empty
v-if="!isLoadingCamera && !isCameraAvailable"
class="absolute top-0 left-0 right-0 bottom-0"
>
<template #image>
<iconify-icon
icon="mingcute:computer-camera-off-line"
:width="100"
class="text-gray-400"
/>
</template>
<template #description>
<div class="flex flex-col items-center">
<p>摄像头开启失败</p>
<p class="mt-2">请检查摄像头连接状态</p>
</div>
</template>
</el-empty>
<!-- 拍照的图片 -->
<el-image
fit="contain"
:src="imageURL"
v-show="imageURL"
hide-on-click-modal
class="w-full h-full"
:previewSrcList="[imageURL]"
/>
</div>
<!-- 拍照按钮 -->
<div class="flex justify-center h-[65px] mt-4">
<el-button
@click="takePhoto"
:disabled="!isCameraAvailable"
class="w-full !h-[65px] !rounded-lg"
:type="imageURL ? 'plain' : 'primary'"
>
<span class="text-2xl tracking-[0.2rem]">{{ imageURL ? "重拍" : "拍照" }}</span>
</el-button>
<el-button
type="primary"
v-if="imageURL"
@click="handleImageUpload"
:disabled="!isCameraAvailable"
class="w-full !h-[65px] !rounded-lg"
>
<span class="text-2xl tracking-[0.2rem]">确认上传</span>
</el-button>
</div>
</div>
</template>
<script setup>
import { ElMessage } from "element-plus";
import { ref, onBeforeUnmount } from "vue";
import { Icon as IconifyIcon } from "@iconify/vue";
const emits = defineEmits(["upload"]);
const canvasRef = ref(null); // canvas控件对象
const videoRef = ref(null); // video 控件对象
const imageURL = ref(null); // 照片路径
const imageBlob = ref(null); // 二进制图片数据
const isCameraAvailable = ref(false); // 摄像头是否可用
const isLoadingCamera = ref(false); // 是否正在加载摄像头
const Stream = ref(null); // 播放流
const cameraList = ref([]); // 摄像头列表
const selectedCamera = ref(0); // 当前选中的摄像头
/** 获取当前连接到设备的摄像头数量 */
function getCameraList() {
if (navigator.mediaDevices) {
navigator.mediaDevices.enumerateDevices().then((devices) => {
cameraList.value = devices.filter((device) => device.kind === "videoinput");
openCameraStream();
});
} else {
ElMessage.error("该浏览器或所处环境不支持开启摄像头,请更换最新版Chrome浏览器");
}
}
/** 外面可以通过ref拿到这个方法来打开摄像头 */
function openCamera() {
if (cameraList.value.length) {
openCameraStream();
return;
}
getCameraList();
}
/** 切换摄像头 */
function handleCameraSwitchChange() {
stop();
openCameraStream();
}
/** 打开摄像头流 */
function openCameraStream() {
imageURL.value = null;
// 如果已经开启摄像头就不再开启
if (Stream.value) {
isCameraAvailable.value = true;
return;
}
isLoadingCamera.value = true;
isCameraAvailable.value = false;
// 检测浏览器是否支持mediaDevices
if (navigator.mediaDevices) {
navigator.mediaDevices
// 开启视频,关闭音频
.getUserMedia({
audio: false,
video: {
deviceId:
selectedCamera.value !== null
? cameraList.value[selectedCamera.value].deviceId
: undefined
}
})
.then((stream) => {
isCameraAvailable.value = true;
// 将视频流传入viedo控件
videoRef.value.srcObject = stream;
Stream.value = stream;
// 播放
videoRef.value.play();
})
.catch((err) => {
isCameraAvailable.value = false;
console.error("摄像头开启失败:" + err.message);
ElMessage.warning({
message: `摄像头开启失败:${err.message.includes("Requested device not found") ? "未找到摄像头" : err.message}`,
duration: 3000
});
})
.finally(() => {
isLoadingCamera.value = false;
});
} else {
ElMessage.error({
message: "该浏览器或所处环境不支持开启摄像头,请更换最新版Chrome浏览器",
duration: 3000
});
isLoadingCamera.value = false;
}
}
/** 点击拍照 */
function takePhoto() {
// 如果已经拍照了就重新启动摄像头
if (imageURL.value) {
openCameraStream();
return;
}
// 设置画布大小与摄像大小一致
canvasRef.value.width = videoRef.value.videoWidth;
canvasRef.value.height = videoRef.value.videoHeight;
// 执行画的操作
canvasRef.value.getContext("2d").drawImage(videoRef.value, 0, 0);
// 将结果转换为可展示的格式
imageURL.value = canvasRef.value.toDataURL("image/png");
// 将结果转换为二进制数据
canvasRef.value.toBlob((blob) => {
imageBlob.value = blob;
});
// 关闭摄像头
// stop();
}
/** 关闭摄像头 */
function stop() {
let stream = videoRef.value?.srcObject ?? Stream.value ?? null;
Stream.value = null;
if (!stream) return;
stream.getTracks().forEach((x) => x.stop());
}
/** 点击上传图片 */
function handleImageUpload() {
emits("upload", imageBlob.value);
}
/** 组件销毁时关闭摄像头 */
onBeforeUnmount(() => {
stop();
});
/** 向组件外面暴露的方法 */
defineExpose({ openCamera, stop });
</script>
<style lang="scss" scoped>
.main {
width: 100%;
min-height: 570px;
}
.preview-area {
height: 800px;
border-radius: 12px;
box-shadow:
rgba(14, 30, 37, 0.12) 0px 2px 4px 0px,
rgba(14, 30, 37, 0.32) 0px 2px 16px 0px;
:deep(.el-loading-mask) {
left: -1px;
.el-loading-text {
font-size: 20px;
}
}
}
</style>