feat(商品管理): 添加商品图片批量压缩功能
新增一键压缩所有商品图片功能,包括: 1. 分页获取所有商品数据 2. 图片压缩和上传服务 3. 实时进度显示和结果统计 4. 相关文档和使用说明
This commit is contained in:
parent
295dc7a8cf
commit
e9c8bdd385
|
|
@ -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
|
||||||
|
- 初始版本
|
||||||
|
- 支持一键压缩所有商品图片
|
||||||
|
- 实时进度显示
|
||||||
|
- 自动上传和更新功能
|
||||||
|
- 错误处理和重试机制
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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/, "")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue