feat(商品管理): 添加商品图片批量压缩功能

新增一键压缩所有商品图片功能,包括:
1. 分页获取所有商品数据
2. 图片压缩和上传服务
3. 实时进度显示和结果统计
4. 相关文档和使用说明
This commit is contained in:
dzq 2025-11-11 10:17:34 +08:00
parent 295dc7a8cf
commit e9c8bdd385
5 changed files with 662 additions and 1 deletions

View File

@ -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
- 初始版本
- 支持一键压缩所有商品图片
- 实时进度显示
- 自动上传和更新功能
- 错误处理和重试机制

View File

@ -135,3 +135,33 @@ export const exportGoodsExcelApi = (params: GoodsQuery, fileName: string) => {
params params
}); });
}; };
/** 获取所有商品(分页获取全部) */
export const getAllGoodsApi = async (corpid: string): Promise<GoodsDTO[]> => {
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;
};

View File

@ -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<File>
*/
const urlToFile = async (url: string, filename: string): Promise<File> => {
const response = await fetch(url);
const blob = await response.blob();
return new File([blob], filename, { type: blob.type });
};
/**
*
* @param file
* @returns Promise<string> URL
*/
const uploadImage = async (file: File): Promise<string> => {
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<CompressResult>
*/
const compressGoodsImage = async (
goods: GoodsDTO,
quality: ImageQuality = ImageQuality.MEDIUM,
maxWidth: number = 980,
maxHeight: number = 980
): Promise<CompressResult> => {
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<GoodsDTO[]>
*/
const getAllGoods = async (corpid: string): Promise<GoodsDTO[]> => {
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<CompressResult[]>
*/
export const compressAllGoodsImages = async (
options: {
quality?: ImageQuality;
maxWidth?: number;
maxHeight?: number;
onProgress?: (progress: CompressProgress) => void;
} = {}
): Promise<CompressResult[]> => {
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();
}
};

View File

@ -10,6 +10,8 @@ import Search from "@iconify-icons/ep/search";
import Refresh from "@iconify-icons/ep/refresh"; import Refresh from "@iconify-icons/ep/refresh";
import Setting from "@iconify-icons/ep/setting"; import Setting from "@iconify-icons/ep/setting";
import View from "@iconify-icons/ep/view"; 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 GoodsFormModal from "./goods-form-modal.vue";
import GoodsEditModal from "./goods-edit-modal.vue"; import GoodsEditModal from "./goods-edit-modal.vue";
import { ElMessage, ElMessageBox } from "element-plus"; import { ElMessage, ElMessageBox } from "element-plus";
@ -18,6 +20,14 @@ import { useMultiTagsStoreHook } from "@/store/modules/multiTags";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { useWxStore } from "@/store/modules/wx"; import { useWxStore } from "@/store/modules/wx";
import { useBtnPermissionStore } from "@/store/modules/btnPermission"; 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({ defineOptions({
name: "ShopGoods" name: "ShopGoods"
@ -52,6 +62,11 @@ const multipleSelection = ref<number[]>([]);
const editVisible = ref(false); const editVisible = ref(false);
const currentRow = ref<GoodsDTO>(); const currentRow = ref<GoodsDTO>();
//
const compressLoading = ref(false);
const compressProgress = ref<CompressProgress | null>(null);
const compressResults = ref<CompressResult[]>([]);
const getList = async () => { const getList = async () => {
try { try {
loading.value = true; 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("压缩失败,请查看控制台错误信息");
}
}
};
</script> </script>
<template> <template>
@ -190,6 +265,10 @@ const handleViewDetail = (row: GoodsDTO) => {
style="margin-right: 10px;"> style="margin-right: 10px;">
新增商品 新增商品
</el-button> </el-button>
<!-- <el-button v-if="hasPermission('shop:goods:write')" type="warning" :icon="useRenderIcon(Picture)" @click="handleCompressAllImages"
:loading="compressLoading" style="margin-right: 10px;">
{{ compressLoading ? '压缩中...' : '一键压缩图片' }}
</el-button> -->
</el-form-item> </el-form-item>
</el-form> </el-form>
@ -215,6 +294,36 @@ const handleViewDetail = (row: GoodsDTO) => {
:page-sizes="[12, 18, 24, 30, 36, 42]" layout="total, sizes, prev, pager, next, jumper" :page-sizes="[12, 18, 24, 30, 36, 42]" layout="total, sizes, prev, pager, next, jumper"
:total="pagination.total" @size-change="onSizeChange" @current-change="onCurrentChange" class="pagination" /> :total="pagination.total" @size-change="onSizeChange" @current-change="onCurrentChange" class="pagination" />
</div> </div>
<!-- 压缩进度显示 -->
<div v-if="compressLoading || compressProgress" class="compress-progress">
<el-card class="progress-card">
<template #header>
<div class="card-header">
<span>图片压缩进度</span>
</div>
</template>
<div v-if="compressProgress">
<div class="progress-info">
<p>总商品数{{ compressProgress.total }}</p>
<p>当前处理{{ compressProgress.current }} / {{ compressProgress.total }}</p>
<p>成功<span class="success">{{ compressProgress.success }}</span></p>
<p>失败<span class="failed">{{ compressProgress.failed }}</span></p>
<p>跳过<span class="skipped">{{ compressProgress.skipped }}</span></p>
</div>
<el-progress
:percentage="Math.round((compressProgress.current / compressProgress.total) * 100)"
:status="compressProgress.failed > 0 ? 'exception' : 'success'"
/>
</div>
<div v-else class="waiting">
<el-icon class="is-loading">
<component :is="useRenderIcon(Loading)" />
</el-icon>
<p>准备中...</p>
</div>
</el-card>
</div>
<!-- 新增商品弹窗 --> <!-- 新增商品弹窗 -->
<goods-form-modal v-model:visible="modalVisible" @refresh="getList" /> <goods-form-modal v-model:visible="modalVisible" @refresh="getList" />
<goods-edit-modal v-model:visible="editVisible" :row="currentRow" @refresh="getList" /> <goods-edit-modal v-model:visible="editVisible" :row="currentRow" @refresh="getList" />
@ -335,4 +444,60 @@ const handleViewDetail = (row: GoodsDTO) => {
margin-bottom: -12px; margin-bottom: -12px;
} }
} }
.compress-progress {
margin-top: 12px;
.progress-card {
background-color: var(--el-bg-color);
border-radius: 4px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.progress-info {
margin-bottom: 16px;
p {
margin: 8px 0;
font-size: 14px;
color: var(--el-text-color-regular);
.success {
color: var(--el-color-success);
font-weight: 600;
}
.failed {
color: var(--el-color-danger);
font-weight: 600;
}
.skipped {
color: var(--el-color-warning);
font-weight: 600;
}
}
}
.waiting {
text-align: center;
padding: 20px 0;
.el-icon {
font-size: 32px;
color: var(--el-color-primary);
}
p {
margin-top: 12px;
color: var(--el-text-color-regular);
}
}
}
}
</style> </style>

View File

@ -44,10 +44,15 @@ export default ({ command, mode }: ConfigEnv): UserConfigExport => {
host: "0.0.0.0", host: "0.0.0.0",
// 本地跨域代理 https://cn.vitejs.dev/config/server-options.html#server-proxy // 本地跨域代理 https://cn.vitejs.dev/config/server-options.html#server-proxy
proxy: { proxy: {
"/dev-api": { /* "/dev-api": {
target: "http://localhost:8080", target: "http://localhost:8080",
changeOrigin: true, changeOrigin: true,
rewrite: path => path.replace(/^\/dev-api/, "") rewrite: path => path.replace(/^\/dev-api/, "")
}, */
"/dev-api": {
target: "http://wxshop.ab98.cn/shop-back-end",
changeOrigin: true,
rewrite: path => path.replace(/^\/dev-api/, "")
} }
} }
}, },