Appearance
BizAvatarCropper 图片裁剪
头像 / 图片裁剪弹窗:自研 canvas 实现,零第三方依赖。视口即裁剪区——拖拽移动图片、滚轮缩放、按钮缩放 / 左右旋转 90°、三分构图网格、实时预览(圆形 / 方形),确认输出 blob / file / base64(canvas 矩阵与渲染 transform 严格一致,所见即所得)。用户头像、证件照、封面图上传前裁剪(可作为 BizUploadImage 的 before-upload 前置步骤)。
基础用法
1:1 头像裁剪 + 圆形预览,确认输出 base64 实时回显。
<script setup lang="ts">
/** 场景:头像裁剪——1:1 圆形预览,确认输出 base64 实时回显 */
import { ref } from 'vue'
import { ElButton } from 'element-plus'
import { BizAvatarCropper } from 'vue3-ep-biz-lib'
import type { BizAvatarCroppedPayload } from 'vue3-ep-biz-lib'
/** 本地生成演示图(dataURL,避免跨域图导致 canvas 输出污染) */
const makeDemoImage = (w: number, h: number): string => {
const canvas = document.createElement('canvas')
canvas.width = w
canvas.height = h
const ctx = canvas.getContext('2d')
if (!ctx) return ''
const grad = ctx.createLinearGradient(0, 0, w, h)
grad.addColorStop(0, '#409eff')
grad.addColorStop(1, '#67c23a')
ctx.fillStyle = grad
ctx.fillRect(0, 0, w, h)
ctx.fillStyle = 'rgba(255,255,255,0.9)'
ctx.font = 'bold 40px sans-serif'
ctx.textAlign = 'center'
ctx.fillText('DEMO IMAGE', w / 2, h / 2)
return canvas.toDataURL('image/png')
}
const visible = ref(false)
const src = ref('')
const result = ref('')
const open = (): void => {
src.value = makeDemoImage(640, 440)
visible.value = true
}
const onCropped = (payload: BizAvatarCroppedPayload): void => {
result.value = payload.base64
visible.value = false
}
</script>
<template>
<div>
<ElButton type="primary" @click="open">选择头像并裁剪</ElButton>
<BizAvatarCropper v-model:visible="visible" :src="src" @cropped="onCropped" />
<div v-if="result" style="margin-top: 14px; display: flex; align-items: center; gap: 10px">
<img
:src="result"
style="width: 72px; height: 72px; border-radius: 50%; border: 1px solid #dcdfe6"
/>
<span style="font-size: 13px; color: #909399">裁剪结果实时回显(base64)</span>
</div>
</div>
</template>
显示代码
直接产出 File
output-type="file" 确认后拿到 File 对象(可直接交 FormData / OSS 直传);16:9 封面 + jpeg 压缩 + loading 上传中态。<script setup lang="ts">
/** 场景:直接产出 File——outputType=file,确认后拿到 File 对象可直接交上传接口 */
import { ref } from 'vue'
import { ElButton, ElMessage } from 'element-plus'
import { BizAvatarCropper } from 'vue3-ep-biz-lib'
import type { BizAvatarCroppedPayload } from 'vue3-ep-biz-lib'
const makeDemoImage = (w: number, h: number): string => {
const canvas = document.createElement('canvas')
canvas.width = w
canvas.height = h
const ctx = canvas.getContext('2d')
if (!ctx) return ''
const grad = ctx.createLinearGradient(0, 0, w, h)
grad.addColorStop(0, '#e6a23c')
grad.addColorStop(1, '#f56c6c')
ctx.fillStyle = grad
ctx.fillRect(0, 0, w, h)
ctx.fillStyle = 'rgba(255,255,255,0.9)'
ctx.font = 'bold 40px sans-serif'
ctx.textAlign = 'center'
ctx.fillText('COVER 16:9', w / 2, h / 2)
return canvas.toDataURL('image/png')
}
const visible = ref(false)
const src = ref('')
const loading = ref(false)
const open = (): void => {
src.value = makeDemoImage(800, 500)
visible.value = true
}
const onCropped = (payload: BizAvatarCroppedPayload): void => {
const file = payload.file
if (!file) return
loading.value = true
/* 模拟上传:实际项目中把 file 交给 FormData / OSS 直传 */
setTimeout(() => {
loading.value = false
visible.value = false
ElMessage.success(
`已生成文件 ${file.name}(${(file.size / 1024).toFixed(1)}KB,${payload.width}×${payload.height}),可直接上传`
)
}, 600)
}
</script>
<template>
<div>
<ElButton type="primary" @click="open">上传封面(16:9 裁剪)</ElButton>
<BizAvatarCropper
v-model:visible="visible"
:src="src"
:ratio="16 / 9"
preview-shape="square"
output-type="file"
output-mime="image/jpeg"
:quality="0.85"
:loading="loading"
title="裁剪封面图"
confirm-text="上传"
@cropped="onCropped"
/>
</div>
</template>
显示代码
多比例与预览形状
ratio 任意比例(1 / 4:3 / 16:9),preview-shape 圆形与方形预览对比。<script setup lang="ts">
/** 场景:比例切换——1 / 4:3 / 16:9 多比例,圆形与方形预览对比 */
import { computed, ref } from 'vue'
import { ElButton, ElRadioButton, ElRadioGroup } from 'element-plus'
import { BizAvatarCropper } from 'vue3-ep-biz-lib'
import type { BizAvatarCroppedPayload } from 'vue3-ep-biz-lib'
const makeDemoImage = (w: number, h: number): string => {
const canvas = document.createElement('canvas')
canvas.width = w
canvas.height = h
const ctx = canvas.getContext('2d')
if (!ctx) return ''
const grad = ctx.createLinearGradient(0, 0, w, h)
grad.addColorStop(0, '#6f42c1')
grad.addColorStop(1, '#20c997')
ctx.fillStyle = grad
ctx.fillRect(0, 0, w, h)
ctx.fillStyle = 'rgba(255,255,255,0.9)'
ctx.font = 'bold 36px sans-serif'
ctx.textAlign = 'center'
ctx.fillText('RATIO', w / 2, h / 2)
return canvas.toDataURL('image/png')
}
const ratio = ref(1)
const previewShape = ref<'circle' | 'square'>('circle')
const visible = ref(false)
const src = ref('')
const result = ref('')
const ratioLabel = computed(() =>
ratio.value === 1 ? '1:1' : ratio.value === 4 / 3 ? '4:3' : '16:9'
)
const open = (): void => {
src.value = makeDemoImage(720, 480)
visible.value = true
}
const onCropped = (payload: BizAvatarCroppedPayload): void => {
result.value = payload.base64
visible.value = false
}
</script>
<template>
<div>
<div style="margin-bottom: 12px; display: flex; align-items: center; gap: 12px">
<ElRadioGroup v-model="ratio">
<ElRadioButton :value="1">1:1</ElRadioButton>
<ElRadioButton :value="4 / 3">4:3</ElRadioButton>
<ElRadioButton :value="16 / 9">16:9</ElRadioButton>
</ElRadioGroup>
<ElRadioGroup v-model="previewShape">
<ElRadioButton value="circle">圆形预览</ElRadioButton>
<ElRadioButton value="square">方形预览</ElRadioButton>
</ElRadioGroup>
</div>
<ElButton type="primary" @click="open">{{ ratioLabel }} 裁剪</ElButton>
<BizAvatarCropper
v-model:visible="visible"
:src="src"
:ratio="ratio"
:preview-shape="previewShape"
@cropped="onCropped"
/>
<img
v-if="result"
:src="result"
style="margin-top: 14px; max-width: 280px; border: 1px solid #dcdfe6; border-radius: 4px"
/>
</div>
</template>
显示代码
压缩输出
jpeg 格式透明区域按 fillColor 白底填充;quality 0.8 平衡清晰度与体积
output-width 限定输出宽度 + quality 质量控制体积;jpeg 格式透明区域按 fill-color 白底填充。<script setup lang="ts">
/** 场景:压缩输出——outputWidth 限定输出宽度 + jpeg 质量,控制产物体积 */
import { ref } from 'vue'
import { ElButton, ElMessage } from 'element-plus'
import { BizAvatarCropper } from 'vue3-ep-biz-lib'
import type { BizAvatarCroppedPayload } from 'vue3-ep-biz-lib'
const makeDemoImage = (w: number, h: number): string => {
const canvas = document.createElement('canvas')
canvas.width = w
canvas.height = h
const ctx = canvas.getContext('2d')
if (!ctx) return ''
for (let x = 0; x < w; x += 32) {
for (let y = 0; y < h; y += 32) {
ctx.fillStyle = `hsl(${(x + y) % 360}, 70%, 60%)`
ctx.fillRect(x, y, 32, 32)
}
}
return canvas.toDataURL('image/png')
}
const visible = ref(false)
const src = ref('')
const open = (): void => {
src.value = makeDemoImage(900, 900)
visible.value = true
}
const onCropped = (payload: BizAvatarCroppedPayload): void => {
visible.value = false
ElMessage.success(
`输出 ${payload.width}×${payload.height},体积 ${(payload.blob.size / 1024).toFixed(1)}KB(原图 900×900)`
)
}
</script>
<template>
<div>
<ElButton type="primary" @click="open">裁剪并压缩到 240px</ElButton>
<BizAvatarCropper
v-model:visible="visible"
:src="src"
:output-width="240"
output-mime="image/jpeg"
:quality="0.8"
@cropped="onCropped"
/>
<p style="margin-top: 10px; font-size: 13px; color: #909399">
jpeg 格式透明区域按 fillColor 白底填充;quality 0.8 平衡清晰度与体积
</p>
</div>
</template>
显示代码
关闭旋转
证件照场景
rotateable=false 锁定旋转,仅缩放 + 拖拽对齐;3.5:4.5 标准证件照比例。<script setup lang="ts">
/** 场景:证件照——关闭旋转(rotateable=false),仅缩放 + 拖拽对齐 */
import { ref } from 'vue'
import { ElButton, ElMessage } from 'element-plus'
import { BizAvatarCropper } from 'vue3-ep-biz-lib'
import type { BizAvatarCroppedPayload } from 'vue3-ep-biz-lib'
const makeDemoImage = (w: number, h: number): string => {
const canvas = document.createElement('canvas')
canvas.width = w
canvas.height = h
const ctx = canvas.getContext('2d')
if (!ctx) return ''
const grad = ctx.createLinearGradient(0, 0, 0, h)
grad.addColorStop(0, '#5c6bc0')
grad.addColorStop(1, '#42a5f5')
ctx.fillStyle = grad
ctx.fillRect(0, 0, w, h)
ctx.fillStyle = '#ffe0b2'
ctx.beginPath()
ctx.arc(w / 2, h / 2 - h * 0.12, h * 0.18, 0, Math.PI * 2)
ctx.fill()
ctx.fillStyle = '#37474f'
ctx.beginPath()
ctx.ellipse(w / 2, h / 2 + h * 0.34, w * 0.26, h * 0.2, 0, Math.PI, Math.PI * 2)
ctx.fill()
return canvas.toDataURL('image/png')
}
const visible = ref(false)
const src = ref('')
const open = (): void => {
src.value = makeDemoImage(600, 800)
visible.value = true
}
const onCropped = (payload: BizAvatarCroppedPayload): void => {
visible.value = false
ElMessage.success(`证件照输出 ${payload.width}×${payload.height}`)
}
</script>
<template>
<div>
<ElButton type="primary" @click="open">3.5:4.5 证件照裁剪</ElButton>
<BizAvatarCropper
v-model:visible="visible"
:src="src"
:ratio="3.5 / 4.5"
:rotateable="false"
:output-width="354"
confirm-text="生成证件照"
title="证件照裁剪"
@cropped="onCropped"
/>
</div>
</template>
显示代码
上传链路整合
本地选图(FileReader 转 dataURL)→ 裁剪 → 回显,全链路无需接口
本地选图(FileReader 转 dataURL)→ 裁剪 → 头像回显,全链路无需接口。
<script setup lang="ts">
/** 场景:上传链路整合——本地选图 FileReader 转 dataURL → 裁剪 → 头像回显(BizUploadImage 前置步骤) */
import { ref } from 'vue'
import { ElAvatar, ElButton, ElMessage } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import { BizAvatarCropper } from 'vue3-ep-biz-lib'
import type { BizAvatarCroppedPayload } from 'vue3-ep-biz-lib'
const visible = ref(false)
const rawSrc = ref('')
const avatar = ref('')
const fileInput = ref<HTMLInputElement | null>(null)
const pick = (): void => {
fileInput.value?.click()
}
const onFileChange = (event: Event): void => {
const file = (event.target as HTMLInputElement).files?.[0]
;(event.target as HTMLInputElement).value = ''
if (!file) return
if (!file.type.startsWith('image/')) {
ElMessage.warning('请选择图片文件')
return
}
const reader = new FileReader()
reader.onload = (): void => {
rawSrc.value = String(reader.result)
visible.value = true
}
reader.readAsDataURL(file)
}
const onCropped = (payload: BizAvatarCroppedPayload): void => {
avatar.value = payload.base64
visible.value = false
ElMessage.success('裁剪完成(实际项目此处调用上传接口)')
}
</script>
<template>
<div style="display: flex; align-items: center; gap: 16px">
<ElAvatar :size="80" :src="avatar" style="flex-shrink: 0" />
<div>
<input ref="fileInput" type="file" accept="image/*" style="display: none" @change="onFileChange" />
<ElButton :icon="Plus" @click="pick">{{ avatar ? '重新裁剪' : '上传头像' }}</ElButton>
<p style="margin-top: 8px; font-size: 13px; color: #909399">
本地选图(FileReader 转 dataURL)→ 裁剪 → 回显,全链路无需接口
</p>
</div>
<BizAvatarCropper
v-model:visible="visible"
:src="rawSrc"
:output-width="320"
output-mime="image/jpeg"
:quality="0.9"
@cropped="onCropped"
/>
</div>
</template>
显示代码
Props
| 属性名 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| visible | boolean | false | 弹窗显隐(v-model:visible) |
| src | string | '' | 待裁剪图片(url / dataURL;跨域 url 会因 canvas 污染输出失败,建议同源或 base64) |
| title | string | '裁剪图片' | 弹窗标题 |
| ratio | number | 1 | 裁剪宽高比(1 头像、4/3、16/9…) |
| preview-shape | 'circle' | 'square' | 'circle' | 预览形状 |
| output-type | 'blob' | 'file' | 'base64' | 'blob' | 输出形态(file 时 payload 附带 File 对象) |
| output-width | number | 视口像素宽 | 输出宽度(压缩出口) |
| output-mime | 'image/png' | 'image/jpeg' | 'image/webp' | 'image/png' | 输出格式(透明图请用 png) |
| quality | number | 0.9 | 输出质量(jpeg / webp) |
| fill-color | string | '#ffffff' | 非透明格式(jpeg / webp)填充底色 |
| rotateable | boolean | true | 允许旋转按钮 |
| confirm-text | string | '确认裁剪' | 确认按钮文案 |
| cancel-text | string | '取消' | 取消按钮文案 |
| loading | boolean | false | 确认按钮 loading(外部上传中置 true) |
Events
| 事件名 | 参数 | 说明 |
|---|---|---|
| update:visible | (value: boolean) | 弹窗显隐(v-model:visible) |
| cropped | (payload: BizAvatarCroppedPayload) | 裁剪完成:{ blob, base64, file?, width, height } |
| cancel | () | 取消关闭 |
Expose
| 名称 | 类型 | 说明 |
|---|---|---|
| crop | () => void | 手动触发裁剪(与确认按钮等价) |
| reset | () => void | 复位变换(缩放 / 旋转 / 位置) |
TIP
裁剪交互为「拖图不拖框」模型:缩放下限锁定 cover(图片始终铺满视口,不会裁出空白);滚轮缩放围绕视口中心。