一、ImageCropper.vue 图片裁剪组件
1. 功能实现思路
核心设计理念 :基于 vue-cropper 库封装的独立图片裁剪组件,提供弹窗式裁剪功能,支持多种比例裁剪和图片编辑操作。
整体架构 :
1. 组件化设计 :独立封装,通过 props 接收参数,emit 事件返回结果
2. 弹窗式交互 :使用 Element Plus Dialog 作为容器,提供良好的用户体验
3. 响应式设计 :支持不同屏幕尺寸,自适应裁剪框大小
4. 实时预览 :裁剪过程中实时显示预览效果
5. 校验机制 :内置图片尺寸、比例、大小校验
| 模块 | 功能描述 | 实现思路 |
| 图片加载 | 加载原始图片并显示 | 使用 FileReader 将 File 对象转为 base64 格式 |
| 比例裁剪 | 支持 1:1 和 3:4 比例 | 通过 fixed-number 属性控制裁剪比例 |
| 图片编辑 | 旋转、缩放操作 | 调用 vue-cropper 提供的 API |
| 实时预览 | 裁剪过程中实时预览 | 监听 realTime 事件,更新预览图 |
| 裁剪校验 | 校验裁剪后的图片 | 检查尺寸、比例、大小是否符合要求 |
| 结果返回 | 返回裁剪后的 File 对象 | 将裁剪后的 blob 转为 File 对象,通过事件返回 |
2. 关键代码注释
<!– 裁剪弹窗 –>
<el-dialog v-model="visible" title="裁剪图片" width="1006" :close-on-click-modal="false" :before-close="handleClose">
<div class="cropper-box">
<!– 左侧裁剪区域 –>
<div class="cropper_left">
<!– vue-cropper 核心组件 –>
<vue-cropper
ref="cropperRef"
:img="imgUrl" <!– 待裁剪图片的 base64 –>
:auto-crop="true" <!– 自动裁剪 –>
:auto-crop-width="cropWidth" <!– 裁剪宽度 –>
:auto-crop-height="cropHeight" <!– 裁剪高度 –>
:fixed="true" <!– 固定比例 –>
:fixed-number="currentRatio" <!– 当前裁剪比例 –>
:output-type="png" <!– 输出格式 –>
@realTime="handleRealTime" <!– 实时裁剪回调 –>
/>
</div>
<!– 右侧编辑和预览区域 –>
<div class="right">
<!– 编辑操作区 –>
<div class="top">
<div class="title">编辑</div>
<div class="button">
<!– 旋转、缩放、比例切换按钮 –>
<div class="icon" @click="rotateImage('left')">旋转左</div>
<div class="icon" @click="rotateImage('right')">旋转右</div>
<div class="icon" @click="scaleImage(0.1)">放大</div>
<div class="icon" @click="scaleImage(-0.1)">缩小</div>
<!– 比例切换按钮 –>
<CustomButtons @click="changeRatio([1, 1])">1:1</CustomButtons>
<CustomButtons @click="changeRatio([3, 4])">3:4</CustomButtons>
</div>
</div>
<!– 预览区 –>
<div class="bottom">
<div class="title">预览</div>
<div class="Preview" :style="{
backgroundImage: previewUrl ? `url(${previewUrl})` : 'none',
backgroundSize: 'cover'
}"></div>
</div>
</div>
</div>
<!– 底部操作按钮 –>
<template #footer>
<CustomButtons @click="confirmCrop">确定</CustomButtons>
<CustomButtons @click="handleClose">取消</CustomButtons>
</template>
</el-dialog>
3. 核心功能实现
// 加载原始文件为 base64
const loadImage = (file: File) => {
const reader = new FileReader()
reader.onload = (ev) => {
imgUrl.value = ev.target?.result as string // 将 File 转为 base64
previewUrl.value = '' // 重置预览图
nextTick(() => {
if (cropperRef.value) {
// 重置裁剪状态
cropperRef.value.rotate(0)
cropperRef.value.scale({ x: 1, y: 1 })
}
})
}
reader.readAsDataURL(file) // 读取文件为 DataURL
}
比例切换功能
// 切换裁剪比例
const changeRatio = (ratio: [number, number]) => {
if (!cropperRef.value) return
currentRatio.value = ratio // 更新当前比例
nextTick(() => {
try {
// 重置裁剪框
if (cropperRef.value.reset) {
cropperRef.value.reset() // 调用裁剪组件的重置方法
}
} catch (e) {
console.error('切换比例失败:', e)
}
})
}
裁剪确认逻辑
const confirmCrop = async () => {
if (!cropperRef.value) return
try {
// 1. 获取裁剪后的 Blob 数据
const blob = await new Promise<Blob>((resolve, reject) => {
cropperRef.value.getCropBlob((blob: Blob) => {
blob ? resolve(blob) : reject(new Error('获取裁剪数据失败'))
})
})
// 2. 校验裁剪后的图片
const { isValid, errorMsg } = await validateCroppedImage(blob)
if (!isValid) {
ElMessage.error(errorMsg)
return // 校验失败,不关闭弹窗
}
// 3. 生成 File 对象并触发回调
const croppedFile = new File(
[blob],
props.rawFile.name, // 保留原文件名
{ type: props.rawFile.type || 'image/png' } // 保留原文件类型
)
emit('crop-success', croppedFile) // 触发裁剪成功事件
visible.value = false // 关闭弹窗
emit('update:modelValue', false) // 更新弹窗显示状态
} catch (error) {
ElMessage.error('裁剪失败,请重新操作')
}
}
4. 注意事项
二、父页面
整体架构 :
1.核心功能模块 :
| 模块 | 功能描述 | 实现思路 |
| 封面图上传 | 上传商品封面图,支持 1:1 比例裁剪 | 使用 el-upload + ImageCropper 组件 |
| 媒体文件上传 | 上传商品图片/视频,支持 3:4 比例裁剪 | 使用 el-upload + ImageCropper 组件 |
| 文件校验 | 校验文件格式、大小、数量、时长 | 前端预校验 + 后端校验 |
2. 关键代码注释
<template>
<AdaptiveCard class="add_commodity">
<div class="form-content">
<el-form ref="ruleFormRef" :model="ruleForm" label-width="120rem">
<!– 商品封面图 –>
<el-row :gutter="16">
<el-col :span="24">
<el-form-item label="商品封面图" prop="Businesslicense">
<div class="cover_image">
<div class="tips">图片要求:比例为1:1,1张</div>
<!– 封面图上传组件 –>
<el-upload
class="avatar-uploader"
:show-file-list="false"
:auto-upload="false"
@change="handleCoverChange">
<!– 已上传图片显示 –>
<img v-if="ruleForm.mainImage" :src="ruleForm.mainImage" class="avatar" />
<!– 未上传时的占位符 –>
<div v-else class="avatar-uploader-icon-wrapper">
<el-icon class="avatar-uploader-icon"><Plus /></el-icon>
<span>点击上传</span>
</div>
</el-upload>
</div>
</el-form-item>
</el-col>
</el-row>
<!– 商品图片/视频 –>
<el-row :gutter="16">
<el-col :span="24">
<el-form-item label="商品图片/视频" prop="mediaFiles">
<div class="images_videos">
<div class="tips">
图片要求:宽高比例为3:4, 最多上传5张(排序)
视频要求:时长5秒—5分钟,最多上传3个
</div>
<!– 媒体文件上传组件 –>
<el-upload
:auto-upload="false"
v-model:file-list="localMediaFiles"
list-type="picture-card"
@change="handleMediaChange"
:limit="8">
<el-icon><Plus /></el-icon>
<span>点击上传</span>
</el-upload>
</div>
</el-form-item>
</el-col>
</el-row>
</el-form>
</div>
<!– 图片裁剪组件 –>
<ImageCropper
v-model="cropperVisible"
v-if="cropRawFile"
:fixed-number="fixedNumber"
:raw-file="cropRawFile"
@crop-success="handleCropSuccess"
@crop-cancel="handleCropCancel" />
</AdaptiveCard>
</template>
3. 核心功能实现-封面图上传逻辑
// 封面图处理(1:1比例校验)
const handleCoverChange = (uploadFile: UploadFile) => {
const file = uploadFile.raw as File | undefined
if (!file || !file.type.startsWith('image/')) {
ElMessage.error('请上传图片格式文件')
return
}
const reader = new FileReader()
reader.onload = (e) => {
const img = new Image()
img.onload = () => {
const ratio = img.width / img.height
// 比例误差允许 ±5%
if (Math.abs(ratio – 1) > 0.05) {
ElMessage.warning('封面图比例需为1:1,即将为您打开裁剪工具')
// 核心:设置裁剪参数并打开裁剪弹窗
fixedNumber.value = [1, 1] // 强制设置为1:1比例
cropRawFile.value = file // 传入需要裁剪的原始文件
pendingCropUid = uploadFile.uid // 记录封面图的uid
cropperVisible.value = true // 打开裁剪弹窗
return
}
// 比例符合,自动上传封面图
uploadSingleFile(fileToUploadRawFile(file, uploadFile.uid), 'cover')
}
img.src = e.target?.result as string
}
reader.readAsDataURL(file)
}
媒体文件上传逻辑
// 文件选择变化:自动触发校验
const handleMediaChange = async (file: UploadFile, uploadFiles: UploadUserFile[]) => {
if (file.status !== 'ready' || !file.raw) {
return
}
// 预处理:确保uploadFiles中所有文件的name/uid非空
const safeUploadFiles: ExtendedUploadFile[] = uploadFiles.map(item => ({
…item,
name: item.name || `未命名文件_${Date.now()}`,
uid: typeof item.uid === 'number' ? item.uid : generateUid(),
thumbUrl: item.url,
status: item.status || 'ready' as UploadStatus,
}))
localMediaFiles.value = safeUploadFiles
await validateAndUpload(file.raw as UploadRawFile)
}
裁剪成功回调
// 裁剪成功回调:替换文件并上传
const handleCropSuccess = async (croppedFile: File) => {
if (!pendingCropUid) return
// 生成UploadRawFile对象
const newFile = Object.assign(croppedFile, {
uid: pendingCropUid
}) as UploadRawFile
// 区分封面图/媒体图上传
if (fixedNumber.value[0] === 1 && fixedNumber.value[1] === 1) {
uploadSingleFile(newFile, 'cover')
} else {
// 临时显示缩略图
const tempThumbUrl = URL.createObjectURL(croppedFile)
const index = localMediaFiles.value.findIndex(f => f.uid === pendingCropUid)
if (index > -1) {
localMediaFiles.value[index] = {
…localMediaFiles.value[index]!,
thumbUrl: tempThumbUrl,
status: 'uploading'
}
}
uploadSingleFile(newFile)
}
// 重置状态
pendingCropUid = null
cropperVisible.value = false
ElMessage.success(isCoverCrop ? '封面图裁剪成功' : '媒体图裁剪成功')
}
文件校验逻辑
// 核心:文件校验函数(分流处理图片/视频)
const validateAndUpload = async (rawFile: UploadRawFile) => {
const isImage = ['image/jpeg', 'image/png', 'image/gif'].includes(rawFile.type)
const isVideo = rawFile.type.startsWith('video/')
// 1. 基础格式校验
if (!isImage && !isVideo) {
ElMessage.error('仅支持图片/视频格式!')
removeFileByUid(rawFile.uid)
return false
}
// 2. 大小校验
const maxImgSize = 10 * 1024 * 1024 // 10MB
const maxVideoSize = 300 * 1024 * 1024 // 300MB
if ((isImage && rawFile.size > maxImgSize) || (isVideo && rawFile.size > maxVideoSize)) {
ElMessage.error(isImage ? '图片不能超过10MB!' : '视频不能超过300MB!')
removeFileByUid(rawFile.uid)
return false
}
// 3. 数量校验
const imgCount = ruleForm.mediaFiles.filter(f => f.raw?.type.startsWith('image')).length
const videoCount = ruleForm.mediaFiles.filter(f => f.raw?.type.startsWith('video')).length
if ((isImage && imgCount >= 5) || (isVideo && videoCount >= 3)) {
ElMessage.error(isImage ? '最多上传5张图片!' : '最多上传3个视频!')
removeFileByUid(rawFile.uid)
return false
}
// 4. 视频专属:时长校验
if (isVideo) {
return new Promise<boolean>((resolve) => {
const video = document.createElement('video')
video.preload = 'metadata'
video.src = URL.createObjectURL(rawFile)
video.onloadedmetadata = () => {
URL.revokeObjectURL(video.src)
const duration = video.duration
if (duration < 5 || duration > 300) {
ElMessage.error('视频要求:时长5秒—5分钟!')
removeFileByUid(rawFile.uid)
resolve(false)
} else {
uploadSingleFile(rawFile)
resolve(true)
}
}
})
}
// 5. 图片专属:比例校验
if (isImage) {
return new Promise<boolean>((resolve) => {
const reader = new FileReader()
reader.onload = (e) => {
const img = new Image()
img.onload = () => {
const ratio = img.width / img.height
const expectedRatio = 3 / 4
const tolerance = 0.05
if (Math.abs(ratio – expectedRatio) > tolerance) {
// 比例不符 → 打开裁剪弹窗
fixedNumber.value = [3, 4]
cropRawFile.value = rawFile
pendingCropUid = rawFile.uid
cropperVisible.value = true
resolve(false)
} else {
// 比例符合 → 自动上传
uploadSingleFile(rawFile)
resolve(true)
}
}
img.src = e.target?.result as string
}
reader.readAsDataURL(rawFile)
})
}
return false
}
4. 注意事项
三、总结
| 技术/库 | 用途 |
| Vue 3 | 前端框架 |
| TypeScript | 类型安全 |
| Element Plus | UI 组件库 |
| vue-cropper | 图片裁剪 |
| Axios | API 请求 |

