Vue拍照上传组件(重拍、切换已有摄像头)

背景

由于业务需求,需要进行拍照上传,百度了一遍组件都不太合适。自己结合已有案例封装了一下,可以把这个组件嵌套到el-dialog里面就可以使用。

实现功能

  1. 实时加载预览画面
  2. 点击拍照截取照片
  3. 不满意可以重拍,不会中断之前的视频流
  4. 加载当前设备的所有摄像头,可以进行选择切换

依赖

  • 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>

posted @ 2024-10-23 11:59  脆皮鸡  阅读(26)  评论(0编辑  收藏  举报