- Expand batch upload list area and optimize preview image layout - Improve upload speed tracking and display (server-side & client-side) - Reduce network detection log noise - Optimize Docker build process (caching, env validation) - Add shadow environment deployment support
142 lines
3.7 KiB
TypeScript
142 lines
3.7 KiB
TypeScript
/**
|
||
* 前端图片压缩工具
|
||
* 在浏览器中压缩图片,减少上传数据量,提高处理速度
|
||
*/
|
||
|
||
interface CompressOptions {
|
||
maxWidth?: number
|
||
maxHeight?: number
|
||
quality?: number
|
||
format?: 'image/jpeg' | 'image/webp' | 'image/png'
|
||
}
|
||
|
||
/**
|
||
* 压缩图片文件
|
||
* @param file 原始图片文件
|
||
* @param options 压缩选项
|
||
* @returns 压缩后的 Blob
|
||
*/
|
||
export async function compressImage(
|
||
file: File,
|
||
options: CompressOptions = {}
|
||
): Promise<Blob> {
|
||
const {
|
||
maxWidth = 800,
|
||
maxHeight = 2000,
|
||
quality = 0.85,
|
||
format = 'image/jpeg',
|
||
} = 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 上下文'))
|
||
return
|
||
}
|
||
|
||
img.onload = () => {
|
||
try {
|
||
// 计算新尺寸(保持比例)
|
||
let { width, height } = img
|
||
|
||
// 如果图片尺寸已经在限制内,且文件不大,直接返回
|
||
if (width <= maxWidth && height <= maxHeight && file.size < 500 * 1024) {
|
||
// ⚠️ 内存泄漏修复:释放 Object URL(R20)
|
||
URL.revokeObjectURL(img.src)
|
||
resolve(file)
|
||
return
|
||
}
|
||
|
||
// 计算缩放比例
|
||
let scale = 1
|
||
if (width > maxWidth) {
|
||
scale = Math.min(scale, maxWidth / width)
|
||
}
|
||
if (height > maxHeight) {
|
||
scale = Math.min(scale, maxHeight / height)
|
||
}
|
||
|
||
const newWidth = Math.round(width * scale)
|
||
const newHeight = Math.round(height * scale)
|
||
|
||
// 设置 canvas 尺寸
|
||
canvas.width = newWidth
|
||
canvas.height = newHeight
|
||
|
||
// 绘制图片(使用高质量缩放)
|
||
ctx.imageSmoothingEnabled = true
|
||
ctx.imageSmoothingQuality = 'high'
|
||
ctx.drawImage(img, 0, 0, newWidth, newHeight)
|
||
|
||
// 转换为 Blob
|
||
canvas.toBlob(
|
||
(blob) => {
|
||
// ⚠️ 内存泄漏修复:处理完成后释放 Object URL(R20)
|
||
URL.revokeObjectURL(img.src)
|
||
|
||
if (blob) {
|
||
console.log(
|
||
`[前端压缩] ${file.name}: ${(file.size / 1024).toFixed(2)}KB -> ${(blob.size / 1024).toFixed(2)}KB (${width}x${height} -> ${newWidth}x${newHeight})`
|
||
)
|
||
resolve(blob)
|
||
} else {
|
||
reject(new Error('图片压缩失败'))
|
||
}
|
||
},
|
||
format,
|
||
quality
|
||
)
|
||
} catch (error) {
|
||
// ⚠️ 内存泄漏修复:错误时也要释放 Object URL(R20)
|
||
URL.revokeObjectURL(img.src)
|
||
reject(error)
|
||
}
|
||
}
|
||
|
||
img.onerror = () => {
|
||
// ⚠️ 内存泄漏修复:加载失败时释放 Object URL(R20)
|
||
URL.revokeObjectURL(img.src)
|
||
reject(new Error('图片加载失败'))
|
||
}
|
||
|
||
// 加载图片
|
||
img.src = URL.createObjectURL(file)
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 将 Blob 转换为 File 对象
|
||
*/
|
||
export function blobToFile(blob: Blob, fileName: string): File {
|
||
return new File([blob], fileName, { type: blob.type })
|
||
}
|
||
|
||
/**
|
||
* 批量压缩图片
|
||
*/
|
||
export async function compressImages(
|
||
files: File[],
|
||
options: CompressOptions = {}
|
||
): Promise<File[]> {
|
||
const compressedFiles: File[] = []
|
||
|
||
for (let i = 0; i < files.length; i++) {
|
||
const file = files[i]
|
||
try {
|
||
const compressedBlob = await compressImage(file, options)
|
||
const compressedFile = blobToFile(compressedBlob, file.name)
|
||
compressedFiles.push(compressedFile)
|
||
} catch (error) {
|
||
console.error(`[前端压缩] 压缩 ${file.name} 失败:`, error)
|
||
// 压缩失败时使用原文件
|
||
compressedFiles.push(file)
|
||
}
|
||
}
|
||
|
||
return compressedFiles
|
||
}
|
||
|