// 父组件
<template>
<DialogImgCropper
v-model:dialogVisible="dialogCropperVisible"
v-model:targetWidth="cropperWidth"
v-model:targetHeight="cropperHeight"
v-model:externalFile="cropperOriginFile"
@data="handleCroppedData"
/>
</template>
<script setup lang="ts">
import { ref, useTemplateRef } from 'vue'
import { useUserStore } from '@/store/modules/user'
import DialogImgCropper from './components/DialogImgCropper.vue'
const userStore = useUserStore()
const fileEl = useTemplateRef('fileEl')
const dialogCropperVisible = ref(false)
const cropperWidth = ref(300)
const cropperHeight = ref(300)
const cropperOriginFile = ref<any>(null)
</script>
// children.vue
<template>
<Dialog v-model="dialogVisible" width="80%" title="图片裁剪" @close="handleClose">
<div class="p-4">
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- 左侧:图片上传和裁剪区域 -->
<div class="lg:col-span-2 bg-[var(--app-view-bg-color)] rounded-lg shadow-md p-4">
<!-- 图片上传 -->
<div class="mb-4">
<label class="block text-sm font-medium mb-2">选择图片</label>
<input
ref="uploadFileEl"
type="file"
@change="handleFileUpload"
accept="image/jpg,image/jpeg,image/png,image/bmp,image/gif"
class="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100"
/>
</div>
<!-- 图片显示与裁剪区域 -->
<div
v-if="!imageUrl"
class="h-64 flex items-center justify-center border-2 border-dashed border-gray-300 rounded-lg cursor-pointer"
@click="uploadFileEl?.click()"
>
<p class="text-gray-400">请上传一张图片</p>
</div>
<div v-else class="relative overflow-hidden border-2 border-gray-200 h-[400px]">
<img
v-if="imageUrl"
:src="imageUrl"
alt="原始图片"
ref="imageRef"
class="max-w-full max-h-full object-contain"
@load="onImageLoad"
/>
<!-- 可调整大小的裁剪框 -->
<div
v-if="isCropping"
ref="cropArea"
class="absolute border-2 border-blue-500 bg-blue-100/30"
:style="{
left: `${cropPosition.x}px`,
top: `${cropPosition.y}px`,
width: `${cropWidth}px`,
height: `${cropHeight}px`,
cursor: isResizing ? 'se-resize' : 'move'
}"
@mousedown.stop="handleCropAreaMouseDown"
>
<!-- 尺寸显示 -->
<div class="absolute top-0 left-0 bg-white text-blue-600 text-xs p-0.5">
{{ Math.round(cropWidth) }}x{{ Math.round(cropHeight) }}
</div>
<!-- 右下角调整手柄 -->
<div
class="absolute right-0 bottom-0 w-4 h-4 bg-blue-500 border-2 border-white cursor-se-resize"
@mousedown.stop="startResize"
></div>
</div>
</div>
<!-- 操作按钮 -->
<div class="mt-4 flex flex-wrap gap-2">
<button
@click="startCrop"
:disabled="!imageUrl"
class="border-none bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600 transition-colors"
>
开始裁剪
</button>
<button
@click="cancelCrop"
:disabled="!isCropping"
class="border-none bg-gray-500 text-white px-4 py-2 rounded hover:bg-gray-600 transition-colors"
>
取消裁剪
</button>
<button
@click="confirmCrop"
:disabled="!isCropping || cropWidth < minWidth || cropHeight < minHeight"
class="border-none bg-green-500 text-white px-4 py-2 rounded hover:bg-green-600 transition-colors"
>
确认裁剪
</button>
</div>
</div>
<!-- 右侧:实时预览与结果 -->
<div class="bg-[var(--app-view-bg-color)] rounded-lg shadow-md p-4">
<h2 class="text-xl font-semibold mb-4">实时预览</h2>
<div
class="relative overflow-hidden border-2 border-gray-200"
:style="{
width: `${targetWidth}px`,
height: `${targetHeight}px`,
margin: '0 auto'
}"
>
<div
v-if="!isCropping || !previewImageUrl"
class="h-full flex items-center justify-center"
>
<p class="text-gray-400 text-sm">拖动裁剪框或调整大小以查看预览</p>
</div>
<img v-else :src="previewImageUrl" alt="预览图" class="w-full h-full object-cover" />
</div>
<!-- 裁剪结果与上传 -->
<div class="mt-4">
<h3 class="text-lg font-medium mb-2">裁剪结果</h3>
<div
class="relative overflow-hidden border-2 border-gray-200"
:style="{
width: `${targetWidth}px`,
height: `${targetHeight}px`,
margin: '0 auto'
}"
>
<img
v-if="croppedImageUrl"
:src="croppedImageUrl"
alt="裁剪后图片"
class="w-full h-full object-cover"
/>
<div v-else class="h-full flex items-center justify-center">
<p class="text-gray-400 text-sm">请完成裁剪</p>
</div>
</div>
</div>
</div>
</div>
</div>
<template #footer>
<ElButton @click="handleClose">关闭</ElButton>
<ElButton type="primary" :loading="isUploading" @click="uploadImageFn">确定</ElButton>
</template>
</Dialog>
</template>
<script lang="ts" setup>
import {
ref,
reactive,
watch,
computed,
onMounted,
nextTick,
onUnmounted,
useTemplateRef
} from 'vue'
import { ElMessage } from 'element-plus'
const emit = defineEmits(['data'])
const dialogVisible = defineModel<boolean>('dialogVisible', { default: false })
const targetWidth = defineModel<any>('targetWidth')
const targetHeight = defineModel<any>('targetHeight')
// 从父组件接收参数,包括可能变化的文件流
const {
minWidth = 20,
minHeight = 20,
externalFile
} = defineProps<{
minWidth?: number // 最小裁剪宽度
minHeight?: number // 最小裁剪高度
externalFile?: File | null // 父组件传入的文件流
}>()
// 计算宽高比
const aspectRatio = computed(() => targetWidth.value / targetHeight.value)
// 初始裁剪区域大小为目标尺寸的1/2
const initialCropWidthBase = ref(targetWidth.value / 2)
const initialCropHeightBase = ref(targetHeight.value / 2)
const uploadFileEl = useTemplateRef('uploadFileEl')
const imageRef = useTemplateRef('imageRef')
const cropArea = ref<HTMLDivElement | null>(null)
const imageUrl = ref<string | null>(null)
const croppedImageUrl = ref<string | null>(null)
const previewImageUrl = ref<string | null>(null)
const isCropping = ref(false)
const isResizing = ref(false)
const isDragging = ref(false)
const isUploading = ref(false)
const animationFrameId = ref<any>(null)
const previewUpdateTimer = ref<any>(null)
const cropperFileInfo = ref<any>(null)
// 裁剪区域状态
const cropPosition = reactive({ x: 0, y: 0 })
const cropWidth = ref(initialCropWidthBase.value)
const cropHeight = ref(initialCropHeightBase.value)
const start = reactive({ x: 0, y: 0, width: 0, height: 0 })
const containerRect = ref<any>({ width: 0, height: 0, left: 0, top: 0 })
// 监听对话框显示状态
watch(
() => dialogVisible.value,
(val) => {
if (val) {
initialCropWidthBase.value = targetWidth.value / 2
initialCropHeightBase.value = targetHeight.value / 2
cropWidth.value = initialCropWidthBase.value
cropHeight.value = initialCropHeightBase.value
// 如果有外部文件,加载它
if (externalFile) {
handleExternalFile(externalFile)
}
}
}
)
// 监听目标尺寸变化
watch([targetWidth, targetHeight], () => {
initialCropWidthBase.value = targetWidth.value / 2
initialCropHeightBase.value = targetHeight.value / 2
if (isCropping.value) {
cropWidth.value = Math.max(minWidth, initialCropWidthBase.value)
cropHeight.value = cropWidth.value / aspectRatio.value
if (cropHeight.value < minHeight) {
cropHeight.value = minHeight
cropWidth.value = cropHeight.value * aspectRatio.value
}
nextTick(resetCropArea)
} else {
cropWidth.value = initialCropWidthBase.value
cropHeight.value = initialCropHeightBase.value
}
})
// 监听外部文件变化
watch(
() => externalFile,
(newFile, oldFile) => {
// 只有当文件真正变化时才处理
if (newFile && newFile !== oldFile) {
handleExternalFile(newFile)
}
},
{ immediate: true } // 立即执行,处理初始传入的文件
)
// 处理父组件传入的文件流
const handleExternalFile = (file: File) => {
// 清除现有图片数据
resetImageData()
const reader = new FileReader()
reader.onload = (event) => {
imageUrl.value = event.target?.result as string
cropperFileInfo.value = file
// 初始化裁剪区域
cropWidth.value = initialCropWidthBase.value
cropHeight.value = initialCropHeightBase.value
isCropping.value = false
}
reader.readAsDataURL(file)
}
// 图片加载处理
const onImageLoad = () => {
nextTick(() => {
if (!imageRef.value) return
containerRect.value = imageRef.value.parentElement!.getBoundingClientRect()
resetCropArea()
})
}
// 处理文件上传
const handleFileUpload = (e: Event) => {
const file = (e.target as HTMLInputElement).files?.[0]
if (file) {
// 清除可能存在的外部文件影响
const inputElement = e.target as HTMLInputElement
inputElement.value = '' // 重置input,允许重复选择同一文件
const reader = new FileReader()
reader.onload = (event) => {
imageUrl.value = event.target?.result as string
cropperFileInfo.value = file
cropWidth.value = initialCropWidthBase.value
cropHeight.value = initialCropHeightBase.value
isCropping.value = false
}
reader.readAsDataURL(file)
}
}
// 开始裁剪
const startCrop = () => {
if (imageUrl.value) {
isCropping.value = true
nextTick(resetCropArea)
}
}
// 重置裁剪区域
const resetCropArea = () => {
if (!imageRef.value) return
const img = imageRef.value as HTMLImageElement
const imgRect = img.getBoundingClientRect()
// 以目标尺寸的1/2为基准计算初始裁剪大小
const maxPossibleWidth = imgRect.width
const maxPossibleHeight = imgRect.height / aspectRatio.value
const adjustedWidth = Math.min(
initialCropWidthBase.value,
Math.min(maxPossibleWidth, maxPossibleHeight * aspectRatio.value)
)
cropWidth.value = adjustedWidth
cropHeight.value = adjustedWidth / aspectRatio.value
// 确保不小于最小尺寸限制
cropWidth.value = Math.max(minWidth, cropWidth.value)
cropHeight.value = Math.max(minHeight, cropHeight.value)
// 确保裁剪框在图片内部居中显示
cropPosition.x = Math.max(0, (imgRect.width - cropWidth.value) / 2)
cropPosition.y = Math.max(0, (imgRect.height - cropHeight.value) / 2)
updatePreview()
}
// 取消裁剪
const cancelCrop = () => {
isCropping.value = false
previewImageUrl.value = null
cropWidth.value = initialCropWidthBase.value
cropHeight.value = initialCropHeightBase.value
}
// 重置图片数据
const resetImageData = () => {
imageUrl.value = null
croppedImageUrl.value = null
previewImageUrl.value = null
isCropping.value = false
cropperFileInfo.value = null
}
// 鼠标按下事件处理
const handleCropAreaMouseDown = (e: any) => {
if (e.target?.classList.contains('cursor-se-resize')) {
startResize(e)
} else {
startDragging(e)
}
}
// 开始拖动
const startDragging = (e: MouseEvent) => {
if (!isCropping.value) return
isDragging.value = true
start.x = e.clientX
start.y = e.clientY
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}
// 开始调整大小
const startResize = (e: MouseEvent) => {
if (!isCropping.value) return
isResizing.value = true
start.x = e.clientX
start.y = e.clientY
start.width = cropWidth.value
start.height = cropHeight.value
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}
// 鼠标移动处理
const handleMouseMove = (e: MouseEvent) => {
if (!isCropping.value || !imageRef.value) return
cancelAnimationFrame(animationFrameId.value)
animationFrameId.value = requestAnimationFrame(() => {
const img = imageRef.value as HTMLImageElement
const imgRect = img.getBoundingClientRect()
if (isResizing.value) {
// 调整大小逻辑,保持宽高比
const deltaWidth = e.clientX - start.x
const deltaHeight = (e.clientY - start.y) * aspectRatio.value
const actualDelta = Math.min(deltaWidth, deltaHeight)
let newWidth = start.width + actualDelta
let newHeight = newWidth / aspectRatio.value
// 限制最小尺寸
newWidth = Math.max(minWidth, newWidth)
newHeight = Math.max(minHeight, newHeight)
// 关键修改:限制最大尺寸不超过图片大小(保持宽高比)
const maxImgWidth = imgRect.width
const maxImgHeight = imgRect.height
// 先按宽度限制(如果超过图片宽度)
if (newWidth > maxImgWidth) {
newWidth = maxImgWidth
newHeight = newWidth / aspectRatio.value
}
// 再按高度限制(如果按宽度限制后仍超过图片高度)
if (newHeight > maxImgHeight) {
newHeight = maxImgHeight
newWidth = newHeight * aspectRatio.value
}
// 最终赋值
cropWidth.value = newWidth
cropHeight.value = newHeight
// 确保裁剪框位置不超出图片(防止左上角超出)
cropPosition.x = Math.max(0, Math.min(cropPosition.x, maxImgWidth - cropWidth.value))
cropPosition.y = Math.max(0, Math.min(cropPosition.y, maxImgHeight - cropHeight.value))
} else if (isDragging.value) {
// 拖动逻辑(保持不变)
const dx = e.clientX - start.x
const dy = e.clientY - start.y
// 限制在图片范围内
cropPosition.x = Math.max(0, Math.min(cropPosition.x + dx, imgRect.width - cropWidth.value))
cropPosition.y = Math.max(0, Math.min(cropPosition.y + dy, imgRect.height - cropHeight.value))
start.x = e.clientX
start.y = e.clientY
}
// 防抖更新预览
clearTimeout(previewUpdateTimer.value)
previewUpdateTimer.value = setTimeout(updatePreview, 100) as unknown as number
})
}
// 鼠标释放处理
const handleMouseUp = () => {
isDragging.value = false
isResizing.value = false
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
updatePreview()
}
// 更新预览
const updatePreview = () => {
if (!imageUrl.value || !isCropping.value || !cropArea.value) return
const img = new Image()
img.src = imageUrl.value
img.onload = () => {
const canvas = document.createElement('canvas')
canvas.width = targetWidth.value
canvas.height = targetHeight.value
const ctx = canvas.getContext('2d')!
if (!imageRef.value) return
const imgElement = imageRef.value as HTMLImageElement
const imgRect = imgElement.getBoundingClientRect()
const scaleX = img.naturalWidth / imgRect.width
const scaleY = img.naturalHeight / imgRect.height
const x = cropPosition.x * scaleX
const y = cropPosition.y * scaleY
const w = cropWidth.value * scaleX
const h = cropHeight.value * scaleY
ctx.drawImage(img, x, y, w, h, 0, 0, targetWidth.value, targetHeight.value)
previewImageUrl.value = canvas.toDataURL('image/png')
}
}
// 确认裁剪
const confirmCrop = () => {
if (!cropperFileInfo.value || cropWidth.value < minWidth || cropHeight.value < minHeight) return
const img = new Image()
img.src = imageUrl.value!
img.onload = () => {
const canvas = document.createElement('canvas')
canvas.width = targetWidth.value
canvas.height = targetHeight.value
const ctx = canvas.getContext('2d')!
const imgElement = imageRef.value as HTMLImageElement
const imgRect = imgElement.getBoundingClientRect()
const scaleX = img.naturalWidth / imgRect.width
const scaleY = img.naturalHeight / imgRect.height
const x = cropPosition.x * scaleX
const y = cropPosition.y * scaleY
const w = cropWidth.value * scaleX
const h = cropHeight.value * scaleY
ctx.drawImage(img, x, y, w, h, 0, 0, targetWidth.value, targetHeight.value)
// 生成文件流
canvas.toBlob(
(blob) => {
if (blob) {
const file = new File([blob], cropperFileInfo.value!.name, {
type: cropperFileInfo.value!.type,
lastModified: Date.now()
})
cropperFileInfo.value = file
croppedImageUrl.value = URL.createObjectURL(blob)
}
},
cropperFileInfo.value!.type,
0.92
)
}
}
// 生命周期处理
onMounted(() => {
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
})
onUnmounted(() => {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
cancelAnimationFrame(animationFrameId.value)
clearTimeout(previewUpdateTimer.value)
})
const handleClose = () => {
resetImageData()
dialogVisible.value = false
}
const uploadImageFn = () => {
if (!cropperFileInfo.value) {
ElMessage.warning({
message: '请先裁剪图片',
showClose: true,
plain: true
})
return
}
emit('data', cropperFileInfo.value)
dialogVisible.value = false
}
</script>