diff --git a/apps/jingrow/frontend/src/shared/utils/imageResize.ts b/apps/jingrow/frontend/src/shared/utils/imageResize.ts new file mode 100644 index 0000000..afed702 --- /dev/null +++ b/apps/jingrow/frontend/src/shared/utils/imageResize.ts @@ -0,0 +1,184 @@ +/** + * 图片压缩工具 - 使用原生 Canvas API + * 将图片压缩到指定最大尺寸(1024x1024px) + */ + +export interface ResizeOptions { + maxWidth?: number + maxHeight?: number + quality?: number + outputFormat?: 'image/jpeg' | 'image/png' | 'image/webp' + mode?: 'contain' | 'cover' | 'crop' // contain: 等比缩放, cover: 填充裁剪, crop: 居中裁剪 +} + +const DEFAULT_OPTIONS: Required = { + maxWidth: 1024, + maxHeight: 1024, + quality: 0.92, + outputFormat: 'image/jpeg', + mode: 'contain' +} + +/** + * 使用 Canvas API 压缩图片 + * @param file 原始图片文件 + * @param options 压缩选项 + * @returns 压缩后的 Blob + */ +export async function resizeImageWithCanvas( + file: File, + options: ResizeOptions = {} +): Promise { + const opts = { ...DEFAULT_OPTIONS, ...options } + + return new Promise((resolve, reject) => { + const img = new Image() + const canvas = document.createElement('canvas') + const ctx = canvas.getContext('2d') + + if (!ctx) { + reject(new Error('Canvas context not available')) + return + } + + const objectUrl = URL.createObjectURL(file) + + img.onload = () => { + try { + let { width, height } = img + + // 如果图片尺寸已经小于等于目标尺寸,直接返回原图 + if (width <= opts.maxWidth && height <= opts.maxHeight) { + URL.revokeObjectURL(objectUrl) + // 直接读取原文件为 blob + file.arrayBuffer().then(buffer => { + resolve(new Blob([buffer], { type: file.type })) + }).catch(reject) + return + } + + // 计算目标尺寸 + let targetWidth = width + let targetHeight = height + let sourceX = 0 + let sourceY = 0 + let sourceWidth = width + let sourceHeight = height + + if (opts.mode === 'contain') { + // 等比缩放,保持宽高比 + const scale = Math.min( + opts.maxWidth / width, + opts.maxHeight / height, + 1 // 不放大 + ) + targetWidth = Math.round(width * scale) + targetHeight = Math.round(height * scale) + } else if (opts.mode === 'cover') { + // 填充模式:裁剪以填满目标尺寸 + const scale = Math.max( + opts.maxWidth / width, + opts.maxHeight / height + ) + targetWidth = opts.maxWidth + targetHeight = opts.maxHeight + sourceWidth = Math.round(opts.maxWidth / scale) + sourceHeight = Math.round(opts.maxHeight / scale) + sourceX = Math.round((width - sourceWidth) / 2) + sourceY = Math.round((height - sourceHeight) / 2) + } else if (opts.mode === 'crop') { + // 居中裁剪到目标尺寸 + const scale = Math.max( + opts.maxWidth / width, + opts.maxHeight / height + ) + const scaledWidth = width * scale + const scaledHeight = height * scale + targetWidth = opts.maxWidth + targetHeight = opts.maxHeight + sourceWidth = width + sourceHeight = height + // 计算裁剪区域 + if (scaledWidth > opts.maxWidth) { + sourceX = Math.round((scaledWidth - opts.maxWidth) / 2 / scale) + sourceWidth = Math.round(opts.maxWidth / scale) + } + if (scaledHeight > opts.maxHeight) { + sourceY = Math.round((scaledHeight - opts.maxHeight) / 2 / scale) + sourceHeight = Math.round(opts.maxHeight / scale) + } + } + + // 设置 canvas 尺寸 + canvas.width = targetWidth + canvas.height = targetHeight + + // 设置图片质量(仅对 JPEG 有效) + if (opts.outputFormat === 'image/jpeg') { + ctx.fillStyle = '#FFFFFF' + ctx.fillRect(0, 0, targetWidth, targetHeight) + } + + // 绘制图片 + ctx.drawImage( + img, + sourceX, sourceY, sourceWidth, sourceHeight, + 0, 0, targetWidth, targetHeight + ) + + // 转换为 Blob + canvas.toBlob( + (blob) => { + // 清理对象 URL + URL.revokeObjectURL(objectUrl) + if (blob) { + resolve(blob) + } else { + reject(new Error('Failed to create blob')) + } + }, + opts.outputFormat, + opts.quality + ) + } catch (error) { + URL.revokeObjectURL(objectUrl) + reject(error) + } + } + + img.onerror = () => { + URL.revokeObjectURL(objectUrl) + reject(new Error('Failed to load image')) + } + + // 加载图片 + img.src = objectUrl + }) +} + +/** + * 压缩图片文件,返回新的 File 对象 + */ +export async function compressImageFile( + file: File, + options: ResizeOptions = {} +): Promise { + const blob = await resizeImageWithCanvas(file, options) + const opts = { ...DEFAULT_OPTIONS, ...options } + + // 确定文件扩展名 + let ext = 'jpg' + if (opts.outputFormat === 'image/png') { + ext = 'png' + } else if (opts.outputFormat === 'image/webp') { + ext = 'webp' + } + + // 创建新的 File 对象,保持原始文件名但更新扩展名 + const fileName = file.name.replace(/\.[^.]+$/, '') + '.' + ext + return new File([blob], fileName, { + type: opts.outputFormat, + lastModified: Date.now() + }) +} + diff --git a/apps/jingrow/frontend/src/views/HomePage.vue b/apps/jingrow/frontend/src/views/HomePage.vue index 34bc595..d4d8455 100644 --- a/apps/jingrow/frontend/src/views/HomePage.vue +++ b/apps/jingrow/frontend/src/views/HomePage.vue @@ -7,6 +7,7 @@ import { t } from '@/shared/i18n' import { useAuthStore } from '@/shared/stores/auth' import { signupApi } from '@/shared/api/auth' import UserMenu from '@/shared/components/UserMenu.vue' +import { compressImageFile } from '@/shared/utils/imageResize' import axios from 'axios' const message = useMessage() @@ -285,7 +286,7 @@ const handleFileSelect = (e: Event) => { } } -const processFile = (file: File) => { +const processFile = async (file: File) => { const validTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp'] if (!validTypes.includes(file.type)) { message.warning('不支持的图片格式,请使用 JPG、PNG 或 WebP') @@ -298,17 +299,29 @@ const processFile = (file: File) => { return } - uploadedImage.value = file - resultImage.value = '' - splitPosition.value = 0 - currentHistoryIndex.value = -1 - - const reader = new FileReader() - reader.onload = (e) => { - uploadedImageUrl.value = e.target?.result as string - handleRemoveBackground() + try { + const compressedFile = await compressImageFile(file, { + maxWidth: 1024, + maxHeight: 1024, + quality: 0.92, + mode: 'contain' + }) + + uploadedImage.value = compressedFile + resultImage.value = '' + splitPosition.value = 0 + currentHistoryIndex.value = -1 + + const reader = new FileReader() + reader.onload = (e) => { + uploadedImageUrl.value = e.target?.result as string + handleRemoveBackground() + } + reader.readAsDataURL(compressedFile) + } catch (error) { + console.error('图片压缩失败:', error) + message.error('图片处理失败,请重试') } - reader.readAsDataURL(file) } const handleRemoveBackground = async () => { @@ -621,12 +634,26 @@ const handleUrlSubmit = async () => { return } - const file = new File([blob], 'image-from-url', { type: blob.type }) - uploadedImage.value = file + const originalFile = new File([blob], 'image-from-url', { type: blob.type }) - uploadedImageUrl.value = URL.createObjectURL(blob) - - await handleRemoveBackground() + // 压缩图片到 1024x1024 + try { + const compressedFile = await compressImageFile(originalFile, { + maxWidth: 1024, + maxHeight: 1024, + quality: 0.92, + mode: 'contain' // 等比缩放 + }) + + uploadedImage.value = compressedFile + uploadedImageUrl.value = URL.createObjectURL(compressedFile) + + await handleRemoveBackground() + } catch (error) { + console.error('图片压缩失败:', error) + message.error('图片处理失败,请重试') + processing.value = false + } } catch (error: any) { console.error('加载图片URL失败:', error) let errorMessage = '加载图片URL失败'