feat: 头像裁剪
This commit is contained in:
4
components.d.ts
vendored
4
components.d.ts
vendored
@@ -25,11 +25,9 @@ declare module 'vue' {
|
||||
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
||||
ElIcon: typeof import('element-plus/es')['ElIcon']
|
||||
ElInput: typeof import('element-plus/es')['ElInput']
|
||||
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
|
||||
ElInputOtp: typeof import('element-plus/es')['ElInputOtp']
|
||||
ElOption: typeof import('element-plus/es')['ElOption']
|
||||
ElPopover: typeof import('element-plus/es')['ElPopover']
|
||||
ElRadio: typeof import('element-plus/es')['ElRadio']
|
||||
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
|
||||
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||
@@ -58,11 +56,9 @@ declare global {
|
||||
const ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
||||
const ElIcon: typeof import('element-plus/es')['ElIcon']
|
||||
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 ElOption: typeof import('element-plus/es')['ElOption']
|
||||
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 ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
||||
const ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user