Browse Source
- Added ImageCropModal component for cropping images before upload. - Updated profile page to handle image cropping for avatar and header icon. - Included vue-advanced-cropper as a dependency in package.json and bun.lock. Made-with: Cursormain
5 changed files with 211 additions and 8 deletions
@ -0,0 +1,156 @@ |
|||
<script setup lang="ts"> |
|||
import { CircleStencil, Cropper, RectangleStencil } from 'vue-advanced-cropper' |
|||
import 'vue-advanced-cropper/dist/style.css' |
|||
|
|||
const props = defineProps<{ |
|||
file: File | null |
|||
variant: 'avatar' | 'headerIcon' |
|||
}>() |
|||
|
|||
const emit = defineEmits<{ |
|||
confirm: [file: File] |
|||
}>() |
|||
|
|||
const open = defineModel<boolean>('open', { default: false }) |
|||
|
|||
const imageSrc = ref<string | null>(null) |
|||
const cropperRef = ref<{ getResult: () => { canvas?: HTMLCanvasElement } } | null>(null) |
|||
const confirming = ref(false) |
|||
|
|||
const title = computed(() => |
|||
props.variant === 'avatar' ? '裁剪头像' : '裁剪公开主页顶栏图标', |
|||
) |
|||
|
|||
const stencilComponent = computed(() => |
|||
props.variant === 'avatar' ? CircleStencil : RectangleStencil, |
|||
) |
|||
|
|||
const stencilProps = { aspectRatio: 1 } |
|||
|
|||
const outputMaxSide = computed(() => (props.variant === 'avatar' ? 512 : 512)) |
|||
|
|||
watch( |
|||
() => ({ isOpen: open.value, f: props.file }), |
|||
({ isOpen, f }) => { |
|||
if (imageSrc.value) { |
|||
URL.revokeObjectURL(imageSrc.value) |
|||
imageSrc.value = null |
|||
} |
|||
if (isOpen && f) { |
|||
imageSrc.value = URL.createObjectURL(f) |
|||
} |
|||
}, |
|||
{ immediate: true }, |
|||
) |
|||
|
|||
onBeforeUnmount(() => { |
|||
if (imageSrc.value) { |
|||
URL.revokeObjectURL(imageSrc.value) |
|||
} |
|||
}) |
|||
|
|||
function canvasToOutputFile( |
|||
source: HTMLCanvasElement, |
|||
mime: string, |
|||
maxSide: number, |
|||
filename: string, |
|||
): Promise<File> { |
|||
let w = source.width |
|||
let h = source.height |
|||
let canvas = source |
|||
if (w > maxSide || h > maxSide) { |
|||
const scale = maxSide / Math.max(w, h) |
|||
w = Math.round(w * scale) |
|||
h = Math.round(h * scale) |
|||
const c = document.createElement('canvas') |
|||
c.width = w |
|||
c.height = h |
|||
const ctx = c.getContext('2d') |
|||
if (!ctx) { |
|||
throw new Error('无法创建画布上下文') |
|||
} |
|||
ctx.drawImage(source, 0, 0, w, h) |
|||
canvas = c |
|||
} |
|||
const quality = mime === 'image/jpeg' ? 0.92 : mime === 'image/webp' ? 0.9 : undefined |
|||
return new Promise((resolve, reject) => { |
|||
canvas.toBlob( |
|||
(blob) => { |
|||
if (!blob) { |
|||
reject(new Error('导出图片失败')) |
|||
return |
|||
} |
|||
resolve(new File([blob], filename, { type: blob.type })) |
|||
}, |
|||
mime, |
|||
quality, |
|||
) |
|||
}) |
|||
} |
|||
|
|||
function pickOutputMime(original: File | null): { mime: string; ext: string } { |
|||
const t = original?.type |
|||
if (t === 'image/png') { |
|||
return { mime: 'image/png', ext: 'png' } |
|||
} |
|||
if (t === 'image/webp') { |
|||
return { mime: 'image/webp', ext: 'webp' } |
|||
} |
|||
return { mime: 'image/jpeg', ext: 'jpg' } |
|||
} |
|||
|
|||
async function onConfirm() { |
|||
const cropper = cropperRef.value |
|||
if (!cropper || !props.file) { |
|||
return |
|||
} |
|||
confirming.value = true |
|||
try { |
|||
const { canvas } = cropper.getResult() |
|||
if (!canvas) { |
|||
throw new Error('无法生成裁剪图') |
|||
} |
|||
const { mime, ext } = pickOutputMime(props.file) |
|||
const base = props.file.name.replace(/\.[^.]+$/, '') || 'image' |
|||
const out = await canvasToOutputFile(canvas, mime, outputMaxSide.value, `${base}-cropped.${ext}`) |
|||
emit('confirm', out) |
|||
open.value = false |
|||
} finally { |
|||
confirming.value = false |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<template> |
|||
<UModal |
|||
v-model:open="open" |
|||
:title="title" |
|||
description="拖动、缩放图片以选取区域;确认后将上传裁剪后的图片。" |
|||
class="w-[calc(100vw-2rem)] sm:max-w-lg" |
|||
> |
|||
<template #body> |
|||
<div v-if="imageSrc" class="space-y-3"> |
|||
<Cropper |
|||
ref="cropperRef" |
|||
class="min-h-[280px] h-[min(52vh,420px)] rounded-lg bg-elevated" |
|||
:src="imageSrc" |
|||
:stencil-component="stencilComponent" |
|||
:stencil-props="stencilProps" |
|||
/> |
|||
<p class="text-xs text-muted"> |
|||
{{ variant === 'avatar' ? '头像将显示为圆形,建议把主体放在圆内。' : '顶栏图标为方形显示。' }} |
|||
</p> |
|||
</div> |
|||
</template> |
|||
<template #footer="{ close }"> |
|||
<div class="flex justify-end gap-2"> |
|||
<UButton type="button" variant="outline" color="neutral" @click="close"> |
|||
取消 |
|||
</UButton> |
|||
<UButton type="button" :loading="confirming" :disabled="!imageSrc" @click="onConfirm"> |
|||
确认并上传 |
|||
</UButton> |
|||
</div> |
|||
</template> |
|||
</UModal> |
|||
</template> |
|||
Binary file not shown.
Loading…
Reference in new issue