Browse Source

feat(image-crop): integrate vue-advanced-cropper for avatar and header icon uploads

- 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: Cursor
main
npmrun 5 hours ago
parent
commit
8d29da0780
  1. 156
      app/components/ImageCropModal.vue
  2. 53
      app/pages/me/profile/index.vue
  3. 9
      bun.lock
  4. 1
      package.json
  5. BIN
      packages/drizzle-pkg/db.sqlite

156
app/components/ImageCropModal.vue

@ -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>

53
app/pages/me/profile/index.vue

@ -48,6 +48,16 @@ const uploadingHeaderIcon = ref(false)
const avatarFileInput = ref<HTMLInputElement | null>(null)
const headerIconFileInput = ref<HTMLInputElement | null>(null)
const imageCropOpen = ref(false)
const imageCropFile = ref<File | null>(null)
const imageCropVariant = ref<'avatar' | 'headerIcon'>('avatar')
watch(imageCropOpen, (isOpen) => {
if (!isOpen) {
imageCropFile.value = null
}
})
const BIO_PREVIEW_MAX_CHARS = 200
const bioModalOpen = ref(false)
const bioDraft = ref('')
@ -83,13 +93,31 @@ function openHeaderIconPicker() {
headerIconFileInput.value?.click()
}
async function onAvatarFileChange(ev: Event) {
function onAvatarFileChange(ev: Event) {
const input = ev.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) {
return
}
imageCropVariant.value = 'avatar'
imageCropFile.value = file
imageCropOpen.value = true
}
function onHeaderIconFileChange(ev: Event) {
const input = ev.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) {
return
}
imageCropVariant.value = 'headerIcon'
imageCropFile.value = file
imageCropOpen.value = true
}
async function uploadCroppedAvatar(file: File) {
uploadingAvatar.value = true
try {
const form = new FormData()
@ -113,13 +141,7 @@ async function onAvatarFileChange(ev: Event) {
}
}
async function onHeaderIconFileChange(ev: Event) {
const input = ev.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) {
return
}
async function uploadCroppedHeaderIcon(file: File) {
uploadingHeaderIcon.value = true
try {
const form = new FormData()
@ -143,6 +165,14 @@ async function onHeaderIconFileChange(ev: Event) {
}
}
async function onImageCropConfirm(file: File) {
if (imageCropVariant.value === 'avatar') {
await uploadCroppedAvatar(file)
} else {
await uploadCroppedHeaderIcon(file)
}
}
async function load() {
loading.value = true
try {
@ -388,6 +418,13 @@ async function save() {
</UForm>
</div>
<ImageCropModal
v-model:open="imageCropOpen"
:file="imageCropFile"
:variant="imageCropVariant"
@confirm="onImageCropConfirm"
/>
<UModal
v-model:open="bioModalOpen"
title="编辑生平 / 简介"

9
bun.lock

@ -27,6 +27,7 @@
"tailwindcss": "^4.2.2",
"ufo": "1.6.3",
"vue": "3.5.32",
"vue-advanced-cropper": "^2.8.9",
"vue-router": "5.0.4",
"zod": "4.3.6",
},
@ -1031,6 +1032,8 @@
"citty": ["citty@0.2.2", "", {}, "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w=="],
"classnames": ["classnames@2.5.1", "", {}, "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="],
"cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="],
"cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="],
@ -1107,6 +1110,8 @@
"db0": ["db0@0.3.4", "", { "peerDependencies": { "@electric-sql/pglite": "*", "@libsql/client": "*", "better-sqlite3": "*", "drizzle-orm": "*", "mysql2": "*", "sqlite3": "*" }, "optionalPeers": ["@electric-sql/pglite", "@libsql/client", "better-sqlite3", "drizzle-orm", "mysql2", "sqlite3"] }, "sha512-RiXXi4WaNzPTHEOu8UPQKMooIbqOEyqA1t7Z6MsdxSCeb8iUC9ko3LcmsLmeUt2SM5bctfArZKkRQggKZz7JNw=="],
"debounce": ["debounce@1.2.1", "", {}, "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
@ -1165,6 +1170,8 @@
"eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
"easy-bem": ["easy-bem@1.1.1", "", {}, "sha512-GJRqdiy2h+EXy6a8E6R+ubmqUM08BK0FWNq41k24fup6045biQ8NXxoXimiwegMQvFFV3t1emADdGNL1TlS61A=="],
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"electron-to-chromium": ["electron-to-chromium@1.5.334", "", {}, "sha512-mgjZAz7Jyx1SRCwEpy9wefDS7GvNPazLthHg8eQMJ76wBdGQQDW33TCrUTvQ4wzpmOrv2zrFoD3oNufMdyMpog=="],
@ -2069,6 +2076,8 @@
"vue": ["vue@3.5.32", "", { "dependencies": { "@vue/compiler-dom": "3.5.32", "@vue/compiler-sfc": "3.5.32", "@vue/runtime-dom": "3.5.32", "@vue/server-renderer": "3.5.32", "@vue/shared": "3.5.32" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw=="],
"vue-advanced-cropper": ["vue-advanced-cropper@2.8.9", "", { "dependencies": { "classnames": "^2.2.6", "debounce": "^1.2.0", "easy-bem": "^1.0.2" }, "peerDependencies": { "vue": "^3.0.0" } }, "sha512-1jc5gO674kVGpJKekoaol6ZlwaF5VYDLSBwBOUpViW0IOrrRsyLw6XNszjEqgbavvqinlKNS6Kqlom3B5M72Tw=="],
"vue-bundle-renderer": ["vue-bundle-renderer@2.2.0", "", { "dependencies": { "ufo": "^1.6.1" } }, "sha512-sz/0WEdYH1KfaOm0XaBmRZOWgYTEvUDt6yPYaUzl4E52qzgWLlknaPPTTZmp6benaPTlQAI/hN1x3tAzZygycg=="],
"vue-component-type-helpers": ["vue-component-type-helpers@3.2.6", "", {}, "sha512-O02tnvIfOQVmnvoWwuSydwRoHjZVt8UEBR+2p4rT35p8GAy5VTlWP8o5qXfJR/GWCN0nVZoYWsVUvx2jwgdBmQ=="],

1
package.json

@ -41,6 +41,7 @@
"tailwindcss": "^4.2.2",
"ufo": "1.6.3",
"vue": "3.5.32",
"vue-advanced-cropper": "^2.8.9",
"vue-router": "5.0.4",
"zod": "4.3.6"
},

BIN
packages/drizzle-pkg/db.sqlite

Binary file not shown.
Loading…
Cancel
Save