1. 管理员管理模块拆分
2. 图片上传自动压缩(大于850kb) 3. 省市区选择组件重构,支持搜索
This commit is contained in:
149
apps/web-antd/src/utils/image-compress.ts
Normal file
149
apps/web-antd/src/utils/image-compress.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
export const IMAGE_COMPRESS_THRESHOLD = 0.8 * 1024 * 1024;
|
||||
|
||||
export interface CompressImageOptions {
|
||||
maxBytes?: number;
|
||||
maxWidth?: number;
|
||||
minQuality?: number;
|
||||
initialQuality?: number;
|
||||
onProgress?: (message: string) => void;
|
||||
}
|
||||
|
||||
export interface CompressImageResult {
|
||||
file: File;
|
||||
skipped: boolean;
|
||||
}
|
||||
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
const value = bytes / k ** i;
|
||||
return `${Number.parseFloat(value.toFixed(2))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
/** 压缩节省比例文案,如「约节省 42%」 */
|
||||
export function formatSavingsPercent(
|
||||
originalBytes: number,
|
||||
compressedBytes: number,
|
||||
): string {
|
||||
if (originalBytes <= 0 || compressedBytes >= originalBytes) {
|
||||
return '';
|
||||
}
|
||||
const percent = Math.round(
|
||||
((originalBytes - compressedBytes) / originalBytes) * 100,
|
||||
);
|
||||
return percent > 0 ? `(约节省 ${percent}%)` : '';
|
||||
}
|
||||
|
||||
function loadImageFromFile(file: File): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = URL.createObjectURL(file);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(img);
|
||||
};
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
reject(new Error('图片加载失败'));
|
||||
};
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
function canvasToBlob(
|
||||
canvas: HTMLCanvasElement,
|
||||
type: string,
|
||||
quality: number,
|
||||
): Promise<Blob> {
|
||||
return new Promise((resolve, reject) => {
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (blob) {
|
||||
resolve(blob);
|
||||
} else {
|
||||
reject(new Error('图片压缩失败'));
|
||||
}
|
||||
},
|
||||
type,
|
||||
quality,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function getOutputType(file: File): string {
|
||||
if (file.type === 'image/png' || file.type === 'image/webp') {
|
||||
return file.type;
|
||||
}
|
||||
return 'image/jpeg';
|
||||
}
|
||||
|
||||
function buildCompressedFile(
|
||||
blob: Blob,
|
||||
originalFile: File,
|
||||
outputType: string,
|
||||
): File {
|
||||
const ext = outputType === 'image/png' ? '.png' : '.jpg';
|
||||
const baseName = originalFile.name.replace(/\.[^.]+$/, '') || 'image';
|
||||
return new File([blob], `${baseName}${ext}`, {
|
||||
type: outputType,
|
||||
lastModified: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
export async function compressImageFile(
|
||||
file: File,
|
||||
options: CompressImageOptions = {},
|
||||
): Promise<CompressImageResult> {
|
||||
const {
|
||||
maxBytes = IMAGE_COMPRESS_THRESHOLD,
|
||||
maxWidth = 1920,
|
||||
minQuality = 0.5,
|
||||
initialQuality = 0.85,
|
||||
onProgress,
|
||||
} = options;
|
||||
|
||||
if (file.type === 'image/gif') {
|
||||
return { file, skipped: true };
|
||||
}
|
||||
|
||||
onProgress?.('正在加载图片…');
|
||||
|
||||
const img = await loadImageFromFile(file);
|
||||
let width = img.naturalWidth;
|
||||
let height = img.naturalHeight;
|
||||
|
||||
if (width > maxWidth || height > maxWidth) {
|
||||
if (width >= height) {
|
||||
height = Math.round((height * maxWidth) / width);
|
||||
width = maxWidth;
|
||||
} else {
|
||||
width = Math.round((width * maxWidth) / height);
|
||||
height = maxWidth;
|
||||
}
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
throw new Error('无法创建 Canvas 上下文');
|
||||
}
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
|
||||
const outputType = getOutputType(file);
|
||||
let quality = initialQuality;
|
||||
onProgress?.(`正在压缩(质量 ${Math.round(quality * 100)}%)…`);
|
||||
let blob = await canvasToBlob(canvas, outputType, quality);
|
||||
|
||||
while (blob.size > maxBytes && quality > minQuality) {
|
||||
quality = Math.max(minQuality, quality - 0.1);
|
||||
onProgress?.(`正在压缩(质量 ${Math.round(quality * 100)}%)…`);
|
||||
blob = await canvasToBlob(canvas, outputType, quality);
|
||||
}
|
||||
|
||||
const compressedFile = buildCompressedFile(blob, file, outputType);
|
||||
return { file: compressedFile, skipped: false };
|
||||
}
|
||||
45
apps/web-antd/src/utils/open-image-compress-modal.ts
Normal file
45
apps/web-antd/src/utils/open-image-compress-modal.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { createApp, h } from 'vue';
|
||||
|
||||
import ImageCompressModal, {
|
||||
type ImageCompressModalResult,
|
||||
} from '#/components/modal/ImageCompressModal.vue';
|
||||
|
||||
import type { PrepareImageUploadMeta } from '#/utils/prepare-image-upload';
|
||||
|
||||
export interface OpenImageCompressModalResult {
|
||||
file: File | null;
|
||||
meta?: PrepareImageUploadMeta;
|
||||
}
|
||||
|
||||
export function openImageCompressModal(
|
||||
file: File,
|
||||
): Promise<OpenImageCompressModalResult> {
|
||||
return new Promise((resolve) => {
|
||||
const container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
|
||||
const app = createApp({
|
||||
render() {
|
||||
return h(ImageCompressModal, {
|
||||
file,
|
||||
onResolve: (result: ImageCompressModalResult) => {
|
||||
app.unmount();
|
||||
container.remove();
|
||||
|
||||
if (result.choice === 'cancel' || !result.file) {
|
||||
resolve({ file: null });
|
||||
return;
|
||||
}
|
||||
|
||||
resolve({
|
||||
file: result.file,
|
||||
meta: result.meta,
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
app.mount(container);
|
||||
});
|
||||
}
|
||||
24
apps/web-antd/src/utils/prepare-image-upload.ts
Normal file
24
apps/web-antd/src/utils/prepare-image-upload.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { IMAGE_COMPRESS_THRESHOLD } from '#/utils/image-compress';
|
||||
import { openImageCompressModal } from '#/utils/open-image-compress-modal';
|
||||
|
||||
export interface PrepareImageUploadMeta {
|
||||
originalSize: number;
|
||||
compressedSize?: number;
|
||||
usedCompress: boolean;
|
||||
}
|
||||
|
||||
export async function prepareImageForUpload(
|
||||
file: File,
|
||||
): Promise<{ file: File | null; meta?: PrepareImageUploadMeta }> {
|
||||
if (file.size <= IMAGE_COMPRESS_THRESHOLD) {
|
||||
return {
|
||||
file,
|
||||
meta: {
|
||||
originalSize: file.size,
|
||||
usedCompress: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return openImageCompressModal(file);
|
||||
}
|
||||
23
apps/web-antd/src/utils/use-image-upload-pending.ts
Normal file
23
apps/web-antd/src/utils/use-image-upload-pending.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Upload } from 'ant-design-vue';
|
||||
|
||||
import { prepareImageForUpload } from '#/utils/prepare-image-upload';
|
||||
|
||||
const pendingFiles = new Map<string, File>();
|
||||
|
||||
export async function beforeImageUpload(file: File) {
|
||||
const { file: prepared } = await prepareImageForUpload(file);
|
||||
if (!prepared) {
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
if (prepared !== file) {
|
||||
pendingFiles.set(file.uid, prepared);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function resolveUploadFile(file: File & { uid?: string }): File {
|
||||
const uid = file.uid ?? '';
|
||||
const actualFile = pendingFiles.get(uid) ?? file;
|
||||
pendingFiles.delete(uid);
|
||||
return actualFile;
|
||||
}
|
||||
Reference in New Issue
Block a user