diff --git a/package.json b/package.json index 9435b55..3e24c08 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "scripts": { "dev": "NODE_OPTIONS=--max-old-space-size=4096 vite", "serve": "pnpm dev", - "build": "rimraf dist && NODE_OPTIONS=--max-old-space-size=8192 vite build", + "build": "rimraf dist && NODE_OPTIONS=--max-old-space-size=8192 vite build && npm run zip", "build:staging": "rimraf dist && vite build --mode staging", "report": "rimraf dist && vite build", "preview": "vite preview", @@ -21,7 +21,8 @@ "lint:pretty": "pretty-quick --staged", "lint": "pnpm lint:eslint && pnpm lint:prettier && pnpm lint:stylelint", "prepare": "husky install", - "preinstall": "npx only-allow pnpm" + "preinstall": "npx only-allow pnpm", + "zip": "7z a -tzip dist/shop-front-end-%date:~0,4%%date:~5,2%%date:~8,2%-%time:~0,2%%time:~3,2%%time:~6,2%.zip .\\dist\\* -xr!*.zip" }, "browserslist": [ "> 1%", diff --git a/src/components/ReCropper/src/index.tsx b/src/components/ReCropper/src/index.tsx index 62dd2fa..181a39c 100644 --- a/src/components/ReCropper/src/index.tsx +++ b/src/components/ReCropper/src/index.tsx @@ -6,6 +6,7 @@ import { useResizeObserver } from "@vueuse/core"; import { longpress } from "@/directives/longpress"; import { useTippy, directive as tippy } from "vue-tippy"; import { delay, debounce, isArray, downloadByBase64 } from "@pureadmin/utils"; +import { beforeImageUpload } from "@/utils/imageCompressor"; import { ref, unref, @@ -218,9 +219,19 @@ export default defineComponent({ : cropper.value?.[event]?.(arg); } - function beforeUpload(file) { + async function beforeUpload(file: File) { + // 压缩图片 + const compressedFile = await beforeImageUpload(file, { + quality: 0.7, + maxWidth: 980, + maxHeight: 980 + }); + + // 使用压缩后的文件或原文件 + const finalFile = (compressedFile as File) || file; + const reader = new FileReader(); - reader.readAsDataURL(file); + reader.readAsDataURL(finalFile); inSrc.value = ""; reader.onload = e => { inSrc.value = e.target?.result as string; diff --git a/src/utils/imageCompressor.ts b/src/utils/imageCompressor.ts new file mode 100644 index 0000000..5dbc8ae --- /dev/null +++ b/src/utils/imageCompressor.ts @@ -0,0 +1,235 @@ +/** + * 图片压缩工具类 + * 基于 CompressorJS 实现图片上传前压缩 + */ +import Compressor from 'compressorjs'; + +export interface CompressorOptions { + /** 压缩质量,0-1 之间,值越小文件越小但质量越差 */ + quality?: number; + /** 最大宽度(像素) */ + maxWidth?: number; + /** 最大高度(像素) */ + maxHeight?: number; + /** 最小宽度 */ + minWidth?: number; + /** 最小高度 */ + minHeight?: number; + /** 固定宽度 */ + width?: number; + /** 固定高度 */ + height?: number; + /** 调整大小模式:'none' | 'contain' | 'cover' */ + resize?: 'none' | 'contain' | 'cover'; + /** 是否裁剪 */ + crop?: boolean; + /** 旋转角度(度) */ + rotate?: number; + /** 模糊半径 */ + blur?: number; + /** 是否转为灰度图 */ + grayscale?: boolean; + /** 输出类型:'blob' | 'base64' | 'datauristring' */ + output?: 'blob' | 'base64' | 'datauristring'; +} + +export interface BeforeUploadResult { + file: File; + compressed: boolean; +} + +/** + * 检查文件是否为图片 + * @param file 上传的文件 + */ +export const isImage = (file: File): boolean => { + return /^image\//.test(file.type); +}; + +/** + * 默认压缩配置 + */ +const DEFAULT_COMPRESS_OPTIONS: Required = { + quality: 0.8, + maxWidth: 1280, + maxHeight: 1280, + minWidth: 0, + minHeight: 0, + width: Infinity, + height: Infinity, + resize: 'cover', + crop: false, + rotate: 0, + blur: 0, + grayscale: false, + output: 'blob' +}; + +/** + * 压缩单个图片文件 + * @param file 原始文件 + * @param options 压缩配置 + * @returns 压缩后的文件对象 + */ +export const compressImage = ( + file: File, + options: CompressorOptions = {} +): Promise => { + return new Promise((resolve, reject) => { + // 合并默认配置 + const mergedOptions = { ...DEFAULT_COMPRESS_OPTIONS, ...options }; + + new Compressor(file, { + ...mergedOptions, + success(result) { + try { + // 转换为 File 对象,保持原始文件名 + const targetType = file.type.includes('png') ? 'image/png' : 'image/jpeg'; + const compressedFile = new File([result], file.name, { + type: targetType, + lastModified: Date.now() + }); + resolve(compressedFile); + } catch (error) { + reject(error); + } + }, + error(err) { + reject(err); + } + }); + }); +}; + +/** + * 批量压缩图片文件 + * @param files 文件数组 + * @param options 压缩配置 + * @returns 压缩后的文件数组 + */ +export const compressImages = async ( + files: File[], + options: CompressorOptions = {} +): Promise => { + const promises = files.map(file => { + if (isImage(file)) { + return compressImage(file, options); + } + return Promise.resolve(file); + }); + + return Promise.all(promises); +}; + +/** + * 带错误处理的图片压缩 + * 压缩失败时返回原文件 + * @param file 原始文件 + * @param options 压缩配置 + * @returns { file, compressed } 压缩结果 + */ +export const compressImageSafe = async ( + file: File, + options: CompressorOptions = {} +): Promise => { + try { + // 仅压缩图片文件 + if (!isImage(file)) { + return { file, compressed: false }; + } + + const compressedFile = await compressImage(file, options); + return { + file: compressedFile, + compressed: true + }; + } catch (error) { + console.error('图片压缩失败,使用原文件:', error); + return { file, compressed: false }; + } +}; + +/** + * Element Plus :before-upload 钩子函数 + * 自动压缩图片并返回压缩后的文件 + * @param rawFile 原始文件 + * @param options 压缩配置 + * @returns Promise 返回文件或布尔值 + */ +export const beforeImageUpload = ( + rawFile: File, + options: CompressorOptions = {} +): Promise => { + // 首先进行基本验证 + const isValidImage = isImage(rawFile); + const isValidType = ['image/jpeg', 'image/png', 'image/webp'].includes(rawFile.type); + const isValidSize = rawFile.size < 50 * 1024 * 1024; // 50MB + + if (!isValidImage || !isValidType) { + return Promise.resolve(false); + } + + if (!isValidSize) { + return Promise.resolve(false); + } + + // 压缩图片 + return compressImageSafe(rawFile, options).then(result => { + return result.file; + }); +}; + +/** + * Element Plus :before-upload 钩子函数(严格模式) + * 压缩失败时拒绝上传 + * @param rawFile 原始文件 + * @param options 压缩配置 + * @returns Promise 返回文件或布尔值 + */ +export const beforeImageUploadStrict = ( + rawFile: File, + options: CompressorOptions = {} +): Promise => { + // 首先进行基本验证 + if (!['image/jpeg', 'image/png', 'image/webp'].includes(rawFile.type)) { + return Promise.resolve(false); + } + + if (rawFile.size > 50 * 1024 * 1024) { + return Promise.resolve(false); + } + + // 压缩图片,失败则拒绝上传 + return compressImage(rawFile, options); +}; + +/** + * 图片质量级别枚举 + */ +export enum ImageQuality { + /** 高质量(适合产品图、头像) */ + HIGH = 0.9, + /** 中等质量(适合一般场景) */ + MEDIUM = 0.8, + /** 高压缩(适合缩略图、证明材料) */ + LOW = 0.6 +} + +/** + * 根据质量级别获取压缩配置 + * @param quality 质量级别 + * @param maxWidth 最大宽度 + * @param maxHeight 最大高度 + * @returns 压缩配置 + */ +export const getQualityConfig = ( + quality: ImageQuality = ImageQuality.MEDIUM, + maxWidth: number = 1280, + maxHeight: number = 1280 +): CompressorOptions => { + return { + quality, + maxWidth, + maxHeight + }; +}; diff --git a/src/views/cabinet/shop/shop-form-modal.vue b/src/views/cabinet/shop/shop-form-modal.vue index 0b3c144..f083406 100644 --- a/src/views/cabinet/shop/shop-form-modal.vue +++ b/src/views/cabinet/shop/shop-form-modal.vue @@ -6,6 +6,7 @@ import { addShop, updateShop, deleteShop, ShopDTO, UpdateShopCommand, AddShopCom import { useWxStore } from "@/store/modules/wx"; import Upload from "@iconify-icons/ep/upload"; import { useSysConfigStoreHook } from "@/store/modules/sysConfig"; +import { beforeImageUpload } from "@/utils/imageCompressor"; import ReQrcode from "@/components/ReQrcode"; import { copyTextToClipboard } from "@pureadmin/utils"; const { VITE_APP_BASE_API } = import.meta.env; @@ -105,16 +106,13 @@ const handleAvatarSuccess = (response) => { formData.value.coverImg = response.data.url; }; -const beforeAvatarUpload = (rawFile) => { - if (!['image/jpeg', 'image/png'].includes(rawFile.type)) { - ElMessage.error('封面图必须是 JPG/PNG 格式!'); - return false; - } - if (rawFile.size > 50 * 1024 * 1024) { - ElMessage.error('封面图大小不能超过 50MB!'); - return false; - } - return true; +const beforeAvatarUpload = async (rawFile: File) => { + const result = await beforeImageUpload(rawFile, { + quality: 0.7, + maxWidth: 980, + maxHeight: 980 + }); + return result; }; const currentPaymentMethods = computed(() => { diff --git a/src/views/shop/goods/goods-edit-modal.vue b/src/views/shop/goods/goods-edit-modal.vue index d4012fa..fa5be30 100644 --- a/src/views/shop/goods/goods-edit-modal.vue +++ b/src/views/shop/goods/goods-edit-modal.vue @@ -4,9 +4,11 @@ import { ElMessage, FormRules } from "element-plus"; import { useRenderIcon } from "@/components/ReIcon/src/hooks"; import { GoodsDTO, updateGoodsApi, addGoodsApi, deleteGoodsApi } from "@/api/shop/goods"; import { CategoryDTO, getCategoryAllApi } from "@/api/shop/category"; +import { beforeImageUpload } from "@/utils/imageCompressor"; import Confirm from "@iconify-icons/ep/check"; import Upload from "@iconify-icons/ep/upload"; import Delete from "@iconify-icons/ep/delete"; +import { useWxStore } from "@/store/modules/wx"; const { VITE_APP_BASE_API } = import.meta.env; const props = defineProps({ @@ -22,6 +24,7 @@ const props = defineProps({ const emit = defineEmits(["update:visible", "refresh"]); +const wxStore = useWxStore(); const formRef = ref(); const isEdit = ref(false); const formData = reactive({ @@ -34,7 +37,9 @@ const formData = reactive({ categoryId: 0, goodsDetail: "", coverImg: "", - usageInstruction: "" + usageInstruction: "", + belongType: 0, + corpid: wxStore.corpid }); const rules = reactive({ @@ -106,16 +111,13 @@ const handleAvatarSuccess = (response, uploadFile) => { formData.coverImg = response.data.url; }; -const beforeAvatarUpload = (rawFile) => { - if (!['image/jpeg', 'image/png'].includes(rawFile.type)) { - ElMessage.error('封面图必须是 JPG/PNG 格式!') - return false - } - if (rawFile.size > 50 * 1024 * 1024) { - ElMessage.error('封面图大小不能超过 50MB!') - return false - } - return true +const beforeAvatarUpload = async (rawFile: File) => { + const result = await beforeImageUpload(rawFile, { + quality: 0.7, + maxWidth: 980, + maxHeight: 980 + }); + return result; }; watch( diff --git a/src/views/shop/goods/goods-form-modal.vue b/src/views/shop/goods/goods-form-modal.vue index 2dcce3d..8c50b68 100644 --- a/src/views/shop/goods/goods-form-modal.vue +++ b/src/views/shop/goods/goods-form-modal.vue @@ -4,6 +4,7 @@ import { ElMessage, FormRules } from "element-plus"; import { useRenderIcon } from "@/components/ReIcon/src/hooks"; import { addGoodsApi, GoodsDTO } from "@/api/shop/goods"; import { CategoryDTO, getCategoryAllApi } from "@/api/shop/category"; +import { beforeImageUpload } from "@/utils/imageCompressor"; import Confirm from "@iconify-icons/ep/check"; import Upload from "@iconify-icons/ep/upload"; import { useWxStore } from "@/store/modules/wx"; @@ -87,16 +88,13 @@ const handleAvatarSuccess = (response, uploadFile) => { formData.coverImg = response.data.url; }; -const beforeAvatarUpload = (rawFile) => { - if (!['image/jpeg', 'image/png'].includes(rawFile.type)) { - ElMessage.error('封面图必须是 JPG/PNG 格式!') - return false - } - if (rawFile.size > 50 * 1024 * 1024) { - ElMessage.error('封面图大小不能超过 50MB!') - return false - } - return true +const beforeAvatarUpload = async (rawFile: File) => { + const result = await beforeImageUpload(rawFile, { + quality: 0.7, + maxWidth: 980, + maxHeight: 980 + }); + return result; };