1. 管理员管理模块拆分

2. 图片上传自动压缩(大于850kb)
3. 省市区选择组件重构,支持搜索
This commit is contained in:
李琦
2026-05-27 09:34:08 +08:00
parent 68fd7b0045
commit 402da17e3d
56 changed files with 4188 additions and 13664 deletions

View 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 };
}

View 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);
});
}

View 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);
}

View 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;
}