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

4
components.d.ts vendored
View File

@@ -25,11 +25,9 @@ declare module 'vue' {
ElFormItem: typeof import('element-plus/es')['ElFormItem'] ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElIcon: typeof import('element-plus/es')['ElIcon'] ElIcon: typeof import('element-plus/es')['ElIcon']
ElInput: typeof import('element-plus/es')['ElInput'] ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElInputOtp: typeof import('element-plus/es')['ElInputOtp'] ElInputOtp: typeof import('element-plus/es')['ElInputOtp']
ElOption: typeof import('element-plus/es')['ElOption'] ElOption: typeof import('element-plus/es')['ElOption']
ElPopover: typeof import('element-plus/es')['ElPopover'] ElPopover: typeof import('element-plus/es')['ElPopover']
ElRadio: typeof import('element-plus/es')['ElRadio']
ElRadioButton: typeof import('element-plus/es')['ElRadioButton'] ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup'] ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElSelect: typeof import('element-plus/es')['ElSelect'] ElSelect: typeof import('element-plus/es')['ElSelect']
@@ -58,11 +56,9 @@ declare global {
const ElFormItem: typeof import('element-plus/es')['ElFormItem'] const ElFormItem: typeof import('element-plus/es')['ElFormItem']
const ElIcon: typeof import('element-plus/es')['ElIcon'] const ElIcon: typeof import('element-plus/es')['ElIcon']
const ElInput: typeof import('element-plus/es')['ElInput'] const ElInput: typeof import('element-plus/es')['ElInput']
const ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
const ElInputOtp: typeof import('element-plus/es')['ElInputOtp'] const ElInputOtp: typeof import('element-plus/es')['ElInputOtp']
const ElOption: typeof import('element-plus/es')['ElOption'] const ElOption: typeof import('element-plus/es')['ElOption']
const ElPopover: typeof import('element-plus/es')['ElPopover'] const ElPopover: typeof import('element-plus/es')['ElPopover']
const ElRadio: typeof import('element-plus/es')['ElRadio']
const ElRadioButton: typeof import('element-plus/es')['ElRadioButton'] const ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
const ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup'] const ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
const ElSelect: typeof import('element-plus/es')['ElSelect'] const ElSelect: typeof import('element-plus/es')['ElSelect']

View File

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