摄像头调取以及配置摄像头功能

<template>
    <div>
        <!-- 摄像头配置弹窗 -->
        <a-modal v-model:visible="configVisible" title="摄像头配置" width="800px" :footer="null" :maskClosable="false" destroyOnClose>
            <a-table :dataSource="availableDevices" :columns="deviceColumns" rowKey="deviceId">
                <template #bodyCell="{ column, record }">
                    <template v-if="column.key === 'action'">
                        <a-radio-group v-model:value="deviceUsage[record.deviceId]">
                            <a-radio-button value="scan">扫码</a-radio-button>
                            <a-radio-button value="photo">拍照</a-radio-button>
                        </a-radio-group>
                    </template>
                </template>
            </a-table>
            <div class="modal-footer">
                <a-button type="primary" @click="saveDeviceConfig">确认</a-button>
            </div>
        </a-modal>

        <a-modal v-model:visible="photoPreviewVisible" title="拍照" width="auto" :footer="null" destroyOnClose
            @cancel="closePhotoPreview">
            <div class="photo-preview">
                <!-- 实时预览区域 -->
                <div class="live-preview" v-if="photoDeviceId">
                    <video :ref="(el) => setPreviewVideoRef(el)" autoplay playsinline />
                </div>

                <!-- 拍照后的预览 -->
                <img :src="currentPhoto" v-if="currentPhoto" class="captured-photo" />

                <div class="preview-actions">
                    <a-button v-if="!currentPhoto" type="primary" @click="capturePhoto">拍照</a-button>
                    <a-button v-if="currentPhoto" type="primary" @click="confirmPhoto">确认使用</a-button>
                    <a-button v-if="currentPhoto" @click="retakePhoto">重拍</a-button>
                    <a-button @click="closePhotoPreview">取消</a-button>
                </div>
            </div>
        </a-modal>

        <!-- 隐藏的视频和画布 -->
        <div style="display: none">
            <video v-for="streamInfo in activeStreams" :key="`video-${streamInfo.deviceId}`"
                :ref="(el) => setVideoRef(el, streamInfo.deviceId)" autoplay playsinline />
            <canvas v-for="streamInfo in activeStreams" :key="`canvas-${streamInfo.deviceId}`"
                :ref="(el) => setCanvasRef(el, streamInfo.deviceId)" />
        </div>
    </div>
</template>

<script setup>
import { ref, onMounted, nextTick, onUnmounted,watch } from 'vue'
import { message } from 'ant-design-vue'
import jsqr from 'jsqr'
import uploadPic from '@/api/dev/fileApi'


const emit = defineEmits(['scanCode', 'photoTaken'])

// 状态控制 
const configVisible = ref(false)
const photoPreviewVisible = ref(false)
const currentPhoto = ref(null)

// 设备管理 
const availableDevices = ref([])
const deviceUsage = ref({})
const activeStreams = ref([])

// 媒体元素 
const videoRefs = ref({})
const canvasRefs = ref({})
const scanInterval = ref(null)

// 新增预览视频引用 
const previewVideoRef = ref(null)
const photoDeviceId = ref(null)
const previewStream = ref(null)

// 表格配置 
const deviceColumns = [
    {
        title: '摄像头名称',
        dataIndex: 'label',
        key: 'label',
        ellipsis: true
    },
    {
        title: '选择用途',
        key: 'action',
        width: '300px'
    }
]

// 初始化 
onMounted(async () => {
    await initCameras()
    const savedConfig = localStorage.getItem('cameraDeviceUsage')

    if (savedConfig) {
        const parsedConfig = JSON.parse(savedConfig)
        const currentDeviceIds = availableDevices.value.map(d => d.deviceId)
        const savedDeviceIds = Object.keys(parsedConfig)

        // 双重校验逻辑 
        const configValid = savedDeviceIds.every(id => currentDeviceIds.includes(id))
            && savedDeviceIds.length === currentDeviceIds.length
            && Object.values(parsedConfig).includes('scan')

        if (configValid) {
            deviceUsage.value = parsedConfig
            startCameras()
        } else {
            configVisible.value = true
            // message.warning(' 检测到摄像头设备变更,请重新配置')
            localStorage.removeItem('cameraDeviceUsage')  // 清除无效配置 
        }
    } else {
        configVisible.value = availableDevices.value.length > 0
    }
})

// 初始化摄像头 
const initCameras = async () => {
    try {
        if (!navigator.mediaDevices?.enumerateDevices) {
            throw new Error('浏览器不支持设备枚举功能')
        }

        await navigator.mediaDevices.getUserMedia({ video: true })
        const devices = await navigator.mediaDevices.enumerateDevices()

        availableDevices.value = devices
            .filter(device => device.kind === 'videoinput')
            .map((device, index) => ({
                deviceId: device.deviceId,
                label: device.label || `摄像头 ${index + 1}`
            }))
    } catch (error) {
        console.error(' 摄像头初始化失败:', error)
        // message.error(` 摄像头初始化失败: ${error.message}`)
    }
}

// 启动摄像头 
const startCameras = async () => {
    stopAllCameras()

    const deviceIds = Object.keys(deviceUsage.value)
    for (const deviceId of deviceIds) {
        try {
            const stream = await navigator.mediaDevices.getUserMedia({
                video: {
                    deviceId: { exact: deviceId },
                    width: { ideal: 1280 },
                    height: { ideal: 720 },
                    facingMode: deviceUsage.value[deviceId] === 'scan' ? { ideal: 'environment' } : undefined
                }
            })

            activeStreams.value.push({
                stream,
                deviceId,
                usage: deviceUsage.value[deviceId]
            })

            await nextTick()
            const video = videoRefs.value[deviceId]
            if (video) video.srcObject = stream
        } catch (error) {
            console.error(` 启动摄像头 ${deviceId} 失败:`, error)
        }
    }

    startQRScanning()
}

// 开始二维码扫描 
const startQRScanning = () => {
    if (scanInterval.value) clearInterval(scanInterval.value)

    scanInterval.value = setInterval(() => {
        activeStreams.value
            .filter(stream => stream.usage === 'scan')
            .forEach(streamInfo => {
                const video = videoRefs.value[streamInfo.deviceId]
                const canvas = canvasRefs.value[streamInfo.deviceId]

                if (!video || !canvas || video.readyState !== 4) return

                try {
                    canvas.width = video.videoWidth
                    canvas.height = video.videoHeight
                    const ctx = canvas.getContext('2d')
                    ctx.drawImage(video, 0, 0, canvas.width, canvas.height)

                    const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
                    const code = jsqr(imageData.data, imageData.width, imageData.height)

                    if (code) {
                        const result = parseQRCode(code.data)
                        emit('scanCode', result)
                    }
                } catch (e) {
                    console.error(' 二维码识别错误:', e)
                }
            })
    }, 500)
}

// 拍照功能 
const takePhoto = async () => {
    const photoDevice = activeStreams.value.find(s => s.usage === 'photo')
    if (!photoDevice) {
        message.warning(' 请先配置拍照摄像头')
        return null
    }

    const video = videoRefs.value[photoDevice.deviceId]
    if (!video || video.readyState !== 4) {
        message.error(' 摄像头未就绪')
        return null
    }

    try {
        const canvas = document.createElement('canvas')
        canvas.width = video.videoWidth
        canvas.height = video.videoHeight
        const ctx = canvas.getContext('2d')
        ctx.drawImage(video, 0, 0, canvas.width, canvas.height)

        return {
            deviceId: photoDevice.deviceId,
            imageData: canvas.toDataURL('image/jpeg', 0.9),
            timestamp: new Date().toISOString(),
            width: canvas.width,
            height: canvas.height
        }
    } catch (error) {
        console.error(' 拍照失败:', error)
        message.error(' 拍照失败: ' + error.message)
        return null
    }
}

// 带预览的拍照功能 
const takePhotoWithPreview = async () => {
    const photoDevice = activeStreams.value.find(s => s.usage === 'photo');
    if (!photoDevice) {
        message.warning(' 请先配置拍照摄像头');
        return null;
    }

    photoDeviceId.value = photoDevice.deviceId;
    photoPreviewVisible.value = true;

    try {
        const stream = await navigator.mediaDevices.getUserMedia({
            video: {
                deviceId: { exact: photoDevice.deviceId },
                width: { ideal: 1280 },
                height: { ideal: 720 }
            }
        });
        previewStream.value = stream;

        await nextTick();
        if (previewVideoRef.value) {
            previewVideoRef.value.srcObject = stream;
        }

        return new Promise((resolve) => {
            // 核心修改:添加 immediate: true 和 cleanup 逻辑 
            const unwatch = watch(
                photoPreviewVisible,
                (visible) => {
                    if (!visible) {
                        cleanup();
                        resolve( currentPhoto.value? {deviceId: photoDevice.deviceId,imageData: currentPhoto.value,timestamp: new Date().toISOString()}: null);
                    }
                },
                { immediate: true }  // 确保立即检查初始状态 
            );

            const cleanup = () => {
                unwatch();  // 停止监听 
                if (previewStream.value) {
                    previewStream.value.getTracks().forEach(track => track.stop());
                    previewStream.value = null;
                }
            };

            // 安全网:组件卸载时强制清理 
            onUnmounted(cleanup);
        });
    } catch (error) {
        console.error(' 打开摄像头失败:', error);
        message.error(' 打开摄像头失败: ' + error.message);
        return null;
    }
}

// 拍照函数 
const capturePhoto = () => {
    if (!previewVideoRef.value || previewVideoRef.value.readyState !== 4) {
        message.error(' 摄像头未就绪')
        return
    }

    try {
        const canvas = document.createElement('canvas')
        canvas.width = previewVideoRef.value.videoWidth
        canvas.height = previewVideoRef.value.videoHeight
        const ctx = canvas.getContext('2d')
        ctx.drawImage(previewVideoRef.value, 0, 0, canvas.width, canvas.height)

        currentPhoto.value = canvas.toDataURL('image/jpeg', 0.9)

        // 停止预览流 
        if (previewStream.value) {
            previewStream.value.getTracks().forEach(track => track.stop())
            previewStream.value = null
        }
    } catch (error) {
        console.error(' 拍照失败:', error)
        message.error(' 拍照失败: ' + error.message)
    }
}

// 设置预览视频引用 
const setPreviewVideoRef = (el) => {
    if (el) previewVideoRef.value = el
}

// 重拍函数 
const retakePhoto = () => {
    currentPhoto.value = null
    takePhotoWithPreview()
}

// 关闭预览 
const closePhotoPreview = () => {
    // 停止预览流 
    if (previewStream.value) {
        previewStream.value.getTracks().forEach(track => track.stop())
        previewStream.value = null
    }
    currentPhoto.value = null
    photoDeviceId.value = null
}
// 新增工具函数 
const dataURLtoFile = (dataurl, filename) => {
    const arr = dataurl.split(',')
    const mime = arr[0].match(/:(.*?);/)[1]
    const bstr = atob(arr[1])
    let n = bstr.length
    const u8arr = new Uint8Array(n)
    while (n--) u8arr[n] = bstr.charCodeAt(n)
    return new File([u8arr], filename, { type: mime })
}
// 确认使用照片 
const confirmPhoto = async () => {
    try {
        // 创建FormData 
        const formData = new FormData()
        const file = dataURLtoFile(currentPhoto.value, `photo_${Date.now()}.jpg`)
        formData.append('file', file)

        // 调用上传接口 
        const { code, data: url } = await uploadPic.fileUploadAliyunReturnUrl(formData)

        if (code === 200 && url) {
            emit('photoTaken', {
                deviceId: activeStreams.value.find(s => s.usage === 'photo').deviceId,
                imageUrl: url,  // 改为返回URL 
                timestamp: new Date().toISOString()
            })
            photoPreviewVisible.value = false
        } else {
            message.error(' 上传失败,请重试')
        }
    } catch (error) {
        console.error(' 上传异常:', error)
        message.error(` 上传失败: ${error.message}`)
    }
}


// 停止所有摄像头 
const stopAllCameras = () => {
    activeStreams.value.forEach(stream => {
        stream.stream.getTracks().forEach(track => track.stop())
    })
    activeStreams.value = []

    if (scanInterval.value) {
        clearInterval(scanInterval.value)
        scanInterval.value = null
    }
}

// 解析二维码 
const parseQRCode = (data) => {
    try {
        const parsed = JSON.parse(data)
        return parsed.No || parsed.code || data
    } catch {
        return data
    }
}

// 保存设备配置 
const saveDeviceConfig = () => {
    const hasScanDevice = Object.values(deviceUsage.value).includes('scan')
    if (!hasScanDevice) {
        message.warning(' 请至少选择一个用于扫码的摄像头')
        return
    }

    localStorage.setItem('cameraDeviceUsage', JSON.stringify(deviceUsage.value))
    configVisible.value = false
    startCameras()
}

// 显示配置界面 
const showConfig = () => {
    configVisible.value = true
}

// 设置元素引用 
const setVideoRef = (el, deviceId) => {
    if (el) videoRefs.value[deviceId] = el
}
const setCanvasRef = (el, deviceId) => {
    if (el) canvasRefs.value[deviceId] = el
}

// 清理 
onUnmounted(() => {
    stopAllCameras()
})


// 暴露API 
defineExpose({
    showConfig,
    takePhoto,
    takePhotoWithPreview,
    startScanning: startQRScanning,
    stopScanning: () => {
        if (scanInterval.value) clearInterval(scanInterval.value)
    },
    getActiveDevices: () => activeStreams.value
})
</script>

<style scoped>
.photo-preview {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 16px;
}

.photo-preview img {
    max-width: 100%;
    max-height: 70vh;
    border: 1px solid #f0f0f0;
}

.preview-actions {
    display: flex;
    gap: 16px;
    justify-content: center;
}

.modal-footer {
    margin-top: 16px;
    text-align: right;
}
</style>

拥抱AI有可能失业,有可能效率不止一点点。上述代码的功能是,检测页面上所有的摄像头,在初始化时弹出摄像头 并配有需要这个摄像头做的事是什么,是负责扫码还是负责拍照等,初次加载会弹出,然后配置后存储本地,下次刷新或者加载先拍判断存储信息和所获取的摄像头是否一致,一致不弹出,不一致或者有新设备弹出提示配置。

检测到多个摄像头后,配置了扫码,就会调起这个扫码摄像头不停扫描返回信息。以便调用后端接口做接下来的需求。

拍照配置后,将调用方法暴露给父级,父级调用后先是弹出一个实时浏览窗口以便观察物体摆放怎样,点击弹框上的拍照,才是截取video帧数获取canvas图片,最后图片转换为合适的格式上传服务器,返回服务器地址,返回给父级。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值