feat: 头像裁剪

This commit is contained in:
2026-06-22 12:42:41 +08:00
parent ec66afaa72
commit 28b27bf127
2 changed files with 80 additions and 48 deletions

View File

@@ -10,15 +10,18 @@
</el-icon>
</div>
<!-- 裁剪画布容器 -->
<div ref="containerRef" class="avatar-cropper__canvas-wrapper">
<img
ref="imageRef"
:src="imageSrc"
alt="裁剪预览"
class="avatar-cropper__image"
/>
</div>
<!--
源图片cropperjs 读取此图片后将其 display:none
并在其 DOM 位置之后插入裁剪画布
-->
<img
ref="imageRef"
:src="imageSrc"
alt=""
class="avatar-cropper__source-image"
@load="handleImageLoad"
@error="handleImageError"
/>
<!-- 底部操作 -->
<div class="avatar-cropper__footer">
@@ -34,20 +37,24 @@
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { Close } from '@element-plus/icons-vue'
import Cropper, { type CropperSelection } from 'cropperjs'
/** 裁剪输出配置 */
/** 裁剪输出尺寸(宽高相等,正方形) */
const CROP_SIZE = 200
/** JPEG 导出质量 */
/** JPEG 导出质量 0-1 */
const CROP_QUALITY = 0.85
/** 允许的图片 MIME 类型 */
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'] as const
/** 文件大小上限 */
/** 文件大小上限 2MB */
const MAX_FILE_SIZE = 2 * 1024 * 1024
/** cropperjs v2 模板:预配置 1:1 裁剪框 */
/**
* cropperjs v2 模板
* 1:1 正方形裁剪框,初始覆盖 90% 区域,可移动/缩放
* 注意grid/crosshair/handles 必须在 <cropper-selection> 内部
*/
const CROPPER_TEMPLATE = `
<cropper-canvas background>
<cropper-image></cropper-image>
@@ -58,12 +65,20 @@ const CROPPER_TEMPLATE = `
initial-coverage="0.9"
movable
resizable
zoomable
outlined
></cropper-selection>
<cropper-grid role="grid" covered></cropper-grid>
<cropper-crosshair centered></cropper-crosshair>
<cropper-handle action="move" theme-color="rgba(255, 255, 255, 0.35)"></cropper-handle>
>
<cropper-grid role="grid" bordered covered></cropper-grid>
<cropper-crosshair centered></cropper-crosshair>
<cropper-handle action="move" theme-color="rgba(255, 255, 255, 0.35)"></cropper-handle>
<cropper-handle action="n-resize"></cropper-handle>
<cropper-handle action="e-resize"></cropper-handle>
<cropper-handle action="s-resize"></cropper-handle>
<cropper-handle action="w-resize"></cropper-handle>
<cropper-handle action="ne-resize"></cropper-handle>
<cropper-handle action="nw-resize"></cropper-handle>
<cropper-handle action="se-resize"></cropper-handle>
<cropper-handle action="sw-resize"></cropper-handle>
</cropper-selection>
</cropper-canvas>
`
@@ -77,14 +92,15 @@ const emit = defineEmits<{
(e: 'cancel'): void
}>()
const containerRef = ref<HTMLDivElement>()
const imageRef = ref<HTMLImageElement>()
const imageSrc = ref('')
const confirming = ref(false)
let cropper: Cropper | null = null
/** 图片是否已加载完成 */
let imageLoaded = false
/** 校验文件 */
/** 校验文件格式和大小 */
function validateFile(file: File): string | null {
if (!(ALLOWED_TYPES as readonly string[]).includes(file.type)) {
return '仅支持 JPG、PNG、WebP、GIF 格式'
@@ -95,35 +111,50 @@ function validateFile(file: File): string | null {
return null
}
onMounted(async () => {
onMounted(() => {
const error = validateFile(props.file)
if (error) {
emit('cancel')
return
}
// 生成 Object URL 触发图片加载
imageSrc.value = URL.createObjectURL(props.file)
await nextTick()
if (!imageRef.value || !containerRef.value) return
cropper = new Cropper(imageRef.value, {
container: containerRef.value,
template: CROPPER_TEMPLATE
})
})
onBeforeUnmount(() => {
destroyCropper()
if (imageSrc.value) {
URL.revokeObjectURL(imageSrc.value)
imageSrc.value = ''
}
})
/** 图片加载成功后初始化 Cropper */
function handleImageLoad() {
imageLoaded = true
if (!imageRef.value) return
// cropperjs v2: 以 img 为数据源,不传 container直接在 img 父元素中渲染裁剪 UI
cropper = new Cropper(imageRef.value, {
template: CROPPER_TEMPLATE
})
}
/** 图片加载失败 */
function handleImageError() {
emit('cancel')
}
/** 销毁 Cropper 实例 */
function destroyCropper() {
if (cropper) {
cropper.destroy()
cropper = null
}
if (imageSrc.value) {
URL.revokeObjectURL(imageSrc.value)
}
})
}
/** 确认裁剪:异步导出 Canvas → Blob */
/** 确认裁剪:导出 Canvas → Blob */
async function handleConfirm() {
if (!cropper) return
@@ -135,6 +166,7 @@ async function handleConfirm() {
return
}
// $toCanvas 返回 HTMLCanvasElement
const canvas = await selection.$toCanvas({
width: CROP_SIZE,
height: CROP_SIZE
@@ -164,6 +196,7 @@ function handleCancel() {
</script>
<style scoped lang="scss">
/* ========== 遮罩层 ========== */
.avatar-cropper-overlay {
position: fixed;
inset: 0;
@@ -175,6 +208,7 @@ function handleCancel() {
backdrop-filter: blur(4px);
}
/* ========== 裁剪弹窗 ========== */
.avatar-cropper {
display: flex;
flex-direction: column;
@@ -210,20 +244,22 @@ function handleCancel() {
}
}
.avatar-cropper__canvas-wrapper {
width: 100%;
min-height: 300px;
max-height: 400px;
background: var(--bg-body);
overflow: hidden;
}
.avatar-cropper__image {
/* 源图片cropperjs 初始化时将其 display:none在此之前短暂可见 */
.avatar-cropper__source-image {
display: block;
max-width: 100%;
max-height: 400px;
}
/* cropperjs 生成的 cropper-canvas 撑满可用空间 */
:deep(cropper-canvas) {
display: block;
min-height: 300px;
max-height: 420px;
background: var(--bg-body);
}
/* ========== 底部操作栏 ========== */
.avatar-cropper__footer {
display: flex;
align-items: center;