Files
KCGL/inventory-web/src/components/Camera/WebRtcCamera.vue

553 lines
14 KiB
Vue

<template>
<div class="camera-container is-fullscreen">
<div v-if="error" class="error-message">
{{ error }}
<el-button style="margin-top: 20px" @click="handleCancel">关闭</el-button>
</div>
<div v-else class="media-box">
<video
v-show="!imgSrc"
ref="videoRef"
autoplay
playsinline
class="media-content video-feed"
:style="{ transform: `scale(${cameraZoom})` }"
></video>
<div v-show="imgSrc" class="editor-container">
<img
ref="previewImgRef"
:src="imgSrc"
class="media-content preview-img"
alt="Photo Preview"
/>
</div>
<canvas ref="canvasRef" style="display: none;"></canvas>
</div>
<div v-if="!imgSrc && isCameraReady" class="zoom-slider-container">
<el-icon class="zoom-icon"><Remove /></el-icon>
<el-slider
v-model="cameraZoom"
:min="1"
:max="3"
:step="0.1"
:show-tooltip="false"
class="custom-slider"
/>
<el-icon class="zoom-icon"><CirclePlus /></el-icon>
</div>
<div class="camera-actions">
<template v-if="!imgSrc">
<el-button circle size="large" @click="handleCancel" class="action-btn icon-btn">
<el-icon><Close /></el-icon>
</el-button>
<el-button
circle
type="danger"
size="large"
@click="capture"
:disabled="!isCameraReady"
class="shutter-btn"
>
<div class="shutter-inner"></div>
</el-button>
<div class="placeholder-btn"></div>
</template>
<template v-else>
<div v-if="isEditing" class="edit-mode-bar">
<div class="edit-tools">
<el-tooltip content="切换移动图片/调整裁剪框" placement="top" :show-after="1000">
<el-button
circle
@click="toggleDragMode"
class="tool-btn"
:class="{ 'is-active': isMoveMode }"
>
<el-icon><Rank /></el-icon>
</el-button>
</el-tooltip>
<el-divider direction="vertical" border-style="dashed" />
<el-button circle @click="zoomCropper(0.1)" class="tool-btn"><el-icon><ZoomIn /></el-icon></el-button>
<el-button circle @click="zoomCropper(-0.1)" class="tool-btn"><el-icon><ZoomOut /></el-icon></el-button>
<el-button circle @click="rotateLeft" class="tool-btn"><el-icon><RefreshLeft /></el-icon></el-button>
<el-button circle @click="rotateRight" class="tool-btn"><el-icon><RefreshRight /></el-icon></el-button>
<el-button circle @click="resetCrop" class="tool-btn"><el-icon><Refresh /></el-icon></el-button>
</div>
<div class="edit-confirm">
<el-button circle size="large" @click="handleCancel" class="action-btn icon-btn">
<el-icon><Close /></el-icon>
</el-button>
<el-button @click="stopEdit" class="text-btn">取消编辑</el-button>
<el-button type="success" @click="confirmUse" class="confirm-btn">
完成并上传
</el-button>
</div>
</div>
<div v-else class="preview-mode-bar">
<el-button circle size="large" @click="handleCancel" class="action-btn icon-btn">
<el-icon><Close /></el-icon>
</el-button>
<el-button @click="retake" size="large" class="text-btn" style="min-width: 80px;">重拍</el-button>
<el-button @click="startEdit" size="large" class="text-btn" style="min-width: 80px;">
<el-icon style="margin-right: 4px"><Edit /></el-icon>编辑
</el-button>
<el-button
type="success"
@click="confirmUse"
size="large"
class="confirm-btn"
>
确认使用 <el-icon class="el-icon--right"><Check /></el-icon>
</el-button>
</div>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { ElMessage } from 'element-plus'
import {
Camera, RefreshLeft, RefreshRight, Check, Close, Refresh, Edit,
ZoomIn, ZoomOut, Remove, CirclePlus, Rank
} from '@element-plus/icons-vue'
import Cropper from 'cropperjs'
import 'cropperjs/dist/cropper.css'
const emit = defineEmits(['photo-submit', 'cancel'])
const videoRef = ref<HTMLVideoElement>()
const canvasRef = ref<HTMLCanvasElement>()
const previewImgRef = ref<HTMLImageElement>()
const mediaStream = ref<MediaStream>()
const error = ref('')
const isCameraReady = ref(false)
const imgSrc = ref('')
const currentFile = ref<File | null>(null)
const isEditing = ref(false)
const isMoveMode = ref(false)
const cameraZoom = ref(1) // 控制拍摄时的变焦倍数
let cropper: Cropper | null = null
const startCamera = async () => {
stopCamera()
error.value = ''
imgSrc.value = ''
isEditing.value = false
currentFile.value = null
cameraZoom.value = 1
try {
const constraints = {
video: {
facingMode: 'environment',
width: { ideal: 1920 },
height: { ideal: 1080 }
}
}
const stream = await navigator.mediaDevices.getUserMedia(constraints)
mediaStream.value = stream
if (videoRef.value) {
videoRef.value.srcObject = stream
await videoRef.value.play()
isCameraReady.value = true
}
} catch (err: any) {
console.error(err)
if (location.protocol !== 'https:' && location.hostname !== 'localhost') {
error.value = '无法访问摄像头: 请使用 HTTPS 环境。'
} else {
error.value = '无法访问摄像头: ' + (err.message || '请检查权限')
}
ElMessage.error(error.value)
}
}
const stopCamera = () => {
if (mediaStream.value) {
mediaStream.value.getTracks().forEach(track => track.stop())
mediaStream.value = undefined
}
if (videoRef.value) {
videoRef.value.srcObject = null
}
isCameraReady.value = false
}
// ----------------------------------------------------
// 核心修复:拍照时应用数码变焦(裁剪+拉伸)
// ----------------------------------------------------
const capture = () => {
const video = videoRef.value
const canvas = canvasRef.value
if (!video || !canvas) return
// 1. 获取视频原始尺寸
const vW = video.videoWidth
const vH = video.videoHeight
if (vW === 0 || vH === 0) return
// 2. 设置画布为全尺寸(保持清晰度)
canvas.width = vW
canvas.height = vH
const ctx = canvas.getContext('2d')
if (!ctx) return
// 3. 计算基于 zoomLevel 的裁剪区域
// zoom = 1: 裁剪宽 = vW
// zoom = 2: 裁剪宽 = vW / 2
const zoom = cameraZoom.value
const cropW = vW / zoom
const cropH = vH / zoom
// 4. 计算裁剪的起始点 (居中裁剪)
const cropX = (vW - cropW) / 2
const cropY = (vH - cropH) / 2
// 5. 将裁剪区域绘制到全尺寸画布上 (drawImage(source, sx, sy, sw, sh, dx, dy, dw, dh))
ctx.drawImage(
video,
cropX, cropY, cropW, cropH, // 源:截取中心部分
0, 0, vW, vH // 目标:铺满整个画布
)
canvas.toBlob((blob) => {
if (!blob) {
ElMessage.error('拍照失败,请重试')
return
}
const timestamp = new Date().getTime()
const filename = `photo_${timestamp}.jpg`
currentFile.value = new File([blob], filename, { type: 'image/jpeg' })
imgSrc.value = URL.createObjectURL(blob)
stopCamera()
}, 'image/jpeg', 0.95)
}
const retake = () => {
destroyCropper()
isEditing.value = false
if (imgSrc.value) URL.revokeObjectURL(imgSrc.value)
imgSrc.value = ''
currentFile.value = null
startCamera()
}
const startEdit = () => {
if (!imgSrc.value || !previewImgRef.value) return
isEditing.value = true
isMoveMode.value = false
nextTick(() => {
if (cropper) cropper.destroy()
cropper = new Cropper(previewImgRef.value!, {
viewMode: 1,
dragMode: 'none',
autoCropArea: 0.8,
background: false,
modal: true,
guides: true,
highlight: false,
cropBoxMovable: true,
cropBoxResizable: true,
toggleDragModeOnDblclick: false,
movable: true,
zoomable: true,
rotatable: true,
scalable: true,
})
})
}
const toggleDragMode = () => {
if (!cropper) return
isMoveMode.value = !isMoveMode.value
cropper.setDragMode(isMoveMode.value ? 'move' : 'none')
}
const stopEdit = () => {
destroyCropper()
isEditing.value = false
}
const destroyCropper = () => {
if (cropper) {
cropper.destroy()
cropper = null
}
}
const rotateLeft = () => cropper?.rotate(-90)
const rotateRight = () => cropper?.rotate(90)
const resetCrop = () => {
cropper?.reset()
isMoveMode.value = false
cropper?.setDragMode('none')
}
const zoomCropper = (ratio: number) => cropper?.zoom(ratio)
const confirmUse = () => {
console.log('👆 确认使用')
if (isEditing.value && cropper) {
const croppedCanvas = cropper.getCroppedCanvas({
imageSmoothingQuality: 'high'
})
if (!croppedCanvas) {
ElMessage.error('图片处理失败')
return
}
croppedCanvas.toBlob((blob) => {
if (!blob) {
ElMessage.error('文件生成失败')
return
}
const timestamp = new Date().getTime()
const filename = `photo_crop_${timestamp}.jpg`
const file = new File([blob], filename, { type: 'image/jpeg' })
emitFile(file)
}, 'image/jpeg', 0.9)
}
else if (currentFile.value) {
emitFile(currentFile.value)
}
else {
ElMessage.warning('没有可用的照片')
}
}
const emitFile = (file: File) => {
try {
console.log('📤 提交文件:', file.name, (file.size/1024).toFixed(1)+'KB')
emit('photo-submit', file)
} catch (err) {
console.error('父组件处理事件失败:', err)
}
}
const handleCancel = () => {
destroyCropper()
stopCamera()
emit('cancel')
}
onMounted(() => startCamera())
onBeforeUnmount(() => {
destroyCropper()
stopCamera()
if (imgSrc.value) URL.revokeObjectURL(imgSrc.value)
})
defineExpose({ startCamera, stopCamera })
</script>
<style scoped>
.camera-container.is-fullscreen {
position: fixed;
top: 0; left: 0; width: 100vw; height: 100vh;
background-color: #000;
z-index: 9999;
display: flex; flex-direction: column;
}
.error-message {
color: #fff;
padding: 40px;
text-align: center;
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
/* 媒体显示区 */
.media-box {
flex: 1;
width: 100%;
position: relative;
overflow: hidden;
background: #000;
display: flex;
justify-content: center;
align-items: center;
}
.media-content {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.2s ease-out; /* 变焦平滑动画 */
transform-origin: center center;
}
.editor-container { width: 100%; height: 100%; }
.preview-img { display: block; max-width: 100%; max-height: 100%; }
/* 变焦滑块 */
.zoom-slider-container {
position: absolute;
bottom: 150px;
left: 50%;
transform: translateX(-50%);
width: 70%;
max-width: 300px;
display: flex;
align-items: center;
gap: 10px;
z-index: 20;
background: rgba(0, 0, 0, 0.4);
padding: 5px 15px;
border-radius: 20px;
}
.zoom-icon { color: #fff; font-size: 18px; }
.custom-slider { flex: 1; }
:deep(.el-slider__runway) { background-color: #555; }
:deep(.el-slider__bar) { background-color: #fff; }
:deep(.el-slider__button) { border-color: #fff; }
/* 底部操作栏 */
.camera-actions {
height: 140px;
width: 100%;
background: rgba(0, 0, 0, 0.85);
display: flex;
flex-direction: column;
justify-content: center;
padding-bottom: 10px;
position: relative;
z-index: 30;
}
/* 拍照按钮布局 */
.camera-actions:has(.shutter-btn) {
flex-direction: row;
justify-content: space-around;
align-items: center;
}
.placeholder-btn { width: 40px; }
/* 按钮样式 */
.action-btn { background: rgba(255, 255, 255, 0.15); border: none; color: #fff; }
.text-btn {
color: #fff;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.3);
font-size: 14px;
}
.confirm-btn { min-width: 120px; font-weight: bold; }
/* 快门按钮 */
.shutter-btn {
width: 72px;
height: 72px;
border: 4px solid #fff;
background: transparent !important;
padding: 0;
display: flex;
justify-content: center;
align-items: center;
}
.shutter-inner {
width: 58px;
height: 58px;
background-color: #fff;
border-radius: 50%;
transition: transform 0.1s;
}
.shutter-btn:active .shutter-inner {
transform: scale(0.9);
background-color: #ccc;
}
/* 预览模式操作栏 */
.preview-mode-bar {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 20px;
gap: 10px;
}
/* 编辑模式操作栏 */
.edit-mode-bar {
width: 100%;
display: flex;
flex-direction: column;
gap: 15px;
}
.edit-tools {
display: flex;
justify-content: center;
gap: 12px;
align-items: center;
}
.tool-btn {
background: rgba(255, 255, 255, 0.1);
border: none;
color: #fff;
font-size: 16px;
transition: all 0.3s;
}
.tool-btn.is-active {
background-color: #409EFF;
color: #fff;
}
.edit-confirm {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 20px;
gap: 10px;
}
/* Cropper 样式定制 */
:deep(.cropper-view-box) {
outline: 3px solid #409EFF;
outline-color: #409EFF;
}
:deep(.cropper-point) {
width: 8px;
height: 8px;
background-color: #409EFF;
opacity: 0.9;
}
:deep(.cropper-line) {
background-color: rgba(64, 158, 255, 0.5);
}
</style>