mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-09-05 15:47:00 +08:00
feat(upload): add image format conversion and quality settings for uploads #6
This commit is contained in:
@@ -83,7 +83,9 @@ public class PictureController(IPictureService pictureService, IConfigService co
|
|||||||
userId,
|
userId,
|
||||||
(PermissionType)request.Permission!,
|
(PermissionType)request.Permission!,
|
||||||
request.AlbumId,
|
request.AlbumId,
|
||||||
request.StorageType
|
request.StorageType,
|
||||||
|
request.ConvertToFormat,
|
||||||
|
request.Quality
|
||||||
);
|
);
|
||||||
|
|
||||||
var picture = result.Picture;
|
var picture = result.Picture;
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
namespace Foxel.Models.Enums;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 支持的图片格式枚举
|
||||||
|
/// </summary>
|
||||||
|
public enum ImageFormat
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 保持原格式
|
||||||
|
/// </summary>
|
||||||
|
Original = 0,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// JPEG格式
|
||||||
|
/// </summary>
|
||||||
|
Jpeg = 1,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PNG格式
|
||||||
|
/// </summary>
|
||||||
|
Png = 2,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// WebP格式
|
||||||
|
/// </summary>
|
||||||
|
WebP = 3,
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,16 +1,30 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using Foxel.Models.DataBase;
|
using Foxel.Models.DataBase;
|
||||||
|
using Foxel.Models.Enums;
|
||||||
using Foxel.Services.Attributes;
|
using Foxel.Services.Attributes;
|
||||||
|
|
||||||
namespace Foxel.Models.Request.Picture;
|
namespace Foxel.Models.Request.Picture;
|
||||||
|
|
||||||
public record UploadPictureRequest
|
public record UploadPictureRequest
|
||||||
{
|
{
|
||||||
[Required] public IFormFile File { get; set; } = null!;
|
[Required(ErrorMessage = "文件不能为空")]
|
||||||
|
public IFormFile File { get; set; } = null!;
|
||||||
|
|
||||||
public int? Permission { get; set; } = 1;
|
[Range(0, 2, ErrorMessage = "权限类型必须是0(公开)、1(私有)或2(仅关注者)")]
|
||||||
|
public int? Permission { get; set; } = 0;
|
||||||
|
|
||||||
public int? AlbumId { get; set; } = null;
|
public int? AlbumId { get; set; }
|
||||||
|
|
||||||
public StorageType? StorageType { get; set; } = null;
|
public StorageType? StorageType { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 目标图片格式,默认为保持原格式
|
||||||
|
/// </summary>
|
||||||
|
public ImageFormat ConvertToFormat { get; set; } = ImageFormat.Original;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 图片质量(仅对JPEG和WebP有效,1-100)
|
||||||
|
/// </summary>
|
||||||
|
[Range(1, 100, ErrorMessage = "图片质量必须在1-100之间")]
|
||||||
|
public int Quality { get; set; } = 95;
|
||||||
}
|
}
|
||||||
@@ -238,8 +238,7 @@ public sealed class BackgroundTaskQueue : IBackgroundTaskQueue, IDisposable
|
|||||||
await UpdatePictureStatus(task.PictureId, ProcessingStatus.Processing, 20);
|
await UpdatePictureStatus(task.PictureId, ProcessingStatus.Processing, 20);
|
||||||
var thumbnailPath = Path.Combine(
|
var thumbnailPath = Path.Combine(
|
||||||
Path.GetDirectoryName(localFilePath)!,
|
Path.GetDirectoryName(localFilePath)!,
|
||||||
Path.GetFileNameWithoutExtension(Path.GetFileName(localFilePath)) + "_thumb" +
|
Path.GetFileNameWithoutExtension(Path.GetFileName(localFilePath)) + "_thumb.webp");
|
||||||
Path.GetExtension(localFilePath));
|
|
||||||
|
|
||||||
await ImageHelper.CreateThumbnailAsync(localFilePath, thumbnailPath, 500);
|
await ImageHelper.CreateThumbnailAsync(localFilePath, thumbnailPath, 500);
|
||||||
|
|
||||||
@@ -257,7 +256,7 @@ public sealed class BackgroundTaskQueue : IBackgroundTaskQueue, IDisposable
|
|||||||
// 非本地存储,上传缩略图到对应的存储服务
|
// 非本地存储,上传缩略图到对应的存储服务
|
||||||
await using var thumbnailFileStream = new FileStream(thumbnailPath, FileMode.Open, FileAccess.Read);
|
await using var thumbnailFileStream = new FileStream(thumbnailPath, FileMode.Open, FileAccess.Read);
|
||||||
var thumbnailFileName = Path.GetFileName(thumbnailPath);
|
var thumbnailFileName = Path.GetFileName(thumbnailPath);
|
||||||
var thumbnailContentType = Path.GetExtension(thumbnailPath).ToLower() == ".png" ? "image/png" : "image/jpeg";
|
var thumbnailContentType = "image/webp";
|
||||||
|
|
||||||
// 上传缩略图并获取存储路径或元数据
|
// 上传缩略图并获取存储路径或元数据
|
||||||
string thumbnailStoragePath = await storageService.SaveAsync(
|
string thumbnailStoragePath = await storageService.SaveAsync(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Foxel.Models;
|
using Foxel.Models;
|
||||||
using Foxel.Models.DataBase;
|
using Foxel.Models.DataBase;
|
||||||
|
using Foxel.Models.Enums;
|
||||||
using Foxel.Models.Response.Picture;
|
using Foxel.Models.Response.Picture;
|
||||||
using Foxel.Services.Attributes;
|
using Foxel.Services.Attributes;
|
||||||
|
|
||||||
@@ -33,7 +34,9 @@ public interface IPictureService
|
|||||||
int? userId,
|
int? userId,
|
||||||
PermissionType permission = PermissionType.Public,
|
PermissionType permission = PermissionType.Public,
|
||||||
int? albumId = null,
|
int? albumId = null,
|
||||||
StorageType? storageType = null
|
StorageType? storageType = null,
|
||||||
|
ImageFormat convertToFormat = ImageFormat.Original,
|
||||||
|
int quality = 95
|
||||||
);
|
);
|
||||||
|
|
||||||
Task<ExifInfo> GetPictureExifInfoAsync(int pictureId);
|
Task<ExifInfo> GetPictureExifInfoAsync(int pictureId);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Foxel.Models;
|
using Foxel.Models;
|
||||||
using Foxel.Models.DataBase;
|
using Foxel.Models.DataBase;
|
||||||
|
using Foxel.Models.Enums;
|
||||||
using Foxel.Models.Response.Picture;
|
using Foxel.Models.Response.Picture;
|
||||||
using Foxel.Services.AI;
|
using Foxel.Services.AI;
|
||||||
using Foxel.Services.Attributes;
|
using Foxel.Services.Attributes;
|
||||||
@@ -436,7 +437,9 @@ public class PictureService(
|
|||||||
int? userId,
|
int? userId,
|
||||||
PermissionType permission = PermissionType.Public,
|
PermissionType permission = PermissionType.Public,
|
||||||
int? albumId = null,
|
int? albumId = null,
|
||||||
StorageType? storageType = null)
|
StorageType? storageType = null,
|
||||||
|
ImageFormat convertToFormat = ImageFormat.Original,
|
||||||
|
int quality = 95)
|
||||||
{
|
{
|
||||||
// 如果未指定存储类型,则从配置中获取默认存储类型
|
// 如果未指定存储类型,则从配置中获取默认存储类型
|
||||||
if (storageType == null)
|
if (storageType == null)
|
||||||
@@ -449,88 +452,143 @@ public class PictureService(
|
|||||||
storageType = defaultStorageType;
|
storageType = defaultStorageType;
|
||||||
}
|
}
|
||||||
|
|
||||||
string fileExtension = Path.GetExtension(fileName);
|
string originalFileName = fileName;
|
||||||
_ = $"{Guid.NewGuid()}{fileExtension}";
|
string finalFileName = fileName;
|
||||||
|
string finalContentType = contentType;
|
||||||
|
Stream finalStream = fileStream;
|
||||||
|
|
||||||
// 使用存储服务保存文件
|
// 如果需要格式转换
|
||||||
string relativePath = await storageService.SaveAsync(storageType.Value, fileStream, fileName, contentType);
|
if (convertToFormat != ImageFormat.Original)
|
||||||
|
|
||||||
// 创建基本的Picture对象,使用文件名作为标题和描述
|
|
||||||
string initialTitle = Path.GetFileNameWithoutExtension(fileName);
|
|
||||||
string initialDescription = $"Uploaded on {DateTime.UtcNow}";
|
|
||||||
|
|
||||||
await using var dbContext = await contextFactory.CreateDbContextAsync();
|
|
||||||
|
|
||||||
// 获取用户
|
|
||||||
User? user = null;
|
|
||||||
if (userId is not null)
|
|
||||||
{
|
{
|
||||||
user = await dbContext.Users.FirstOrDefaultAsync(u => u.Id == userId);
|
// 创建临时文件保存原始上传内容
|
||||||
if (user == null)
|
string tempOriginalFile = Path.GetTempFileName();
|
||||||
|
string tempConvertedFile = Path.GetTempFileName();
|
||||||
|
|
||||||
|
try
|
||||||
{
|
{
|
||||||
throw new Exception("找不到指定的用户");
|
// 保存原始文件到临时位置
|
||||||
|
await using (var tempFileStream = new FileStream(tempOriginalFile, FileMode.Create))
|
||||||
|
{
|
||||||
|
await fileStream.CopyToAsync(tempFileStream);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换格式
|
||||||
|
string convertedFilePath = await ImageHelper.ConvertImageFormatAsync(
|
||||||
|
tempOriginalFile, tempConvertedFile, convertToFormat, quality);
|
||||||
|
|
||||||
|
// 更新文件信息
|
||||||
|
string newExtension = ImageHelper.GetFileExtensionFromFormat(convertToFormat);
|
||||||
|
finalFileName = Path.ChangeExtension(Path.GetFileNameWithoutExtension(originalFileName), newExtension);
|
||||||
|
finalContentType = ImageHelper.GetMimeTypeFromFormat(convertToFormat);
|
||||||
|
|
||||||
|
// 创建新的流用于上传转换后的文件
|
||||||
|
finalStream = new FileStream(convertedFilePath, FileMode.Open, FileAccess.Read);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// 清理临时文件
|
||||||
|
if (File.Exists(tempOriginalFile)) File.Delete(tempOriginalFile);
|
||||||
|
if (File.Exists(tempConvertedFile)) File.Delete(tempConvertedFile);
|
||||||
|
throw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查相册是否存在并且属于当前用户
|
try
|
||||||
Album? album = null;
|
|
||||||
if (albumId.HasValue)
|
|
||||||
{
|
{
|
||||||
album = await dbContext.Albums.Include(a => a.User)
|
// 使用存储服务保存文件
|
||||||
.FirstOrDefaultAsync(a => a.Id == albumId.Value);
|
string relativePath = await storageService.SaveAsync(storageType.Value, finalStream, finalFileName, finalContentType);
|
||||||
|
|
||||||
if (album == null)
|
// 创建基本的Picture对象,使用文件名作为标题和描述
|
||||||
|
string initialTitle = Path.GetFileNameWithoutExtension(originalFileName);
|
||||||
|
string initialDescription = $"Uploaded on {DateTime.UtcNow}";
|
||||||
|
|
||||||
|
await using var dbContext = await contextFactory.CreateDbContextAsync();
|
||||||
|
|
||||||
|
// 获取用户
|
||||||
|
User? user = null;
|
||||||
|
if (userId is not null)
|
||||||
{
|
{
|
||||||
throw new KeyNotFoundException($"找不到ID为{albumId.Value}的相册");
|
user = await dbContext.Users.FirstOrDefaultAsync(u => u.Id == userId);
|
||||||
|
if (user == null)
|
||||||
|
{
|
||||||
|
throw new Exception("找不到指定的用户");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (album.User.Id != userId)
|
// 检查相册是否存在并且属于当前用户
|
||||||
|
Album? album = null;
|
||||||
|
if (albumId.HasValue)
|
||||||
{
|
{
|
||||||
throw new Exception("您无权将图片添加到此相册");
|
album = await dbContext.Albums.Include(a => a.User)
|
||||||
|
.FirstOrDefaultAsync(a => a.Id == albumId.Value);
|
||||||
|
|
||||||
|
if (album == null)
|
||||||
|
{
|
||||||
|
throw new KeyNotFoundException($"找不到ID为{albumId.Value}的相册");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (album.User.Id != userId)
|
||||||
|
{
|
||||||
|
throw new Exception("您无权将图片添加到此相册");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isAnonymous = userId == null;
|
||||||
|
|
||||||
|
// 创建图片对象并保存到数据库
|
||||||
|
var picture = new Picture
|
||||||
|
{
|
||||||
|
Name = initialTitle,
|
||||||
|
Description = initialDescription,
|
||||||
|
Path = relativePath,
|
||||||
|
User = user,
|
||||||
|
Permission = permission,
|
||||||
|
AlbumId = albumId,
|
||||||
|
StorageType = storageType.Value,
|
||||||
|
ProcessingStatus = isAnonymous ? ProcessingStatus.Completed : ProcessingStatus.Pending,
|
||||||
|
ThumbnailPath = isAnonymous ? relativePath : null
|
||||||
|
};
|
||||||
|
|
||||||
|
dbContext.Pictures.Add(picture);
|
||||||
|
await dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
if (!isAnonymous)
|
||||||
|
{
|
||||||
|
await backgroundTaskQueue.QueuePictureProcessingTaskAsync(picture.Id, relativePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回图片基本信息
|
||||||
|
var pictureResponse = new PictureResponse
|
||||||
|
{
|
||||||
|
Id = picture.Id,
|
||||||
|
Name = picture.Name,
|
||||||
|
Path = storageService.GetUrl(picture.StorageType, relativePath),
|
||||||
|
ThumbnailPath = isAnonymous ? storageService.GetUrl(picture.StorageType, relativePath) : null,
|
||||||
|
Description = picture.Description,
|
||||||
|
CreatedAt = picture.CreatedAt,
|
||||||
|
Tags = new List<string>(),
|
||||||
|
Permission = permission,
|
||||||
|
AlbumId = albumId,
|
||||||
|
AlbumName = album?.Name,
|
||||||
|
ProcessingStatus = picture.ProcessingStatus
|
||||||
|
};
|
||||||
|
|
||||||
|
return (pictureResponse, picture.Id);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
// 清理转换后的临时流
|
||||||
|
if (finalStream != fileStream && finalStream is FileStream tempFileStream)
|
||||||
|
{
|
||||||
|
string tempFilePath = tempFileStream.Name;
|
||||||
|
finalStream.Dispose();
|
||||||
|
if (File.Exists(tempFilePath)) File.Delete(tempFilePath);
|
||||||
|
|
||||||
|
// 同时清理原始临时文件
|
||||||
|
string tempOriginalFile = Path.ChangeExtension(tempFilePath, null);
|
||||||
|
if (File.Exists(tempOriginalFile)) File.Delete(tempOriginalFile);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
bool isAnonymous = userId == null;
|
|
||||||
|
|
||||||
// 创建图片对象并保存到数据库
|
|
||||||
var picture = new Picture
|
|
||||||
{
|
|
||||||
Name = initialTitle,
|
|
||||||
Description = initialDescription,
|
|
||||||
Path = relativePath,
|
|
||||||
User = user,
|
|
||||||
Permission = permission,
|
|
||||||
AlbumId = albumId,
|
|
||||||
StorageType = storageType.Value,
|
|
||||||
ProcessingStatus = isAnonymous ? ProcessingStatus.Completed : ProcessingStatus.Pending,
|
|
||||||
ThumbnailPath = isAnonymous ? relativePath : null
|
|
||||||
};
|
|
||||||
|
|
||||||
dbContext.Pictures.Add(picture);
|
|
||||||
await dbContext.SaveChangesAsync();
|
|
||||||
|
|
||||||
if (!isAnonymous)
|
|
||||||
{
|
|
||||||
await backgroundTaskQueue.QueuePictureProcessingTaskAsync(picture.Id, relativePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 返回图片基本信息
|
|
||||||
var pictureResponse = new PictureResponse
|
|
||||||
{
|
|
||||||
Id = picture.Id,
|
|
||||||
Name = picture.Name,
|
|
||||||
Path = storageService.GetUrl(picture.StorageType, relativePath),
|
|
||||||
ThumbnailPath = isAnonymous ? storageService.GetUrl(picture.StorageType, relativePath) : null,
|
|
||||||
Description = picture.Description,
|
|
||||||
CreatedAt = picture.CreatedAt,
|
|
||||||
Tags = new List<string>(),
|
|
||||||
Permission = permission,
|
|
||||||
AlbumId = albumId,
|
|
||||||
AlbumName = album?.Name,
|
|
||||||
ProcessingStatus = picture.ProcessingStatus
|
|
||||||
};
|
|
||||||
|
|
||||||
return (pictureResponse, picture.Id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ExifInfo> GetPictureExifInfoAsync(int pictureId)
|
public async Task<ExifInfo> GetPictureExifInfoAsync(int pictureId)
|
||||||
|
|||||||
+145
-42
@@ -3,6 +3,7 @@ using SixLabors.ImageSharp.Processing;
|
|||||||
using SixLabors.ImageSharp.Metadata.Profiles.Exif;
|
using SixLabors.ImageSharp.Metadata.Profiles.Exif;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using Foxel.Models;
|
using Foxel.Models;
|
||||||
|
using Foxel.Models.Enums;
|
||||||
using SixLabors.ImageSharp.PixelFormats;
|
using SixLabors.ImageSharp.PixelFormats;
|
||||||
|
|
||||||
namespace Foxel.Utils;
|
namespace Foxel.Utils;
|
||||||
@@ -53,54 +54,26 @@ public static class ImageHelper
|
|||||||
Mode = ResizeMode.Max
|
Mode = ResizeMode.Max
|
||||||
}));
|
}));
|
||||||
|
|
||||||
string extension = Path.GetExtension(thumbnailPath).ToLower();
|
// 强制使用 WebP 格式,修改缩略图路径扩展名
|
||||||
|
string webpThumbnailPath = Path.ChangeExtension(thumbnailPath, ".webp");
|
||||||
|
|
||||||
// 根据原图大小动态调整质量
|
int adjustedQuality = AdjustQualityByFileSize(originalSize, ".webp", quality);
|
||||||
int adjustedQuality = AdjustQualityByFileSize(originalSize, extension, quality);
|
|
||||||
|
|
||||||
if (extension == ".jpg" || extension == ".jpeg")
|
await image.SaveAsWebpAsync(webpThumbnailPath, new SixLabors.ImageSharp.Formats.Webp.WebpEncoder
|
||||||
{
|
{
|
||||||
await image.SaveAsJpegAsync(thumbnailPath, new SixLabors.ImageSharp.Formats.Jpeg.JpegEncoder
|
Quality = adjustedQuality,
|
||||||
{
|
Method = SixLabors.ImageSharp.Formats.Webp.WebpEncodingMethod.BestQuality
|
||||||
Quality = adjustedQuality
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
else if (extension == ".png")
|
|
||||||
{
|
|
||||||
await image.SaveAsPngAsync(thumbnailPath, new SixLabors.ImageSharp.Formats.Png.PngEncoder
|
|
||||||
{
|
|
||||||
CompressionLevel = SixLabors.ImageSharp.Formats.Png.PngCompressionLevel.BestCompression,
|
|
||||||
ColorType = SixLabors.ImageSharp.Formats.Png.PngColorType.RgbWithAlpha, // 确保使用最优的颜色类型
|
|
||||||
FilterMethod = SixLabors.ImageSharp.Formats.Png.PngFilterMethod.Adaptive // 使用自适应过滤
|
|
||||||
});
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
await image.SaveAsync(thumbnailPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
var thumbnailFileInfo = new FileInfo(thumbnailPath);
|
var thumbnailFileInfo = new FileInfo(webpThumbnailPath);
|
||||||
if (thumbnailFileInfo.Length < originalSize) return thumbnailFileInfo.Length;
|
if (thumbnailFileInfo.Length < originalSize) return thumbnailFileInfo.Length;
|
||||||
|
|
||||||
// 再次尝试优化,但不改变扩展名
|
await image.SaveAsWebpAsync(webpThumbnailPath, new SixLabors.ImageSharp.Formats.Webp.WebpEncoder
|
||||||
if (extension == ".png")
|
|
||||||
{
|
{
|
||||||
await image.SaveAsPngAsync(thumbnailPath, new SixLabors.ImageSharp.Formats.Png.PngEncoder
|
Quality = Math.Max(adjustedQuality - 15, 50),
|
||||||
{
|
Method = SixLabors.ImageSharp.Formats.Webp.WebpEncodingMethod.BestQuality
|
||||||
CompressionLevel = SixLabors.ImageSharp.Formats.Png.PngCompressionLevel.BestCompression,
|
});
|
||||||
FilterMethod = SixLabors.ImageSharp.Formats.Png.PngFilterMethod.Adaptive
|
thumbnailFileInfo = new FileInfo(webpThumbnailPath);
|
||||||
});
|
|
||||||
thumbnailFileInfo = new FileInfo(thumbnailPath);
|
|
||||||
}
|
|
||||||
else if (extension == ".jpg" || extension == ".jpeg")
|
|
||||||
{
|
|
||||||
// 如果是 JPEG,尝试降低质量进一步压缩
|
|
||||||
await image.SaveAsJpegAsync(thumbnailPath, new SixLabors.ImageSharp.Formats.Jpeg.JpegEncoder
|
|
||||||
{
|
|
||||||
Quality = Math.Max(adjustedQuality - 10, 60) // 再降低质量但不低于60
|
|
||||||
});
|
|
||||||
thumbnailFileInfo = new FileInfo(thumbnailPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
return thumbnailFileInfo.Length;
|
return thumbnailFileInfo.Length;
|
||||||
}
|
}
|
||||||
@@ -162,7 +135,16 @@ public static class ImageHelper
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private static int AdjustQualityByFileSize(long originalSize, string extension, int baseQuality)
|
private static int AdjustQualityByFileSize(long originalSize, string extension, int baseQuality)
|
||||||
{
|
{
|
||||||
if (extension == ".jpg" || extension == ".jpeg")
|
if (extension == ".webp")
|
||||||
|
{
|
||||||
|
if (originalSize > 10 * 1024 * 1024) // 10MB
|
||||||
|
return Math.Min(baseQuality, 70);
|
||||||
|
else if (originalSize > 5 * 1024 * 1024) // 5MB
|
||||||
|
return Math.Min(baseQuality, 75);
|
||||||
|
else if (originalSize > 1 * 1024 * 1024) // 1MB
|
||||||
|
return Math.Min(baseQuality, 80);
|
||||||
|
}
|
||||||
|
else if (extension == ".jpg" || extension == ".jpeg")
|
||||||
{
|
{
|
||||||
if (originalSize > 10 * 1024 * 1024) // 10MB
|
if (originalSize > 10 * 1024 * 1024) // 10MB
|
||||||
return Math.Min(baseQuality, 65);
|
return Math.Min(baseQuality, 65);
|
||||||
@@ -342,4 +324,125 @@ public static class ImageHelper
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 转换图片格式(无损转换并保留EXIF信息)
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="inputPath">输入图片路径</param>
|
||||||
|
/// <param name="outputPath">输出图片路径</param>
|
||||||
|
/// <param name="targetFormat">目标格式</param>
|
||||||
|
/// <param name="quality">压缩质量(仅对JPEG和WebP有效,1-100)</param>
|
||||||
|
/// <returns>转换后的文件路径</returns>
|
||||||
|
public static async Task<string> ConvertImageFormatAsync(string inputPath, string outputPath, ImageFormat targetFormat, int quality = 95)
|
||||||
|
{
|
||||||
|
if (targetFormat == ImageFormat.Original)
|
||||||
|
{
|
||||||
|
// 如果是原格式,直接返回输入路径
|
||||||
|
return inputPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var image = await Image.LoadAsync(inputPath);
|
||||||
|
|
||||||
|
// 保留原始EXIF信息
|
||||||
|
var originalExifProfile = image.Metadata.ExifProfile;
|
||||||
|
|
||||||
|
// 根据目标格式确定文件扩展名和输出路径
|
||||||
|
string extension = GetFileExtensionFromFormat(targetFormat);
|
||||||
|
string finalOutputPath = Path.ChangeExtension(outputPath, extension);
|
||||||
|
|
||||||
|
switch (targetFormat)
|
||||||
|
{
|
||||||
|
case ImageFormat.Jpeg:
|
||||||
|
await image.SaveAsJpegAsync(finalOutputPath, new SixLabors.ImageSharp.Formats.Jpeg.JpegEncoder
|
||||||
|
{
|
||||||
|
Quality = quality
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ImageFormat.Png:
|
||||||
|
await image.SaveAsPngAsync(finalOutputPath, new SixLabors.ImageSharp.Formats.Png.PngEncoder
|
||||||
|
{
|
||||||
|
CompressionLevel = SixLabors.ImageSharp.Formats.Png.PngCompressionLevel.BestCompression,
|
||||||
|
ColorType = SixLabors.ImageSharp.Formats.Png.PngColorType.RgbWithAlpha
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ImageFormat.WebP:
|
||||||
|
await image.SaveAsWebpAsync(finalOutputPath, new SixLabors.ImageSharp.Formats.Webp.WebpEncoder
|
||||||
|
{
|
||||||
|
Quality = quality,
|
||||||
|
Method = SixLabors.ImageSharp.Formats.Webp.WebpEncodingMethod.BestQuality
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new NotSupportedException($"不支持的图片格式: {targetFormat}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果原图有EXIF信息,保存到转换后的图片中
|
||||||
|
if (originalExifProfile != null)
|
||||||
|
{
|
||||||
|
using var convertedImage = await Image.LoadAsync(finalOutputPath);
|
||||||
|
convertedImage.Metadata.ExifProfile = originalExifProfile;
|
||||||
|
|
||||||
|
switch (targetFormat)
|
||||||
|
{
|
||||||
|
case ImageFormat.Jpeg:
|
||||||
|
await convertedImage.SaveAsJpegAsync(finalOutputPath, new SixLabors.ImageSharp.Formats.Jpeg.JpegEncoder
|
||||||
|
{
|
||||||
|
Quality = quality
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ImageFormat.Png:
|
||||||
|
await convertedImage.SaveAsPngAsync(finalOutputPath, new SixLabors.ImageSharp.Formats.Png.PngEncoder
|
||||||
|
{
|
||||||
|
CompressionLevel = SixLabors.ImageSharp.Formats.Png.PngCompressionLevel.BestCompression,
|
||||||
|
ColorType = SixLabors.ImageSharp.Formats.Png.PngColorType.RgbWithAlpha
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ImageFormat.WebP:
|
||||||
|
await convertedImage.SaveAsWebpAsync(finalOutputPath, new SixLabors.ImageSharp.Formats.Webp.WebpEncoder
|
||||||
|
{
|
||||||
|
Quality = quality,
|
||||||
|
Method = SixLabors.ImageSharp.Formats.Webp.WebpEncodingMethod.BestQuality
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return finalOutputPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 根据图片格式获取文件扩展名
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="format">图片格式</param>
|
||||||
|
/// <returns>文件扩展名</returns>
|
||||||
|
public static string GetFileExtensionFromFormat(ImageFormat format)
|
||||||
|
{
|
||||||
|
return format switch
|
||||||
|
{
|
||||||
|
ImageFormat.Jpeg => ".jpg",
|
||||||
|
ImageFormat.Png => ".png",
|
||||||
|
ImageFormat.WebP => ".webp",
|
||||||
|
_ => throw new NotSupportedException($"不支持的图片格式: {format}")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 根据图片格式获取MIME类型
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="format">图片格式</param>
|
||||||
|
/// <returns>MIME类型</returns>
|
||||||
|
public static string GetMimeTypeFromFormat(ImageFormat format)
|
||||||
|
{
|
||||||
|
return format switch
|
||||||
|
{
|
||||||
|
ImageFormat.Jpeg => "image/jpeg",
|
||||||
|
ImageFormat.Png => "image/png",
|
||||||
|
ImageFormat.WebP => "image/webp",
|
||||||
|
_ => throw new NotSupportedException($"不支持的图片格式: {format}")
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -119,6 +119,8 @@ export async function uploadPicture(
|
|||||||
data: {
|
data: {
|
||||||
permission?: number;
|
permission?: number;
|
||||||
albumId?: number;
|
albumId?: number;
|
||||||
|
convertToFormat?: string;
|
||||||
|
quality?: number;
|
||||||
onProgress?: (percent: number) => void
|
onProgress?: (percent: number) => void
|
||||||
} = {}
|
} = {}
|
||||||
): Promise<BaseResult<PictureResponse>> {
|
): Promise<BaseResult<PictureResponse>> {
|
||||||
@@ -133,6 +135,14 @@ export async function uploadPicture(
|
|||||||
formData.append('albumId', data.albumId.toString());
|
formData.append('albumId', data.albumId.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data.convertToFormat !== undefined) {
|
||||||
|
formData.append('convertToFormat', data.convertToFormat.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.quality !== undefined) {
|
||||||
|
formData.append('quality', data.quality.toString());
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
|
|||||||
+17
-3
@@ -103,10 +103,24 @@ export interface UploadFile {
|
|||||||
response?: PictureResponse; // 上传成功后的响应
|
response?: PictureResponse; // 上传成功后的响应
|
||||||
}
|
}
|
||||||
|
|
||||||
// 上传图片请求参数
|
// 图片格式类型
|
||||||
|
export type ImageFormat = 0 | 1 | 2 | 3;
|
||||||
|
|
||||||
|
// 添加常量对象提供运行时值
|
||||||
|
export const ImageFormat = {
|
||||||
|
Original: 0 as ImageFormat,
|
||||||
|
Jpeg: 1 as ImageFormat,
|
||||||
|
Png: 2 as ImageFormat,
|
||||||
|
WebP: 3 as ImageFormat
|
||||||
|
};
|
||||||
|
|
||||||
|
// 上传图片参数
|
||||||
export interface UploadPictureParams {
|
export interface UploadPictureParams {
|
||||||
permission?: number; // 权限设置,默认为0(公开)
|
permission?: number;
|
||||||
albumId?: number; // 相册ID,可选
|
albumId?: number;
|
||||||
|
convertToFormat?: ImageFormat;
|
||||||
|
quality?: number;
|
||||||
|
onProgress?: (percent: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 相册响应数据
|
// 相册响应数据
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Modal, Upload, Button, Progress, message, Form, Select, Radio, Slider } from 'antd';
|
import { Modal, Upload, Button, Progress, message, Form, Select, Radio, Slider, Divider, Alert } from 'antd';
|
||||||
import { InboxOutlined } from '@ant-design/icons';
|
import { InboxOutlined } from '@ant-design/icons';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import type { UploadFile, UploadPictureParams, AlbumResponse } from '../../api';
|
import type { UploadFile, AlbumResponse } from '../../api';
|
||||||
import { uploadPicture, getAlbums } from '../../api';
|
import { uploadPicture, getAlbums, ImageFormat } from '../../api';
|
||||||
|
|
||||||
const { Dragger } = Upload;
|
const { Dragger } = Upload;
|
||||||
const { Option } = Select;
|
const { Option } = Select;
|
||||||
@@ -20,6 +20,8 @@ const ImageUploadDialog: React.FC<UploadDialogProps> = ({ visible, onClose, onUp
|
|||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [albums, setAlbums] = useState<AlbumResponse[]>([]);
|
const [albums, setAlbums] = useState<AlbumResponse[]>([]);
|
||||||
const [concurrentUploads, setConcurrentUploads] = useState<number>(3);
|
const [concurrentUploads, setConcurrentUploads] = useState<number>(3);
|
||||||
|
const [convertFormat, setConvertFormat] = useState<ImageFormat>(ImageFormat.Original);
|
||||||
|
const [quality, setQuality] = useState<number>(95);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (visible) {
|
if (visible) {
|
||||||
@@ -75,13 +77,23 @@ const ImageUploadDialog: React.FC<UploadDialogProps> = ({ visible, onClose, onUp
|
|||||||
setUploading(true);
|
setUploading(true);
|
||||||
const values = await form.validateFields();
|
const values = await form.validateFields();
|
||||||
|
|
||||||
const params: UploadPictureParams = {};
|
const params: { // 修改此处的类型定义
|
||||||
|
permission?: number;
|
||||||
|
albumId?: number;
|
||||||
|
convertToFormat?: string; // 允许 string 类型
|
||||||
|
quality?: number;
|
||||||
|
} = {};
|
||||||
|
|
||||||
if (values.permission !== undefined) {
|
if (values.permission !== undefined) {
|
||||||
params.permission = values.permission;
|
params.permission = values.permission;
|
||||||
}
|
}
|
||||||
if (values.albumId) {
|
if (values.albumId) {
|
||||||
params.albumId = values.albumId;
|
params.albumId = values.albumId;
|
||||||
}
|
}
|
||||||
|
if (convertFormat !== ImageFormat.Original) {
|
||||||
|
params.convertToFormat = convertFormat.toString(); // 现在类型匹配
|
||||||
|
params.quality = quality;
|
||||||
|
}
|
||||||
|
|
||||||
let successCount = 0;
|
let successCount = 0;
|
||||||
let failCount = 0;
|
let failCount = 0;
|
||||||
@@ -98,7 +110,7 @@ const ImageUploadDialog: React.FC<UploadDialogProps> = ({ visible, onClose, onUp
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// 上传文件
|
// 上传文件
|
||||||
const result = await uploadPicture(item.file, {
|
const result = await uploadPicture(item.file, { // 此处 params 类型现在匹配
|
||||||
...params,
|
...params,
|
||||||
onProgress: (percent) => {
|
onProgress: (percent) => {
|
||||||
setUploadQueue((prev) =>
|
setUploadQueue((prev) =>
|
||||||
@@ -205,6 +217,17 @@ const ImageUploadDialog: React.FC<UploadDialogProps> = ({ visible, onClose, onUp
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 获取格式名称
|
||||||
|
const getFormatName = (format: ImageFormat) => {
|
||||||
|
switch (format) {
|
||||||
|
case ImageFormat.Original: return '保持原格式';
|
||||||
|
case ImageFormat.Jpeg: return 'JPEG';
|
||||||
|
case ImageFormat.Png: return 'PNG';
|
||||||
|
case ImageFormat.WebP: return 'WebP';
|
||||||
|
default: return '未知格式';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 自定义上传列表项
|
// 自定义上传列表项
|
||||||
const renderUploadItem = (item: UploadFile) => {
|
const renderUploadItem = (item: UploadFile) => {
|
||||||
let statusIcon;
|
let statusIcon;
|
||||||
@@ -305,9 +328,9 @@ const ImageUploadDialog: React.FC<UploadDialogProps> = ({ visible, onClose, onUp
|
|||||||
onClick={uploadFiles}
|
onClick={uploadFiles}
|
||||||
>
|
>
|
||||||
{uploading ? '正在上传...' : '开始上传'}
|
{uploading ? '正在上传...' : '开始上传'}
|
||||||
</Button>,
|
</Button>
|
||||||
]}
|
]}
|
||||||
width={600}
|
width={700}
|
||||||
>
|
>
|
||||||
<Form
|
<Form
|
||||||
form={form}
|
form={form}
|
||||||
@@ -336,10 +359,54 @@ const ImageUploadDialog: React.FC<UploadDialogProps> = ({ visible, onClose, onUp
|
|||||||
<Radio value={0}>公开</Radio>
|
<Radio value={0}>公开</Radio>
|
||||||
<Radio value={1}>好友可见</Radio>
|
<Radio value={1}>好友可见</Radio>
|
||||||
<Radio value={2}>仅自己</Radio>
|
<Radio value={2}>仅自己</Radio>
|
||||||
|
|
||||||
</Radio.Group>
|
</Radio.Group>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
|
<Divider orientation="left">格式转换设置</Divider>
|
||||||
|
|
||||||
|
<Form.Item label="输出格式">
|
||||||
|
<Radio.Group
|
||||||
|
value={convertFormat}
|
||||||
|
onChange={(e) => setConvertFormat(e.target.value)}
|
||||||
|
optionType="button"
|
||||||
|
buttonStyle="solid"
|
||||||
|
style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}
|
||||||
|
>
|
||||||
|
<Radio.Button value={ImageFormat.Original}>保持原格式</Radio.Button>
|
||||||
|
<Radio.Button value={ImageFormat.Jpeg}>JPEG (.jpg)</Radio.Button>
|
||||||
|
<Radio.Button value={ImageFormat.Png}>PNG (.png)</Radio.Button>
|
||||||
|
<Radio.Button value={ImageFormat.WebP}>WebP (.webp)</Radio.Button>
|
||||||
|
</Radio.Group>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
{convertFormat !== ImageFormat.Original && (
|
||||||
|
<>
|
||||||
|
{convertFormat === ImageFormat.Png ? (
|
||||||
|
<Alert
|
||||||
|
message="提示"
|
||||||
|
description={`${getFormatName(convertFormat)} 格式为无损压缩,不支持质量调节。`}
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: '16px' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Form.Item label={`图片质量 (${quality}%)`}>
|
||||||
|
<Slider
|
||||||
|
min={50}
|
||||||
|
max={100}
|
||||||
|
value={quality}
|
||||||
|
onChange={setQuality}
|
||||||
|
marks={{ 50: '50%', 75: '75%', 90: '90%', 95: '95%', 100: '100%' }}
|
||||||
|
tooltip={{ formatter: (value) => `${value}%` }}
|
||||||
|
/>
|
||||||
|
<div style={{ fontSize: '12px', color: '#666', marginTop: '4px' }}>
|
||||||
|
质量越高文件越大,建议使用 85-95% 的质量设置
|
||||||
|
</div>
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="concurrentUploads"
|
name="concurrentUploads"
|
||||||
label="并发上传数量"
|
label="并发上传数量"
|
||||||
@@ -367,6 +434,14 @@ const ImageUploadDialog: React.FC<UploadDialogProps> = ({ visible, onClose, onUp
|
|||||||
<p className="ant-upload-text">点击或拖拽图片到此区域上传</p>
|
<p className="ant-upload-text">点击或拖拽图片到此区域上传</p>
|
||||||
<p className="ant-upload-hint">
|
<p className="ant-upload-hint">
|
||||||
支持单个或批量上传,图片大小不超过10MB
|
支持单个或批量上传,图片大小不超过10MB
|
||||||
|
{convertFormat !== ImageFormat.Original && (
|
||||||
|
<>
|
||||||
|
<br />
|
||||||
|
<span style={{ color: '#1890ff' }}>
|
||||||
|
将转换为 {getFormatName(convertFormat)} 格式
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</Dragger>
|
</Dragger>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user