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']
|
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']
|
||||||
|
|||||||
@@ -10,15 +10,18 @@
|
|||||||
</el-icon>
|
</el-icon>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 裁剪画布容器 -->
|
<!--
|
||||||
<div ref="containerRef" class="avatar-cropper__canvas-wrapper">
|
源图片:cropperjs 读取此图片后将其 display:none,
|
||||||
<img
|
并在其 DOM 位置之后插入裁剪画布
|
||||||
ref="imageRef"
|
-->
|
||||||
:src="imageSrc"
|
<img
|
||||||
alt="裁剪预览"
|
ref="imageRef"
|
||||||
class="avatar-cropper__image"
|
:src="imageSrc"
|
||||||
/>
|
alt=""
|
||||||
</div>
|
class="avatar-cropper__source-image"
|
||||||
|
@load="handleImageLoad"
|
||||||
|
@error="handleImageError"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- 底部操作 -->
|
<!-- 底部操作 -->
|
||||||
<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;
|
||||||
|
|||||||
Reference in New Issue
Block a user