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