修改拍照的大小以及增加放大缩小编辑等功能

This commit is contained in:
dxc
2026-02-10 09:59:32 +08:00
parent a0ed92319c
commit 2d0593078b
3 changed files with 425 additions and 96 deletions

View File

@ -11,6 +11,7 @@
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"axios": "^1.13.3",
"cropperjs": "^1.6.2",
"element-plus": "^2.13.1",
"html5-qrcode": "^2.3.8",
"jspdf": "^2.5.1",

View File

@ -1,7 +1,8 @@
<template>
<div class="camera-container">
<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">
@ -10,60 +11,134 @@
ref="videoRef"
autoplay
playsinline
class="media-content"
class="media-content video-feed"
:style="{ transform: `scale(${cameraZoom})` }"
></video>
<img
v-if="imgSrc"
:src="imgSrc"
class="media-content preview-img"
alt="Photo Preview"
/>
<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 @click="handleCancel" size="large">取消</el-button>
<el-button
type="primary"
@click="capture"
size="large"
:disabled="!isCameraReady"
>
<el-icon class="el-icon--left"><Camera /></el-icon>
拍照
<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>
<el-button @click="retake" size="large" type="warning" plain>
<el-icon class="el-icon--left"><RefreshLeft /></el-icon>
重拍
</el-button>
<el-button
type="success"
@click="confirmUse"
size="large"
>
<el-icon class="el-icon--left"><Check /></el-icon>
确认使用
</el-button>
<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 } from 'vue'
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { ElMessage } from 'element-plus'
import { Camera, RefreshLeft, Check } from '@element-plus/icons-vue'
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)
@ -71,11 +146,18 @@ 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 = {
@ -96,7 +178,7 @@ const startCamera = async () => {
} catch (err: any) {
console.error(err)
if (location.protocol !== 'https:' && location.hostname !== 'localhost') {
error.value = '无法访问摄像头: 浏览器限制非 HTTPS 环境调用摄像头,请使用 localhost 或 配置 HTTPS。'
error.value = '无法访问摄像头: 请使用 HTTPS 环境。'
} else {
error.value = '无法访问摄像头: ' + (err.message || '请检查权限')
}
@ -115,126 +197,357 @@ const stopCamera = () => {
isCameraReady.value = false
}
// ----------------------------------------------------
// 核心修复:拍照时应用数码变焦(裁剪+拉伸)
// ----------------------------------------------------
const capture = () => {
const video = videoRef.value
const canvas = canvasRef.value
if (!video || !canvas) return
const width = video.videoWidth
const height = video.videoHeight
// 1. 获取视频原始尺寸
const vW = video.videoWidth
const vH = video.videoHeight
if (vW === 0 || vH === 0) return
if (width === 0 || height === 0) return
canvas.width = width
canvas.height = height
// 2. 设置画布为全尺寸(保持清晰度)
canvas.width = vW
canvas.height = vH
const ctx = canvas.getContext('2d')
if (!ctx) return
ctx.drawImage(video, 0, 0, width, height)
// 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('照片生成失败,请重试')
ElMessage.error('照失败,请重试')
return
}
const timestamp = new Date().getTime()
const filename = `photo_${timestamp}.jpg`
const file = new File([blob], filename, { type: 'image/jpeg' })
currentFile.value = new File([blob], filename, { type: 'image/jpeg' })
currentFile.value = file
imgSrc.value = URL.createObjectURL(blob)
console.log('✅ 照片已捕获:', filename, (file.size / 1024).toFixed(2) + 'KB')
stopCamera()
}, 'image/jpeg', 0.95)
}
const retake = () => {
if (imgSrc.value) {
URL.revokeObjectURL(imgSrc.value)
}
destroyCropper()
isEditing.value = false
if (imgSrc.value) URL.revokeObjectURL(imgSrc.value)
imgSrc.value = ''
currentFile.value = null
startCamera()
}
if (!mediaStream.value || !mediaStream.value.active) {
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 (currentFile.value) {
try {
console.log('📤 发送 photo-submit 事件', currentFile.value)
emit('photo-submit', currentFile.value)
} catch (err) {
console.error('父组件处理事件失败:', err)
console.log('👆 确认使用')
if (isEditing.value && cropper) {
const croppedCanvas = cropper.getCroppedCanvas({
imageSmoothingQuality: 'high'
})
if (!croppedCanvas) {
ElMessage.error('图片处理失败')
return
}
} else {
ElMessage.warning('照片数据未就绪,请稍后或重拍')
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()
})
onMounted(() => startCamera())
onBeforeUnmount(() => {
destroyCropper()
stopCamera()
if (imgSrc.value) {
URL.revokeObjectURL(imgSrc.value)
}
if (imgSrc.value) URL.revokeObjectURL(imgSrc.value)
})
defineExpose({
startCamera,
stopCamera
})
defineExpose({ startCamera, stopCamera })
</script>
<style scoped>
.camera-container {
.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;
width: 100%;
height: 100%;
}
/* 媒体显示区 */
.media-box {
flex: 1;
width: 100%;
max-width: 640px;
height: 400px;
background: #000;
border-radius: 8px;
position: relative;
overflow: hidden;
background: #000;
display: flex;
justify-content: center;
align-items: center;
position: relative;
}
.media-content {
width: 100%;
height: 100%;
object-fit: contain;
object-fit: cover;
transition: transform 0.2s ease-out; /* 变焦平滑动画 */
transform-origin: center center;
}
.error-message {
color: #f56c6c;
padding: 40px 20px;
text-align: center;
background: #fef0f0;
border-radius: 8px;
width: 100%;
}
.camera-actions {
margin-top: 20px;
.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;
gap: 20px;
justify-content: center;
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>

View File

@ -353,7 +353,7 @@
<script setup lang="ts">
import { ref, reactive, onMounted, watch } from 'vue'
import { Plus, Setting, Refresh, Search, Box, House, Link, InfoFilled, Printer, Camera, Picture } from '@element-plus/icons-vue'
// 修复:引入 ElLoading
// 修复:引入 ElLoading
import { ElMessage, ElLoading } from 'element-plus'
import dayjs from 'dayjs'
import { getProductList, createProductInbound, updateProductInbound, deleteProductInbound, searchMaterialBase } from '@/api/inbound/product'
@ -389,6 +389,7 @@ const inspectionFileList = ref<any[]>([]) // 检测报告
const cameraDialogVisible = ref(false)
const cameraRef = ref<InstanceType<typeof WebRtcCamera> | null>(null)
// 定义当前触发拍照的字段
const currentCameraField = ref<'product_photo' | 'quality_report_link' | 'inspection_report_link'>('product_photo')
const quality_url = ref('')
const inspection_url = ref('')
@ -573,6 +574,10 @@ const triggerCamera = (field: any) => {
currentCameraField.value = field;
cameraDialogVisible.value = true;
}
// ------------------------------------------------------------------------
// 修复核心:拍照上传回调逻辑
// ------------------------------------------------------------------------
const handleCameraConfirm = async (file: File) => {
if (!beforeAvatarUpload(file)) {
cameraDialogVisible.value = false;
@ -581,23 +586,33 @@ const handleCameraConfirm = async (file: File) => {
const formData = new FormData();
formData.append('file', file);
// 修复点:使用 ElLoading
const loadingMsg = ElLoading.service({ text: '照片上传中...', background: 'rgba(0, 0, 0, 0.7)' });
// 使用 ElLoading.service 替代报错的 ElMessage.loading
const loadingMsg = ElLoading.service({
lock: true,
text: '照片处理中...',
background: 'rgba(0, 0, 0, 0.7)'
});
let success = false;
try {
const res: any = await uploadFile(formData);
if (res.code === 200) {
const newUrl = res.data.url;
const field = currentCameraField.value;
const field = currentCameraField.value; // 根据触发时记录的字段
// 添加到表单数据
form[field].push(newUrl);
// 更新对应的显示列表
const previewItem = { name: newUrl.split('/').pop(), url: getImageUrl(newUrl) };
if (field === 'product_photo') {
productPhotoList.value.push({ name: newUrl.split('/').pop(), url: getImageUrl(newUrl) });
productPhotoList.value.push(previewItem);
} else if (field === 'quality_report_link') {
qualityFileList.value.push({ name: newUrl.split('/').pop(), url: getImageUrl(newUrl) });
qualityFileList.value.push(previewItem);
} else if (field === 'inspection_report_link') {
inspectionFileList.value.push({ name: newUrl.split('/').pop(), url: getImageUrl(newUrl) });
inspectionFileList.value.push(previewItem);
}
ElMessage.success('拍照上传成功');
success = true;
} else {