<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图片,最后图片转换为合适的格式上传服务器,返回服务器地址,返回给父级。