diff --git a/yshop-drink-boot3/yshop-dependencies/pom.xml b/yshop-drink-boot3/yshop-dependencies/pom.xml
index 61de8ad..5ff8917 100644
--- a/yshop-drink-boot3/yshop-dependencies/pom.xml
+++ b/yshop-drink-boot3/yshop-dependencies/pom.xml
@@ -61,6 +61,7 @@
3.10.0
0.1.55
2.9.1
+ 0.8.0
2.7.0
3.0.6
@@ -466,6 +467,12 @@
${tika-core.version}
+
+ com.github.usefulness
+ webp-imageio
+ ${webp-imageio.version}
+
+
org.apache.velocity
velocity-engine-core
diff --git a/yshop-drink-boot3/yshop-module-infra/yshop-module-infra-biz/pom.xml b/yshop-drink-boot3/yshop-module-infra/yshop-module-infra-biz/pom.xml
index 255c012..8ed6dfd 100644
--- a/yshop-drink-boot3/yshop-module-infra/yshop-module-infra-biz/pom.xml
+++ b/yshop-drink-boot3/yshop-module-infra/yshop-module-infra-biz/pom.xml
@@ -125,6 +125,11 @@
tika-core
+
+ com.github.usefulness
+ webp-imageio
+
+
diff --git a/yshop-drink-boot3/yshop-module-infra/yshop-module-infra-biz/src/main/java/co/yixiang/yshop/module/infra/framework/file/config/WebpProperties.java b/yshop-drink-boot3/yshop-module-infra/yshop-module-infra-biz/src/main/java/co/yixiang/yshop/module/infra/framework/file/config/WebpProperties.java
new file mode 100644
index 0000000..016e191
--- /dev/null
+++ b/yshop-drink-boot3/yshop-module-infra/yshop-module-infra-biz/src/main/java/co/yixiang/yshop/module/infra/framework/file/config/WebpProperties.java
@@ -0,0 +1,32 @@
+package co.yixiang.yshop.module.infra.framework.file.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.validation.annotation.Validated;
+
+/**
+ * 图片转 WebP 压缩的配置项
+ *
+ * @author yshop
+ */
+@ConfigurationProperties(prefix = "yshop.file.webp")
+@Validated
+@Data
+public class WebpProperties {
+
+ /**
+ * 是否开启图片自动转 webp 压缩
+ */
+ private boolean enable = true;
+
+ /**
+ * 有损压缩质量,取值 0~1,越小体积越小
+ */
+ private float quality = 0.75f;
+
+ /**
+ * 最大宽度(像素),超过则等比缩小;<= 0 表示不限制
+ */
+ private int maxWidth = 2048;
+
+}
diff --git a/yshop-drink-boot3/yshop-module-infra/yshop-module-infra-biz/src/main/java/co/yixiang/yshop/module/infra/framework/file/config/YshopFileAutoConfiguration.java b/yshop-drink-boot3/yshop-module-infra/yshop-module-infra-biz/src/main/java/co/yixiang/yshop/module/infra/framework/file/config/YshopFileAutoConfiguration.java
index 0e894a0..1cd616e 100644
--- a/yshop-drink-boot3/yshop-module-infra/yshop-module-infra-biz/src/main/java/co/yixiang/yshop/module/infra/framework/file/config/YshopFileAutoConfiguration.java
+++ b/yshop-drink-boot3/yshop-module-infra/yshop-module-infra-biz/src/main/java/co/yixiang/yshop/module/infra/framework/file/config/YshopFileAutoConfiguration.java
@@ -2,6 +2,7 @@ package co.yixiang.yshop.module.infra.framework.file.config;
import co.yixiang.yshop.module.infra.framework.file.core.client.FileClientFactory;
import co.yixiang.yshop.module.infra.framework.file.core.client.FileClientFactoryImpl;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -11,6 +12,7 @@ import org.springframework.context.annotation.Configuration;
* @author yshop
*/
@Configuration(proxyBeanMethods = false)
+@EnableConfigurationProperties(WebpProperties.class)
public class YshopFileAutoConfiguration {
@Bean
diff --git a/yshop-drink-boot3/yshop-module-infra/yshop-module-infra-biz/src/main/java/co/yixiang/yshop/module/infra/framework/file/core/utils/ImageWebpUtils.java b/yshop-drink-boot3/yshop-module-infra/yshop-module-infra-biz/src/main/java/co/yixiang/yshop/module/infra/framework/file/core/utils/ImageWebpUtils.java
new file mode 100644
index 0000000..aafba1f
--- /dev/null
+++ b/yshop-drink-boot3/yshop-module-infra/yshop-module-infra-biz/src/main/java/co/yixiang/yshop/module/infra/framework/file/core/utils/ImageWebpUtils.java
@@ -0,0 +1,158 @@
+package co.yixiang.yshop.module.infra.framework.file.core.utils;
+
+import cn.hutool.core.util.StrUtil;
+import com.luciad.imageio.webp.CompressionType;
+import com.luciad.imageio.webp.WebPWriteParam;
+import lombok.SneakyThrows;
+
+import javax.imageio.IIOImage;
+import javax.imageio.ImageIO;
+import javax.imageio.ImageReader;
+import javax.imageio.ImageWriteParam;
+import javax.imageio.ImageWriter;
+import javax.imageio.stream.ImageInputStream;
+import javax.imageio.stream.MemoryCacheImageOutputStream;
+import java.awt.Graphics2D;
+import java.awt.RenderingHints;
+import java.awt.Transparency;
+import java.awt.image.BufferedImage;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.util.Iterator;
+import java.util.Set;
+
+/**
+ * 图片转 WebP 工具类
+ *
+ * 基于 webp-imageio(libwebp 的 JNI 封装),将 jpg/png/bmp 等图片转为有损 webp,
+ * 并按最大宽度等比缩小,以达到最小体积。
+ *
+ * @author yshop
+ */
+public class ImageWebpUtils {
+
+ /**
+ * 可转为 webp 的图片 mime 白名单
+ *
+ * 排除:image/gif(动图,webp-imageio 不支持写动图)、image/webp(已转换)、image/svg+xml(矢量图)
+ */
+ private static final Set CONVERTIBLE_TYPES = Set.of(
+ "image/jpeg", "image/jpg", "image/png", "image/bmp");
+
+ /**
+ * 判断指定 mime 类型的图片是否可转 webp
+ *
+ * @param mimeType mime 类型
+ * @return 是否可转
+ */
+ public static boolean isConvertible(String mimeType) {
+ return StrUtil.isNotBlank(mimeType) && CONVERTIBLE_TYPES.contains(mimeType.toLowerCase());
+ }
+
+ /**
+ * 将图片字节转为有损 webp
+ *
+ * @param content 原始图片字节
+ * @param quality 有损质量 0~1,越小体积越小
+ * @param maxWidth 最大宽度(像素),超过则等比缩小;<= 0 表示不限制
+ * @return webp 字节
+ */
+ @SneakyThrows
+ public static byte[] convertToWebp(byte[] content, float quality, int maxWidth) {
+ BufferedImage image = readImage(content);
+ BufferedImage scaled = scaleIfNeeded(image, maxWidth);
+ return encodeWebp(scaled, quality);
+ }
+
+ /**
+ * 将文件名/路径的扩展名替换为 .webp,保留目录前缀;无扩展名时追加 .webp
+ *
+ * @param fileName 文件名或路径,可为空
+ * @return 替换后的文件名
+ */
+ public static String replaceExtToWebp(String fileName) {
+ if (StrUtil.isBlank(fileName)) {
+ return fileName;
+ }
+ // 兼容 Windows 与 Linux 路径分隔符,保留目录前缀
+ int slashIdx = Math.max(fileName.lastIndexOf('/'), fileName.lastIndexOf('\\'));
+ String dir = slashIdx >= 0 ? fileName.substring(0, slashIdx + 1) : "";
+ String name = slashIdx >= 0 ? fileName.substring(slashIdx + 1) : fileName;
+ if (StrUtil.isBlank(name)) {
+ return dir;
+ }
+ int dotIdx = name.lastIndexOf('.');
+ // dotIdx <= 0 排除隐藏文件(如 ".gitignore")和无扩展名的情况,二者均直接追加 .webp
+ String mainName = dotIdx > 0 ? name.substring(0, dotIdx) : name;
+ return dir + mainName + ".webp";
+ }
+
+ /**
+ * 读取图片字节为 BufferedImage
+ */
+ private static BufferedImage readImage(byte[] content) throws Exception {
+ try (ImageInputStream iis = ImageIO.createImageInputStream(new ByteArrayInputStream(content))) {
+ Iterator readers = ImageIO.getImageReaders(iis);
+ if (!readers.hasNext()) {
+ throw new IllegalArgumentException("无法识别的图片格式");
+ }
+ ImageReader reader = readers.next();
+ try {
+ reader.setInput(iis);
+ return reader.read(0);
+ } finally {
+ reader.dispose();
+ }
+ }
+ }
+
+ /**
+ * 按最大宽度等比缩小;未超限或限制关闭时原样返回
+ */
+ private static BufferedImage scaleIfNeeded(BufferedImage src, int maxWidth) {
+ if (maxWidth <= 0 || src.getWidth() <= maxWidth) {
+ return src;
+ }
+ int newWidth = maxWidth;
+ int newHeight = Math.round((float) src.getHeight() * maxWidth / src.getWidth());
+ // 保留原图透明通道,避免 PNG 透明区域被压成黑底
+ int imageType = src.getTransparency() != Transparency.OPAQUE
+ ? BufferedImage.TYPE_INT_ARGB : BufferedImage.TYPE_INT_RGB;
+ BufferedImage scaled = new BufferedImage(newWidth, newHeight, imageType);
+ Graphics2D g = scaled.createGraphics();
+ try {
+ g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
+ g.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
+ g.drawImage(src, 0, 0, newWidth, newHeight, null);
+ } finally {
+ g.dispose();
+ }
+ return scaled;
+ }
+
+ /**
+ * 有损 webp 编码
+ */
+ private static byte[] encodeWebp(BufferedImage image, float quality) throws Exception {
+ ImageWriter writer = ImageIO.getImageWritersByMIMEType("image/webp").next();
+ try {
+ WebPWriteParam writeParam = new WebPWriteParam(writer.getLocale());
+ writeParam.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
+ writeParam.setCompressionType(CompressionType.Lossy);
+ writeParam.setCompressionQuality(clamp(quality, 0f, 1f));
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ try (MemoryCacheImageOutputStream ios = new MemoryCacheImageOutputStream(out)) {
+ writer.setOutput(ios);
+ writer.write(null, new IIOImage(image, null, null), writeParam);
+ }
+ return out.toByteArray();
+ } finally {
+ writer.dispose();
+ }
+ }
+
+ private static float clamp(float value, float min, float max) {
+ return Math.max(min, Math.min(max, value));
+ }
+
+}
diff --git a/yshop-drink-boot3/yshop-module-infra/yshop-module-infra-biz/src/main/java/co/yixiang/yshop/module/infra/service/file/FileServiceImpl.java b/yshop-drink-boot3/yshop-module-infra/yshop-module-infra-biz/src/main/java/co/yixiang/yshop/module/infra/service/file/FileServiceImpl.java
index bee739d..d2979e0 100644
--- a/yshop-drink-boot3/yshop-module-infra/yshop-module-infra-biz/src/main/java/co/yixiang/yshop/module/infra/service/file/FileServiceImpl.java
+++ b/yshop-drink-boot3/yshop-module-infra/yshop-module-infra-biz/src/main/java/co/yixiang/yshop/module/infra/service/file/FileServiceImpl.java
@@ -5,9 +5,11 @@ import cn.hutool.core.util.StrUtil;
import co.yixiang.yshop.framework.common.pojo.PageResult;
import co.yixiang.yshop.framework.common.util.io.FileUtils;
import co.yixiang.yshop.framework.common.util.object.BeanUtils;
+import co.yixiang.yshop.module.infra.framework.file.config.WebpProperties;
import co.yixiang.yshop.module.infra.framework.file.core.client.FileClient;
import co.yixiang.yshop.module.infra.framework.file.core.client.s3.FilePresignedUrlRespDTO;
import co.yixiang.yshop.module.infra.framework.file.core.utils.FileTypeUtils;
+import co.yixiang.yshop.module.infra.framework.file.core.utils.ImageWebpUtils;
import co.yixiang.yshop.module.infra.controller.admin.file.vo.file.FileCreateReqVO;
import co.yixiang.yshop.module.infra.controller.admin.file.vo.file.FilePageReqVO;
import co.yixiang.yshop.module.infra.controller.admin.file.vo.file.FilePresignedUrlRespVO;
@@ -15,6 +17,7 @@ import co.yixiang.yshop.module.infra.dal.dataobject.file.FileDO;
import co.yixiang.yshop.module.infra.dal.mysql.file.FileMapper;
import jakarta.annotation.Resource;
import lombok.SneakyThrows;
+import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import static co.yixiang.yshop.framework.common.exception.util.ServiceExceptionUtil.exception;
@@ -26,6 +29,7 @@ import static co.yixiang.yshop.module.infra.enums.ErrorCodeConstants.FILE_NOT_EX
* @author yshop
*/
@Service
+@Slf4j
public class FileServiceImpl implements FileService {
@Resource
@@ -34,6 +38,9 @@ public class FileServiceImpl implements FileService {
@Resource
private FileMapper fileMapper;
+ @Resource
+ private WebpProperties webpProperties;
+
@Override
public PageResult getFilePage(FilePageReqVO pageReqVO) {
return fileMapper.selectPage(pageReqVO);
@@ -44,6 +51,23 @@ public class FileServiceImpl implements FileService {
public String createFile(String name, String path, byte[] content) {
// 计算默认的 path 名
String type = FileTypeUtils.getMineType(content, name);
+
+ // 图片自动转 webp 压缩,确保体积最小
+ if (webpProperties.isEnable() && ImageWebpUtils.isConvertible(type)) {
+ try {
+ byte[] webp = ImageWebpUtils.convertToWebp(content, webpProperties.getQuality(), webpProperties.getMaxWidth());
+ // 仅当转换后体积更小时才采用,避免极小原图转换后反而变大
+ if (webp.length < content.length) {
+ content = webp;
+ type = "image/webp";
+ name = ImageWebpUtils.replaceExtToWebp(name);
+ path = ImageWebpUtils.replaceExtToWebp(path);
+ }
+ } catch (Exception e) {
+ log.warn("[createFile] 转 webp 失败,保留原图上传: {}", e.getMessage());
+ }
+ }
+
if (StrUtil.isEmpty(path)) {
path = FileUtils.generatePath(content, name);
}
diff --git a/yshop-drink-boot3/yshop-server/src/main/resources/application.yaml b/yshop-drink-boot3/yshop-server/src/main/resources/application.yaml
index 1f69732..63c27ba 100644
--- a/yshop-drink-boot3/yshop-server/src/main/resources/application.yaml
+++ b/yshop-drink-boot3/yshop-server/src/main/resources/application.yaml
@@ -149,6 +149,11 @@ yshop:
version: 3.0.0
base-package: co.yixiang.yshop
isActive: true
+ file: # 图片上传相关
+ webp:
+ enable: true # 图片自动转 webp 压缩开关
+ quality: 0.75 # 有损压缩质量 0~1,越小体积越小
+ max-width: 2048 # 最大宽度(像素),超过等比缩小;0 表示不限制
web:
admin-ui:
url: http://dashboard.yshop.yixiang.co # Admin 管理后台 UI 的地址
diff --git a/yshop-drink-uniapp-vue3/pages/components/pages/orders/orders.vue b/yshop-drink-uniapp-vue3/pages/components/pages/orders/orders.vue
index 8a4cf29..0992024 100644
--- a/yshop-drink-uniapp-vue3/pages/components/pages/orders/orders.vue
+++ b/yshop-drink-uniapp-vue3/pages/components/pages/orders/orders.vue
@@ -10,7 +10,7 @@
-
+
diff --git a/yshop-drink-uniapp-vue3/pages/menu/menu.vue b/yshop-drink-uniapp-vue3/pages/menu/menu.vue
index c4d2a80..432cbba 100644
--- a/yshop-drink-uniapp-vue3/pages/menu/menu.vue
+++ b/yshop-drink-uniapp-vue3/pages/menu/menu.vue
@@ -50,6 +50,7 @@
@@ -1496,6 +1497,8 @@ page {
.menu-search { display: flex; align-items: center; height: 78rpx; margin: 22rpx 0; padding: 0 24rpx; border: 2rpx solid #eee9e5; border-radius: 42rpx; color: #9d9d9d; background: #fff; }
.menu-search input { flex: 1; margin-left: 14rpx; font-size: 28rpx; }
.menu-search .iconsearch { font-size: 40rpx; line-height: 1; }
+ .menu-search .clear-search { width: 48rpx; height: 48rpx; margin: 0; padding: 0; border-radius: 50%; color: #999; font-size: 34rpx; line-height: 44rpx; background: #f1efed; }
+ .menu-search .clear-search::after { border: 0; }
.content { height: calc(100vh - 440rpx) !important; }
.menus { width: 156rpx; border-radius: 24rpx 0 0 24rpx; background: #fffaf5; }