From e9c8bdd38555dd28ebaa4ae412e8a5344e5247a6 Mon Sep 17 00:00:00 2001 From: dzq Date: Tue, 11 Nov 2025 10:17:34 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E5=95=86=E5=93=81=E7=AE=A1=E7=90=86):=20?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=95=86=E5=93=81=E5=9B=BE=E7=89=87=E6=89=B9?= =?UTF-8?q?=E9=87=8F=E5=8E=8B=E7=BC=A9=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增一键压缩所有商品图片功能,包括: 1. 分页获取所有商品数据 2. 图片压缩和上传服务 3. 实时进度显示和结果统计 4. 相关文档和使用说明 --- docs/商品图片压缩功能使用说明.md | 173 ++++++++++++++++++ src/api/shop/goods.ts | 30 ++++ src/utils/goodsImageCompressor.ts | 288 ++++++++++++++++++++++++++++++ src/views/shop/goods/index.vue | 165 +++++++++++++++++ vite.config.ts | 7 +- 5 files changed, 662 insertions(+), 1 deletion(-) create mode 100644 docs/商品图片压缩功能使用说明.md create mode 100644 src/utils/goodsImageCompressor.ts diff --git a/docs/商品图片压缩功能使用说明.md b/docs/商品图片压缩功能使用说明.md new file mode 100644 index 0000000..34b4df3 --- /dev/null +++ b/docs/商品图片压缩功能使用说明.md @@ -0,0 +1,173 @@ +# 商品图片压缩功能使用说明 + +## 功能概述 + +一键压缩数据库全部商品图片功能,可以批量压缩所有商品的封面图,提高页面加载速度,减少服务器存储空间。 + +## 文件结构 + +``` +src/ +├── utils/ +│ ├── imageCompressor.ts # 基础图片压缩工具 +│ └── goodsImageCompressor.ts # 商品图片压缩服务 +├── api/shop/ +│ └── goods.ts # 商品API(已添加获取所有商品方法) +└── views/shop/goods/ + └── index.vue # 商品管理页面(已添加压缩按钮和功能) +``` + +## 功能特性 + +### 1. 批量获取商品 +- 自动分页获取所有商品数据 +- 每页获取100条记录 +- 智能判断数据是否获取完成 + +### 2. 智能压缩 +- 支持自定义压缩质量(高质量/中等质量/高压缩) +- 可配置最大宽度和高度(默认980x980) +- 仅压缩图片文件,非图片文件自动跳过 +- 压缩失败时使用原文件 + +### 3. 实时进度显示 +- 显示总商品数、当前处理进度 +- 显示成功数、失败数、跳过数 +- 实时进度条和状态提示 +- 每个商品的处理状态实时反馈 + +### 4. 自动上传更新 +- 压缩后自动上传到服务器 +- 自动更新商品信息的封面图URL +- 保留原图片作为备份 + +## 使用方法 + +### 在商品管理页面操作 + +1. 进入 **店铺管理 > 商品管理** 页面 +2. 点击顶部工具栏的 **"一键压缩图片"** 按钮 +3. 确认操作提示(注意:此操作可能需要较长时间) +4. 等待压缩完成,查看进度和结果 + +### 压缩配置 + +默认压缩配置: +- 质量:中等质量(0.8) +- 最大宽度:980像素 +- 最大高度:980像素 + +如需修改配置,可编辑 `src/views/shop/goods/index.vue` 中的 `handleCompressAllImages` 函数: + +```typescript +const results = await compressAllGoodsImages({ + quality: ImageQuality.HIGH, // 可选:HIGH | MEDIUM | LOW + maxWidth: 1280, // 可自定义最大宽度 + maxHeight: 1280, // 可自定义最大高度 + onProgress: (progress) => { + compressProgress.value = progress; + } +}); +``` + +## 质量级别说明 + +| 级别 | 值 | 适用场景 | +|------|----|----------| +| 高质量 | 0.9 | 产品图、头像等需要高清晰度的图片 | +| 中等质量 | 0.8 | 一般场景(推荐) | +| 高压缩 | 0.6 | 缩略图、证明材料等 | + +## 进度信息说明 + +- **总商品数**:需要处理的商品总数(有封面图) +- **当前处理**:正在处理第几个商品 +- **成功**:压缩并更新成功的商品数 +- **失败**:压缩或上传失败的商品数 +- **跳过**:没有封面图或无需处理的商品数 + +## 注意事项 + +### ⚠️ 重要提示 + +1. **备份建议**:操作前建议先备份商品数据和图片文件 +2. **网络要求**:确保网络稳定,压缩过程需要多次上传 +3. **时间预估**:根据商品数量,可能需要几分钟到几十分钟 +4. **权限要求**:需要 `shop:goods:write` 权限才能执行压缩 + +### 处理逻辑 + +1. 扫描所有商品,筛选出有封面图的商品 +2. 逐个下载商品封面图 +3. 压缩图片(质量、尺寸调整) +4. 上传压缩后的图片到服务器 +5. 更新商品信息中的封面图URL +6. 记录处理结果和错误信息 + +### 错误处理 + +- 单个商品压缩失败不会影响其他商品 +- 失败信息会实时显示在控制台 +- 可以在浏览器开发者工具中查看详细错误 +- 支持断点续传(刷新页面后可重新执行) + +## 开发者指南 + +### 自定义压缩参数 + +编辑 `src/utils/goodsImageCompressor.ts` 文件中的 `compressGoodsImage` 函数,可以自定义更多压缩选项: + +```typescript +const { file: compressedFile, compressed } = await compressImageSafe(file, { + quality: 0.8, // 压缩质量 0-1 + maxWidth: 980, // 最大宽度 + maxHeight: 980, // 最大高度 + resize: 'cover', // 调整大小模式 + crop: false, // 是否裁剪 + rotate: 0, // 旋转角度 + blur: 0, // 模糊半径 + grayscale: false, // 是否转为灰度图 + output: 'blob' // 输出类型 +}); +``` + +### 调试模式 + +压缩过程中的详细信息会输出到浏览器控制台,包括: +- 商品处理进度 +- 成功/失败的商品列表 +- 错误详情和原因 +- 压缩前后的文件大小对比 + +### 扩展功能 + +可以基于此功能扩展以下功能: +1. 批量压缩商品详情页图片 +2. 添加图片格式转换(如转为WebP) +3. 添加图片水印功能 +4. 添加压缩前后对比功能 + +## 故障排除 + +### 常见问题 + +**Q: 压缩过程中出现网络错误怎么办?** +A: 单个图片失败不会影响整体流程,可以重新点击按钮对失败的商品进行二次压缩。 + +**Q: 压缩后图片模糊怎么办?** +A: 调整压缩质量为 `ImageQuality.HIGH`,或增大 `maxWidth/maxHeight` 参数。 + +**Q: 可以压缩指定分类的商品吗?** +A: 目前是压缩所有商品,如需指定分类,可以修改 `getAllGoods` 函数添加分类过滤条件。 + +**Q: 压缩后的图片存储在哪里?** +A: 压缩后的图片通过 `/file/upload` 接口上传到服务器,存储路径由后端配置决定。 + +## 更新日志 + +### v1.0.0 +- 初始版本 +- 支持一键压缩所有商品图片 +- 实时进度显示 +- 自动上传和更新功能 +- 错误处理和重试机制 diff --git a/src/api/shop/goods.ts b/src/api/shop/goods.ts index 0ab6d12..ab82d35 100644 --- a/src/api/shop/goods.ts +++ b/src/api/shop/goods.ts @@ -134,4 +134,34 @@ export const exportGoodsExcelApi = (params: GoodsQuery, fileName: string) => { return http.download("/shop/goods/excel", fileName, { params }); +}; + +/** 获取所有商品(分页获取全部) */ +export const getAllGoodsApi = async (corpid: string): Promise => { + const allGoods: GoodsDTO[] = []; + let pageNum = 1; + const pageSize = 100; + + while (true) { + const { data } = await getGoodsListApi({ + corpid, + pageNum, + pageSize + }); + + if (data.rows.length === 0) { + break; + } + + allGoods.push(...data.rows); + + // 如果当前页数据小于pageSize,说明已经获取完所有数据 + if (data.rows.length < pageSize) { + break; + } + + pageNum++; + } + + return allGoods; }; \ No newline at end of file diff --git a/src/utils/goodsImageCompressor.ts b/src/utils/goodsImageCompressor.ts new file mode 100644 index 0000000..9948e0d --- /dev/null +++ b/src/utils/goodsImageCompressor.ts @@ -0,0 +1,288 @@ +/** + * 商品图片压缩和上传服务 + * 批量压缩和上传商品图片 + */ +import { ElMessage } from "element-plus"; +import { compressImageSafe, ImageQuality } from "./imageCompressor"; +import { getGoodsListApi, updateGoodsApi, GoodsDTO } from "@/api/shop/goods"; +import { useWxStore } from "@/store/modules/wx"; + +const { VITE_APP_BASE_API } = import.meta.env; + +export interface CompressProgress { + total: number; // 总数 + current: number; // 当前处理数 + success: number; // 成功数 + failed: number; // 失败数 + skipped: number; // 跳过数 +} + +export interface CompressResult { + goodsId: number; + goodsName: string; + success: boolean; + oldImageUrl: string; + newImageUrl?: string; + error?: string; +} + +/** + * 将网络图片转换为File对象 + * @param url 图片URL + * @param filename 文件名 + * @returns Promise + */ +const urlToFile = async (url: string, filename: string): Promise => { + const response = await fetch(url); + const blob = await response.blob(); + return new File([blob], filename, { type: blob.type }); +}; + +/** + * 上传图片到服务器 + * @param file 文件对象 + * @returns Promise 图片URL + */ +const uploadImage = async (file: File): Promise => { + const formData = new FormData(); + formData.append("file", file); + + const response = await fetch(`${VITE_APP_BASE_API}/file/upload`, { + method: "POST", + body: formData, + credentials: "include" + }); + + if (!response.ok) { + throw new Error(`上传失败: ${response.statusText}`); + } + + const result = await response.json(); + if (result.code !== 200) { + throw new Error(result.msg || "上传失败"); + } + + return result.data.url; +}; + +/** + * 压缩单个商品的图片 + * @param goods 商品信息 + * @param quality 压缩质量 + * @param maxWidth 最大宽度 + * @param maxHeight 最大高度 + * @returns Promise + */ +const compressGoodsImage = async ( + goods: GoodsDTO, + quality: ImageQuality = ImageQuality.MEDIUM, + maxWidth: number = 980, + maxHeight: number = 980 +): Promise => { + try { + // 如果没有封面图,跳过 + if (!goods.coverImg) { + return { + goodsId: goods.goodsId!, + goodsName: goods.goodsName, + success: false, + oldImageUrl: "", + error: "无封面图" + }; + } + + // 转换为File对象 + const imageFileName = `goods_${goods.goodsId}_${Date.now()}.jpg`; + const file = await urlToFile(goods.coverImg, imageFileName); + + // 压缩图片 + const { file: compressedFile, compressed } = await compressImageSafe(file, { + quality, + maxWidth, + maxHeight + }); + + // 上传压缩后的图片 + const newImageUrl = await uploadImage(compressedFile); + + // 更新商品信息 + const updateData = { + ...goods, + coverImg: newImageUrl + }; + delete (updateData as any).goodsId; + delete (updateData as any).createTime; + delete (updateData as any).updateTime; + + await updateGoodsApi(goods.goodsId!, updateData); + + return { + goodsId: goods.goodsId!, + goodsName: goods.goodsName, + success: true, + oldImageUrl: goods.coverImg, + newImageUrl + }; + } catch (error) { + return { + goodsId: goods.goodsId!, + goodsName: goods.goodsName, + success: false, + oldImageUrl: goods.coverImg, + error: error instanceof Error ? error.message : "未知错误" + }; + } +}; + +/** + * 获取所有商品列表(分页获取) + * @param corpid 企业微信ID + * @returns Promise + */ +const getAllGoods = async (corpid: string): Promise => { + const allGoods: GoodsDTO[] = []; + let pageNum = 1; + const pageSize = 100; // 每页获取100条 + + while (true) { + const { data } = await getGoodsListApi({ + corpid, + pageNum, + pageSize + }); + + if (data.rows.length === 0) { + break; + } + + allGoods.push(...data.rows); + + // 如果当前页数据小于pageSize,说明已经获取完所有数据 + if (data.rows.length < pageSize) { + break; + } + + pageNum++; + } + + return allGoods; +}; + +/** + * 一键压缩全部商品图片 + * @param options 压缩配置 + * @param onProgress 进度回调 + * @returns Promise + */ +export const compressAllGoodsImages = async ( + options: { + quality?: ImageQuality; + maxWidth?: number; + maxHeight?: number; + onProgress?: (progress: CompressProgress) => void; + } = {} +): Promise => { + const wxStore = useWxStore(); + const corpid = wxStore.corpid; + + if (!corpid) { + throw new Error("未获取到企业微信ID"); + } + + // 获取所有商品 + ElMessage.info("正在获取商品列表..."); + const allGoods = await getAllGoods(corpid); + + if (allGoods.length === 0) { + ElMessage.warning("没有找到商品数据"); + return []; + } + + // 过滤出有封面图的商品 + const goodsWithImages = allGoods.filter(goods => goods.coverImg + && goods.coverImg.trim() !== '' + && goods.coverImg.indexOf('wxshop.ab98.cn') !== -1); + + if (goodsWithImages.length === 0) { + ElMessage.warning("没有找到带封面图的商品"); + return []; + } + + ElMessage.info( + `找到 ${allGoods.length} 个商品,其中 ${goodsWithImages.length} 个有封面图` + ); + + // 配置参数 + const quality = options.quality || ImageQuality.MEDIUM; + const maxWidth = options.maxWidth || 980; + const maxHeight = options.maxHeight || 980; + const onProgress = options.onProgress; + + // 初始化进度 + const progress: CompressProgress = { + total: goodsWithImages.length, + current: 0, + success: 0, + failed: 0, + skipped: 0 + }; + + const results: CompressResult[] = []; + + // 逐个处理商品 + for (const goods of goodsWithImages) { + progress.current++; + onProgress?.(progress); + + // 跳过没有图片的商品 + if (!goods.coverImg) { + progress.skipped++; + continue; + } + + ElMessage.info( + `正在处理 (${progress.current}/${progress.total}): ${goods.goodsName}` + ); + + const result = await compressGoodsImage(goods, quality, maxWidth, maxHeight); + results.push(result); + + if (result.success) { + progress.success++; + ElMessage.success(`✓ ${goods.goodsName} - 压缩成功`); + } else { + progress.failed++; + ElMessage.error(`✗ ${goods.goodsName} - ${result.error}`); + } + } + + onProgress?.(progress); + + return results; +}; + +/** + * 预览压缩结果统计 + * @param results 压缩结果 + */ +export const previewCompressResults = (results: CompressResult[]): void => { + const success = results.filter(r => r.success).length; + const failed = results.filter(r => !r.success).length; + + console.group("商品图片压缩结果统计"); + console.log(`总商品数: ${results.length}`); + console.log(`成功: ${success}`); + console.log(`失败: ${failed}`); + console.log(`成功率: ${((success / results.length) * 100).toFixed(2)}%`); + console.groupEnd(); + + if (failed > 0) { + console.group("失败的商品:"); + results + .filter(r => !r.success) + .forEach(r => { + console.log(`- ${r.goodsName}: ${r.error}`); + }); + console.groupEnd(); + } +}; diff --git a/src/views/shop/goods/index.vue b/src/views/shop/goods/index.vue index 54c9477..f290f68 100644 --- a/src/views/shop/goods/index.vue +++ b/src/views/shop/goods/index.vue @@ -10,6 +10,8 @@ import Search from "@iconify-icons/ep/search"; import Refresh from "@iconify-icons/ep/refresh"; import Setting from "@iconify-icons/ep/setting"; import View from "@iconify-icons/ep/view"; +import Picture from "@iconify-icons/ep/picture"; +import Loading from "@iconify-icons/ep/loading"; import GoodsFormModal from "./goods-form-modal.vue"; import GoodsEditModal from "./goods-edit-modal.vue"; import { ElMessage, ElMessageBox } from "element-plus"; @@ -18,6 +20,14 @@ import { useMultiTagsStoreHook } from "@/store/modules/multiTags"; import { useRouter } from "vue-router"; import { useWxStore } from "@/store/modules/wx"; import { useBtnPermissionStore } from "@/store/modules/btnPermission"; +import { + compressAllGoodsImages, + previewCompressResults, + type CompressProgress, + type CompressResult +} from "@/utils/goodsImageCompressor"; +import { ImageQuality } from "@/utils/imageCompressor"; +import { defineProps } from "vue"; defineOptions({ name: "ShopGoods" @@ -52,6 +62,11 @@ const multipleSelection = ref([]); const editVisible = ref(false); const currentRow = ref(); +// 压缩相关状态 +const compressLoading = ref(false); +const compressProgress = ref(null); +const compressResults = ref([]); + const getList = async () => { try { loading.value = true; @@ -155,6 +170,66 @@ const handleViewDetail = (row: GoodsDTO) => { }); }; +// 一键压缩所有商品图片 +const handleCompressAllImages = async () => { + try { + await ElMessageBox.confirm( + "此操作将对所有商品的封面图进行压缩和重新上传,可能需要较长时间,是否继续?", + "提示", + { + confirmButtonText: "确定压缩", + cancelButtonText: "取消", + type: "warning" + } + ); + + compressLoading.value = true; + compressProgress.value = { + total: 0, + current: 0, + success: 0, + failed: 0, + skipped: 0 + }; + + const results = await compressAllGoodsImages({ + quality: ImageQuality.MEDIUM, + maxWidth: 980, + maxHeight: 980, + onProgress: (progress) => { + compressProgress.value = progress; + } + }); + + compressResults.value = results; + compressLoading.value = false; + + // 显示结果 + const success = results.filter(r => r.success).length; + const failed = results.filter(r => !r.success).length; + + if (failed === 0) { + ElMessage.success(`所有商品图片压缩完成!成功: ${success}个`); + } else { + ElMessage.warning( + `压缩完成!成功: ${success}个,失败: ${failed}个` + ); + } + + // 打印详细结果 + previewCompressResults(results); + + // 刷新列表 + getList(); + } catch (error) { + compressLoading.value = false; + if (error !== "cancel") { + console.error("压缩失败", error); + ElMessage.error("压缩失败,请查看控制台错误信息"); + } + } +}; +