553 lines
14 KiB
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> |