mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-07-09 06:21:35 +08:00
feat(storage): implement storage management API and enhance storage mode handling
This commit is contained in:
193
Api/Management/StorageManagementController.cs
Normal file
193
Api/Management/StorageManagementController.cs
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
using Foxel.Controllers;
|
||||||
|
using Foxel.Models;
|
||||||
|
using Foxel.Models.Request.Storage;
|
||||||
|
using Foxel.Models.Response.Storage;
|
||||||
|
using Foxel.Services.Attributes;
|
||||||
|
using Foxel.Services.Management;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Foxel.Api.Management;
|
||||||
|
|
||||||
|
[Authorize(Roles = "Administrator")]
|
||||||
|
[Route("api/management/storage")]
|
||||||
|
public class StorageManagementController(IStorageManagementService storageManagementService) : BaseApiController
|
||||||
|
{
|
||||||
|
[HttpGet("get_modes")]
|
||||||
|
public async Task<ActionResult<PaginatedResult<StorageModeResponse>>> GetStorageModes(
|
||||||
|
[FromQuery] int page = 1,
|
||||||
|
[FromQuery] int pageSize = 10,
|
||||||
|
[FromQuery] string? searchQuery = null,
|
||||||
|
[FromQuery] StorageType? storageType = null,
|
||||||
|
[FromQuery] bool? isEnabled = null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result =
|
||||||
|
await storageManagementService.GetStorageModesAsync(page, pageSize, searchQuery, storageType,
|
||||||
|
isEnabled);
|
||||||
|
return PaginatedSuccess(result.Data, result.TotalCount, result.Page, result.PageSize);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return PaginatedError<StorageModeResponse>($"Failed to get storage modes: {ex.Message}", 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("get_mode/{id}")]
|
||||||
|
public async Task<ActionResult<BaseResult<StorageModeResponse>>> GetStorageModeById(int id)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await storageManagementService.GetStorageModeByIdAsync(id);
|
||||||
|
return Success(result, "Storage mode retrieved successfully.");
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException ex)
|
||||||
|
{
|
||||||
|
return Error<StorageModeResponse>(ex.Message, 404);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Error<StorageModeResponse>($"Failed to get storage mode: {ex.Message}", 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("create_mode")]
|
||||||
|
public async Task<ActionResult<BaseResult<StorageModeResponse>>> CreateStorageMode(
|
||||||
|
[FromBody] CreateStorageModeRequest request)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await storageManagementService.CreateStorageModeAsync(request);
|
||||||
|
return Success(result, "Storage mode created successfully.");
|
||||||
|
}
|
||||||
|
catch (ArgumentException ex)
|
||||||
|
{
|
||||||
|
return Error<StorageModeResponse>(ex.Message, 400);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Error<StorageModeResponse>($"Failed to create storage mode: {ex.Message}", 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("update_mode")]
|
||||||
|
public async Task<ActionResult<BaseResult<StorageModeResponse>>> UpdateStorageMode(
|
||||||
|
[FromBody] UpdateStorageModeRequest request)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await storageManagementService.UpdateStorageModeAsync(request);
|
||||||
|
return Success(result, "Storage mode updated successfully.");
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException ex)
|
||||||
|
{
|
||||||
|
return Error<StorageModeResponse>(ex.Message, 404);
|
||||||
|
}
|
||||||
|
catch (ArgumentException ex)
|
||||||
|
{
|
||||||
|
return Error<StorageModeResponse>(ex.Message, 400);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Error<StorageModeResponse>($"Failed to update storage mode: {ex.Message}", 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("delete_mode")]
|
||||||
|
public async Task<ActionResult<BaseResult<bool>>> DeleteStorageMode([FromBody] int id)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await storageManagementService.DeleteStorageModeAsync(id);
|
||||||
|
return Success(result, "Storage mode deleted successfully.");
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException ex)
|
||||||
|
{
|
||||||
|
return Error<bool>(ex.Message, 404);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex) // Catch specific exception for "in use"
|
||||||
|
{
|
||||||
|
return Error<bool>(ex.Message, 400);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Error<bool>($"Failed to delete storage mode: {ex.Message}", 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("batch_delete_modes")]
|
||||||
|
public async Task<ActionResult<BaseResult<BatchDeleteResult>>> BatchDeleteStorageModes([FromBody] List<int> ids)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (ids == null || !ids.Any())
|
||||||
|
{
|
||||||
|
return Error<BatchDeleteResult>("No IDs provided for batch deletion.", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await storageManagementService.BatchDeleteStorageModesAsync(ids);
|
||||||
|
return Success(result,
|
||||||
|
$"Batch delete completed. Succeeded: {result.SuccessCount}, Failed: {result.FailedCount}.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Error<BatchDeleteResult>($"Batch delete failed: {ex.Message}", 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("get_storage_types")]
|
||||||
|
public async Task<ActionResult<BaseResult<IEnumerable<StorageTypeResponse>>>> GetStorageTypes()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await storageManagementService.GetStorageTypesAsync();
|
||||||
|
return Success(result, "Storage types retrieved successfully.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Error<IEnumerable<StorageTypeResponse>>($"Failed to get storage types: {ex.Message}", 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("get_default_mode_id")]
|
||||||
|
public async Task<ActionResult<BaseResult<int?>>> GetDefaultStorageModeId()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await storageManagementService.GetDefaultStorageModeIdAsync();
|
||||||
|
if (result.HasValue)
|
||||||
|
{
|
||||||
|
return Success<int?>(result.Value, "Default storage mode ID retrieved successfully.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Success<int?>(null, "No default storage mode is currently set or the configured one is invalid.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Error<int?>($"Failed to get default storage mode ID: {ex.Message}", 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("set_default_mode/{id}")]
|
||||||
|
public async Task<ActionResult<BaseResult<bool>>> SetDefaultStorageMode(int id)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await storageManagementService.SetDefaultStorageModeAsync(id);
|
||||||
|
return Success(result, $"Default storage mode set to ID {id} successfully.");
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException ex)
|
||||||
|
{
|
||||||
|
return Error<bool>(ex.Message, 404);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex)
|
||||||
|
{
|
||||||
|
return Error<bool>(ex.Message, 400);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Error<bool>($"Failed to set default storage mode: {ex.Message}", 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,22 +1,23 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using System.Text.Json.Serialization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Foxel.Controllers;
|
||||||
using Foxel.Models;
|
using Foxel.Models;
|
||||||
using Foxel.Models.DataBase;
|
using Foxel.Models.DataBase;
|
||||||
using Foxel.Models.Request.Picture;
|
using Foxel.Models.Request.Picture;
|
||||||
using Foxel.Models.Response.Picture;
|
using Foxel.Models.Response.Picture;
|
||||||
using System.Text.Json;
|
|
||||||
using System.Text.Json.Serialization;
|
|
||||||
using Foxel.Services.Media;
|
using Foxel.Services.Media;
|
||||||
using Foxel.Services.Storage;
|
using Foxel.Services.Storage;
|
||||||
using System.IO;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Foxel.Services.Attributes;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using System.Text.Json; // Added for JsonSerializer in GetTelegramFile if it were kept
|
||||||
|
|
||||||
namespace Foxel.Controllers;
|
namespace Foxel.Api;
|
||||||
|
|
||||||
[Authorize]
|
[Authorize]
|
||||||
[Route("api/picture")]
|
[Route("api/picture")]
|
||||||
public class PictureController(IPictureService pictureService, IStorageService storageService) : BaseApiController
|
public class PictureController(IPictureService pictureService, IStorageService storageService, ILogger<PictureController> logger) : BaseApiController
|
||||||
{
|
{
|
||||||
|
private readonly ILogger<PictureController> _logger = logger;
|
||||||
|
|
||||||
[HttpGet("get_pictures")]
|
[HttpGet("get_pictures")]
|
||||||
public async Task<ActionResult<PaginatedResult<PictureResponse>>> GetPictures(
|
public async Task<ActionResult<PaginatedResult<PictureResponse>>> GetPictures(
|
||||||
[FromQuery] FilteredPicturesRequest request)
|
[FromQuery] FilteredPicturesRequest request)
|
||||||
@@ -81,7 +82,7 @@ public class PictureController(IPictureService pictureService, IStorageService s
|
|||||||
userId,
|
userId,
|
||||||
(PermissionType)request.Permission!,
|
(PermissionType)request.Permission!,
|
||||||
request.AlbumId,
|
request.AlbumId,
|
||||||
request.StorageType
|
request.StorageModeId
|
||||||
);
|
);
|
||||||
|
|
||||||
var picture = result.Picture;
|
var picture = result.Picture;
|
||||||
@@ -260,92 +261,76 @@ public class PictureController(IPictureService pictureService, IStorageService s
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("get_telegram_file")]
|
[HttpGet("file/{pictureId}")]
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
public async Task<IActionResult> GetTelegramFile([FromQuery] string fileId)
|
public async Task<IActionResult> GetPictureFile(int pictureId)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// 创建一个模拟的存储元数据
|
var picture = await pictureService.GetPictureByIdAsync(pictureId);
|
||||||
var metadata = new
|
if (picture == null)
|
||||||
{
|
{
|
||||||
FileId = fileId,
|
_logger.LogWarning("GetPictureFile: Picture with ID {PictureId} not found.", pictureId);
|
||||||
OriginalFileName = "telegram_file"
|
return NotFound("Picture not found.");
|
||||||
};
|
|
||||||
|
|
||||||
// 序列化为 JSON 字符串,与 TelegramStorageProvider 中的格式保持一致
|
|
||||||
string storagePath = JsonSerializer.Serialize(metadata);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// 使用 storageService 下载文件,这样会自动使用配置的代理
|
|
||||||
string tempFilePath = await storageService.ExecuteAsync(StorageType.Telegram,
|
|
||||||
provider => provider.DownloadFileAsync(storagePath));
|
|
||||||
|
|
||||||
// 获取文件内容类型
|
|
||||||
string contentType = GetContentTypeFromPath(tempFilePath);
|
|
||||||
|
|
||||||
// 返回文件
|
|
||||||
return PhysicalFile(tempFilePath, contentType, Path.GetFileName(tempFilePath));
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
var currentUserId = GetCurrentUserId();
|
||||||
|
if (picture.Permission != PermissionType.Public)
|
||||||
{
|
{
|
||||||
return StatusCode(500, $"下载 Telegram 文件失败: {ex.Message}");
|
if (currentUserId == null || picture.UserId != currentUserId.Value)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("GetPictureFile: User {UserId} forbidden to access picture {PictureId}.", currentUserId, pictureId);
|
||||||
|
return Forbid();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3. 使用 StorageService 下载文件
|
||||||
|
string tempFilePath = await storageService.ExecuteAsync(
|
||||||
|
picture.StorageModeId,
|
||||||
|
provider => provider.DownloadFileAsync(picture.Path)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(tempFilePath) || !System.IO.File.Exists(tempFilePath))
|
||||||
|
{
|
||||||
|
_logger.LogError("GetPictureFile: Failed to download file or file not found at temp path for picture ID {PictureId}. TempPath: {TempPath}", pictureId, tempFilePath);
|
||||||
|
return StatusCode(500, "Failed to retrieve file from storage.");
|
||||||
|
}
|
||||||
|
// 4. 确定内容类型
|
||||||
|
string contentType = GetContentTypeFromPath(tempFilePath);
|
||||||
|
|
||||||
|
// 5. 返回文件
|
||||||
|
return PhysicalFile(tempFilePath, contentType, Path.GetFileName(picture.Name));
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException knfEx)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(knfEx, "GetPictureFile: Resource not found for picture ID {PictureId}.", pictureId);
|
||||||
|
return NotFound($"Resource related to picture ID {pictureId} not found.");
|
||||||
|
}
|
||||||
|
catch (FileNotFoundException fnfEx)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(fnfEx, "GetPictureFile: File not found in storage for picture ID {PictureId}.", pictureId);
|
||||||
|
return NotFound("File not found in storage.");
|
||||||
|
}
|
||||||
|
catch (NotImplementedException niEx)
|
||||||
|
{
|
||||||
|
_logger.LogError(niEx, "GetPictureFile: DownloadFileAsync not implemented for the storage provider of picture ID {PictureId}.", pictureId);
|
||||||
|
return StatusCode(501, "File download is not supported for this storage type.");
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ioEx)
|
||||||
|
{
|
||||||
|
_logger.LogError(ioEx, "GetPictureFile: Invalid operation for picture ID {PictureId}.", pictureId);
|
||||||
|
return StatusCode(500, $"Error processing file request: {ioEx.Message}");
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
return StatusCode(500, $"代理获取文件失败: {ex.Message}");
|
_logger.LogError(ex, "GetPictureFile: Error getting file for picture ID {PictureId}", pictureId);
|
||||||
}
|
return StatusCode(500, "An error occurred while retrieving the file.");
|
||||||
}
|
|
||||||
|
|
||||||
// 用于解析 Telegram getFile API 响应的辅助类
|
|
||||||
private class TelegramGetFileResponse
|
|
||||||
{
|
|
||||||
[JsonPropertyName("ok")]
|
|
||||||
public bool Ok { get; set; }
|
|
||||||
|
|
||||||
[JsonPropertyName("result")]
|
|
||||||
public TelegramFileResult? Result { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
private class TelegramFileResult
|
|
||||||
{
|
|
||||||
[JsonPropertyName("file_path")]
|
|
||||||
public string? FilePath { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpGet("proxy")]
|
|
||||||
[AllowAnonymous]
|
|
||||||
public async Task<IActionResult> GetWebDavFile([FromQuery] string path)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(path))
|
|
||||||
{
|
|
||||||
return BadRequest("文件路径不能为空");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 下载文件到临时位置
|
|
||||||
string filePath = await storageService.ExecuteAsync(StorageType.WebDAV,
|
|
||||||
provider => provider.DownloadFileAsync(path));
|
|
||||||
|
|
||||||
// 确定内容类型
|
|
||||||
string contentType = GetContentTypeFromPath(path);
|
|
||||||
|
|
||||||
// 返回文件内容
|
|
||||||
return PhysicalFile(filePath, contentType, Path.GetFileName(path));
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return StatusCode(500, $"代理获取WebDAV文件失败: {ex.Message}");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private string GetContentTypeFromPath(string path)
|
private string GetContentTypeFromPath(string path)
|
||||||
{
|
{
|
||||||
string extension = Path.GetExtension(path).ToLowerInvariant();
|
string extension = Path.GetExtension(path).ToLowerInvariant();
|
||||||
|
|
||||||
return extension switch
|
return extension switch
|
||||||
{
|
{
|
||||||
".jpg" or ".jpeg" => "image/jpeg",
|
".jpg" or ".jpeg" => "image/jpeg",
|
||||||
|
|||||||
@@ -31,13 +31,9 @@ public static class ServiceCollectionExtensions
|
|||||||
services.AddSingleton<IUserManagementService, UserManagementService>();
|
services.AddSingleton<IUserManagementService, UserManagementService>();
|
||||||
services.AddSingleton<IPictureManagementService, PictureManagementService>();
|
services.AddSingleton<IPictureManagementService, PictureManagementService>();
|
||||||
services.AddSingleton<ILogManagementService, LogManagementService>();
|
services.AddSingleton<ILogManagementService, LogManagementService>();
|
||||||
|
services.AddSingleton<IStorageManagementService, StorageManagementService>();
|
||||||
services.AddSingleton<IBackgroundTaskQueue, BackgroundTaskQueue>();
|
services.AddSingleton<IBackgroundTaskQueue, BackgroundTaskQueue>();
|
||||||
services.AddHostedService<QueuedHostedService>();
|
services.AddHostedService<QueuedHostedService>();
|
||||||
services.AddSingleton<LocalStorageProvider>();
|
|
||||||
services.AddSingleton<TelegramStorageProvider>();
|
|
||||||
services.AddSingleton<S3StorageProvider>();
|
|
||||||
services.AddSingleton<CosStorageProvider>();
|
|
||||||
services.AddSingleton<WebDavStorageProvider>();
|
|
||||||
services.AddSingleton<IStorageService, StorageService>();
|
services.AddSingleton<IStorageService, StorageService>();
|
||||||
services.AddSingleton<PictureTaskProcessor>();
|
services.AddSingleton<PictureTaskProcessor>();
|
||||||
services.AddSingleton<VisualRecognitionTaskProcessor>();
|
services.AddSingleton<VisualRecognitionTaskProcessor>();
|
||||||
|
|||||||
@@ -35,7 +35,9 @@ public class Picture : BaseModel
|
|||||||
set => ExifInfoJson = value != null ? JsonSerializer.Serialize(value) : null;
|
set => ExifInfoJson = value != null ? JsonSerializer.Serialize(value) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public StorageType StorageType { get; set; } = StorageType.Local;
|
public int StorageModeId { get; set; }
|
||||||
|
[ForeignKey("StorageModeId")]
|
||||||
|
public StorageMode? StorageMode { get; set; } = null!;
|
||||||
|
|
||||||
public ICollection<Tag>? Tags { get; set; }
|
public ICollection<Tag>? Tags { get; set; }
|
||||||
public int? UserId { get; set; }
|
public int? UserId { get; set; }
|
||||||
|
|||||||
16
Models/DataBase/StorageMode.cs
Normal file
16
Models/DataBase/StorageMode.cs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
using Foxel.Services.Attributes;
|
||||||
|
|
||||||
|
namespace Foxel.Models.DataBase;
|
||||||
|
|
||||||
|
public class StorageMode : BaseModel
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
[StringLength(100)]
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public bool IsEnabled { get; set; } = true;
|
||||||
|
|
||||||
|
public StorageType StorageType { get; set; } = StorageType.Local;
|
||||||
|
[Column(TypeName = "jsonb")] public string? ConfigurationJson { get; set; }
|
||||||
|
}
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using Foxel.Models.DataBase;
|
|
||||||
using Foxel.Models.Enums;
|
|
||||||
using Foxel.Services.Attributes;
|
|
||||||
|
|
||||||
namespace Foxel.Models.Request.Picture;
|
namespace Foxel.Models.Request.Picture;
|
||||||
|
|
||||||
@@ -15,5 +13,5 @@ public record UploadPictureRequest
|
|||||||
|
|
||||||
public int? AlbumId { get; set; }
|
public int? AlbumId { get; set; }
|
||||||
|
|
||||||
public StorageType? StorageType { get; set; }
|
public int? StorageModeId { get; set; }
|
||||||
}
|
}
|
||||||
18
Models/Request/Storage/CreateStorageModeRequest.cs
Normal file
18
Models/Request/Storage/CreateStorageModeRequest.cs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using Foxel.Services.Attributes; // For StorageType enum
|
||||||
|
|
||||||
|
namespace Foxel.Models.Request.Storage;
|
||||||
|
|
||||||
|
public class CreateStorageModeRequest
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
[StringLength(100)]
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public StorageType StorageType { get; set; }
|
||||||
|
|
||||||
|
public string? ConfigurationJson { get; set; }
|
||||||
|
|
||||||
|
public bool IsEnabled { get; set; } = true;
|
||||||
|
}
|
||||||
21
Models/Request/Storage/UpdateStorageModeRequest.cs
Normal file
21
Models/Request/Storage/UpdateStorageModeRequest.cs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using Foxel.Services.Attributes; // For StorageType enum
|
||||||
|
|
||||||
|
namespace Foxel.Models.Request.Storage;
|
||||||
|
|
||||||
|
public class UpdateStorageModeRequest
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[StringLength(100)]
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public StorageType StorageType { get; set; }
|
||||||
|
|
||||||
|
public string? ConfigurationJson { get; set; }
|
||||||
|
|
||||||
|
public bool IsEnabled { get; set; }
|
||||||
|
}
|
||||||
@@ -21,4 +21,6 @@ public record PictureResponse
|
|||||||
public int? AlbumId { get; set; }
|
public int? AlbumId { get; set; }
|
||||||
public string? AlbumName { get; set; }
|
public string? AlbumName { get; set; }
|
||||||
public PermissionType Permission { get; set; } = PermissionType.Public;
|
public PermissionType Permission { get; set; } = PermissionType.Public;
|
||||||
|
public string? StorageModeName { get; set; }
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
15
Models/Response/Storage/StorageModeResponse.cs
Normal file
15
Models/Response/Storage/StorageModeResponse.cs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
using Foxel.Services.Attributes; // For StorageType enum
|
||||||
|
|
||||||
|
namespace Foxel.Models.Response.Storage;
|
||||||
|
|
||||||
|
public class StorageModeResponse
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public StorageType StorageType { get; set; }
|
||||||
|
public string StorageTypeName => StorageType.ToString();
|
||||||
|
public string? ConfigurationJson { get; set; } // Consider if this should be exposed or masked/summarized
|
||||||
|
public bool IsEnabled { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
public DateTime UpdatedAt { get; set; }
|
||||||
|
}
|
||||||
7
Models/Response/Storage/StorageTypeResponse.cs
Normal file
7
Models/Response/Storage/StorageTypeResponse.cs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
namespace Foxel.Models.Response.Storage;
|
||||||
|
|
||||||
|
public class StorageTypeResponse
|
||||||
|
{
|
||||||
|
public int Value { get; set; }
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -14,4 +14,5 @@ public class MyDbContext(DbContextOptions<MyDbContext> options) : DbContext(opti
|
|||||||
public DbSet<Role> Roles { get; set; } = null!;
|
public DbSet<Role> Roles { get; set; } = null!;
|
||||||
public DbSet<Log> Logs { get; set; } = null!;
|
public DbSet<Log> Logs { get; set; } = null!;
|
||||||
public DbSet<BackgroundTask> BackgroundTasks { get; set; } = null!;
|
public DbSet<BackgroundTask> BackgroundTasks { get; set; } = null!;
|
||||||
|
public DbSet<StorageMode> StorageModes { get; set; } = null!;
|
||||||
}
|
}
|
||||||
@@ -1,49 +1,30 @@
|
|||||||
using Foxel.Models.DataBase;
|
using Foxel.Models.DataBase;
|
||||||
using Foxel.Services.AI;
|
|
||||||
using Foxel.Services.Storage;
|
using Foxel.Services.Storage;
|
||||||
using Foxel.Services.VectorDB;
|
|
||||||
using Foxel.Utils;
|
using Foxel.Utils;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Foxel.Services.Attributes;
|
|
||||||
using Foxel.Services.Background; // Added for IBackgroundTaskQueue
|
|
||||||
|
|
||||||
namespace Foxel.Services.Background.Processors
|
namespace Foxel.Services.Background.Processors
|
||||||
{
|
{
|
||||||
public class PictureProcessingPayload // Ensure this is defined or imported
|
public class PictureProcessingPayload
|
||||||
{
|
{
|
||||||
public int PictureId { get; set; }
|
public int PictureId { get; set; }
|
||||||
public string OriginalFilePath { get; set; } = string.Empty;
|
public string OriginalFilePath { get; set; } = string.Empty;
|
||||||
public int? UserIdForPicture { get; set; }
|
public int? UserIdForPicture { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class PictureTaskProcessor(
|
||||||
|
IDbContextFactory<MyDbContext> contextFactory,
|
||||||
public class PictureTaskProcessor : ITaskProcessor
|
IServiceProvider serviceProvider,
|
||||||
|
ILogger<PictureTaskProcessor> logger)
|
||||||
|
: ITaskProcessor
|
||||||
{
|
{
|
||||||
private readonly IDbContextFactory<MyDbContext> _contextFactory;
|
|
||||||
private readonly IServiceProvider _serviceProvider;
|
|
||||||
private readonly ILogger<PictureTaskProcessor> _logger;
|
|
||||||
private readonly IWebHostEnvironment _environment;
|
|
||||||
|
|
||||||
public PictureTaskProcessor(
|
|
||||||
IDbContextFactory<MyDbContext> contextFactory,
|
|
||||||
IServiceProvider serviceProvider,
|
|
||||||
ILogger<PictureTaskProcessor> logger,
|
|
||||||
IWebHostEnvironment environment)
|
|
||||||
{
|
|
||||||
_contextFactory = contextFactory;
|
|
||||||
_serviceProvider = serviceProvider;
|
|
||||||
_logger = logger;
|
|
||||||
_environment = environment;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task ProcessAsync(BackgroundTask backgroundTask)
|
public async Task ProcessAsync(BackgroundTask backgroundTask)
|
||||||
{
|
{
|
||||||
if (backgroundTask.Payload == null)
|
if (backgroundTask.Payload == null)
|
||||||
{
|
{
|
||||||
await UpdateTaskStatusInDb(backgroundTask.Id, TaskExecutionStatus.Failed, 0, "任务 Payload 为空。");
|
await UpdateTaskStatusInDb(backgroundTask.Id, TaskExecutionStatus.Failed, 0, "任务 Payload 为空。");
|
||||||
_logger.LogError("任务 Payload 为空: TaskId={TaskId}", backgroundTask.Id);
|
logger.LogError("任务 Payload 为空: TaskId={TaskId}", backgroundTask.Id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,125 +35,139 @@ namespace Foxel.Services.Background.Processors
|
|||||||
}
|
}
|
||||||
catch (JsonException ex)
|
catch (JsonException ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "无法解析图片处理任务的 Payload: TaskId={TaskId}", backgroundTask.Id);
|
logger.LogError(ex, "无法解析图片处理任务的 Payload: TaskId={TaskId}", backgroundTask.Id);
|
||||||
await UpdateTaskStatusInDb(backgroundTask.Id, TaskExecutionStatus.Failed, 0, "Payload 解析失败。");
|
await UpdateTaskStatusInDb(backgroundTask.Id, TaskExecutionStatus.Failed, 0, "Payload 解析失败。");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payload == null || payload.PictureId == 0)
|
if (payload == null || payload.PictureId == 0)
|
||||||
{
|
{
|
||||||
_logger.LogError("图片处理任务的 Payload 无效或缺少 PictureId: TaskId={TaskId}", backgroundTask.Id);
|
logger.LogError("图片处理任务的 Payload 无效或缺少 PictureId: TaskId={TaskId}", backgroundTask.Id);
|
||||||
await UpdateTaskStatusInDb(backgroundTask.Id, TaskExecutionStatus.Failed, 0, "Payload 无效或缺少 PictureId。");
|
await UpdateTaskStatusInDb(backgroundTask.Id, TaskExecutionStatus.Failed, 0,
|
||||||
|
"Payload 无效或缺少 PictureId。");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var pictureId = payload.PictureId;
|
var pictureId = payload.PictureId;
|
||||||
var originalFilePathFromPayload = payload.OriginalFilePath;
|
var storageKeyForOriginalFile = payload.OriginalFilePath;
|
||||||
string localFilePath = "";
|
string localFilePath = "";
|
||||||
bool isTempFile = false;
|
bool isTempFile = false;
|
||||||
// string thumbnailForAI = string.Empty; // No longer directly used for AI here
|
|
||||||
|
|
||||||
await using var dbContext = await _contextFactory.CreateDbContextAsync();
|
await using var dbContext = await contextFactory.CreateDbContextAsync();
|
||||||
var currentBackgroundTaskState = await dbContext.BackgroundTasks.FindAsync(backgroundTask.Id);
|
var currentBackgroundTaskState = await dbContext.BackgroundTasks.FindAsync(backgroundTask.Id);
|
||||||
if (currentBackgroundTaskState == null)
|
if (currentBackgroundTaskState == null)
|
||||||
{
|
{
|
||||||
_logger.LogError("在 PictureTaskProcessor 中找不到后台任务: TaskId={TaskId}", backgroundTask.Id);
|
logger.LogError("在 PictureTaskProcessor 中找不到后台任务: TaskId={TaskId}", backgroundTask.Id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var picture = await dbContext.Pictures.Include(p => p.User).FirstOrDefaultAsync(p => p.Id == pictureId);
|
// Include StorageMode to access its StorageType
|
||||||
|
var picture = await dbContext.Pictures
|
||||||
|
.Include(p => p.User)
|
||||||
|
.Include(p => p.StorageMode)
|
||||||
|
.FirstOrDefaultAsync(p => p.Id == pictureId);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Processing, 10, currentBackgroundTaskState: currentBackgroundTaskState);
|
await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Processing, 10,
|
||||||
|
currentBackgroundTaskState: currentBackgroundTaskState);
|
||||||
|
|
||||||
if (picture == null)
|
if (picture == null)
|
||||||
{
|
{
|
||||||
throw new Exception($"找不到ID为{pictureId}的图片。");
|
throw new Exception($"找不到ID为{pictureId}的图片。");
|
||||||
}
|
}
|
||||||
|
|
||||||
using var scope = _serviceProvider.CreateScope();
|
if (picture.StorageMode == null || picture.StorageModeId < 0)
|
||||||
|
{
|
||||||
|
throw new Exception($"图片ID {pictureId} 缺少有效的 StorageMode 配置。");
|
||||||
|
}
|
||||||
|
|
||||||
|
using var scope = serviceProvider.CreateScope();
|
||||||
var storageService = scope.ServiceProvider.GetRequiredService<IStorageService>();
|
var storageService = scope.ServiceProvider.GetRequiredService<IStorageService>();
|
||||||
|
|
||||||
string contentRootPath = _environment.ContentRootPath;
|
if (picture.StorageMode.StorageType == Attributes.StorageType.Local)
|
||||||
|
|
||||||
if (picture.StorageType == StorageType.Local)
|
|
||||||
{
|
{
|
||||||
localFilePath = Path.Combine(contentRootPath, originalFilePathFromPayload.TrimStart('/'));
|
logger.LogInformation(
|
||||||
}
|
"Picture {PictureId} is Local. Attempting to download via StorageService for consistency.",
|
||||||
else
|
pictureId);
|
||||||
{
|
|
||||||
await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Processing, 25, currentBackgroundTaskState: currentBackgroundTaskState); // Adjusted progress
|
|
||||||
localFilePath = await storageService.ExecuteAsync(picture.StorageType,
|
|
||||||
provider => provider.DownloadFileAsync(originalFilePathFromPayload));
|
|
||||||
isTempFile = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Processing, 25,
|
||||||
|
currentBackgroundTaskState: currentBackgroundTaskState);
|
||||||
|
localFilePath = await storageService.ExecuteAsync(picture.StorageModeId,
|
||||||
|
provider => provider.DownloadFileAsync(storageKeyForOriginalFile)); // Use storageKeyForOriginalFile
|
||||||
|
isTempFile = true;
|
||||||
if (string.IsNullOrEmpty(localFilePath) || !File.Exists(localFilePath))
|
if (string.IsNullOrEmpty(localFilePath) || !File.Exists(localFilePath))
|
||||||
{
|
{
|
||||||
throw new Exception($"找不到图片文件: {localFilePath} (源路径: {originalFilePathFromPayload})");
|
throw new Exception($"找不到图片文件: {localFilePath} (源存储路径: {storageKeyForOriginalFile})");
|
||||||
}
|
}
|
||||||
|
|
||||||
await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Processing, 50, currentBackgroundTaskState: currentBackgroundTaskState); // Adjusted progress
|
await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Processing, 50,
|
||||||
|
currentBackgroundTaskState: currentBackgroundTaskState);
|
||||||
if (string.IsNullOrEmpty(picture.ThumbnailPath))
|
if (string.IsNullOrEmpty(picture.ThumbnailPath))
|
||||||
{
|
{
|
||||||
var tempThumbContainer = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
|
var tempThumbContainer = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
|
||||||
Directory.CreateDirectory(tempThumbContainer);
|
Directory.CreateDirectory(tempThumbContainer);
|
||||||
|
string baseNameFromOriginalStorageKey = Path.GetFileNameWithoutExtension(picture.OriginalPath);
|
||||||
// Derive baseName from OriginalPath (which is in originalFilePathFromPayload)
|
var thumbnailDiskPath = Path.Combine(tempThumbContainer,
|
||||||
// originalFilePathFromPayload is the stored path/key, not a local path.
|
$"{baseNameFromOriginalStorageKey}-thumbnail-temp.webp");
|
||||||
// We need the base name (UUID part) from the picture's OriginalPath.
|
|
||||||
string baseNameFromOriginalPath = Path.GetFileNameWithoutExtension(picture.OriginalPath);
|
|
||||||
|
|
||||||
var thumbnailDiskPath = Path.Combine(tempThumbContainer, $"{baseNameFromOriginalPath}-thumbnail-temp.webp");
|
|
||||||
|
|
||||||
await ImageHelper.CreateThumbnailAsync(localFilePath, thumbnailDiskPath, 500);
|
await ImageHelper.CreateThumbnailAsync(localFilePath, thumbnailDiskPath, 500);
|
||||||
// thumbnailForAI = thumbnailDiskPath; // This temp path is for AI, but AI is in next step
|
await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Processing, 65,
|
||||||
|
currentBackgroundTaskState: currentBackgroundTaskState);
|
||||||
await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Processing, 65, currentBackgroundTaskState: currentBackgroundTaskState); // Adjusted progress
|
await using var thumbnailFileStream =
|
||||||
|
new FileStream(thumbnailDiskPath, FileMode.Open, FileAccess.Read);
|
||||||
await using var thumbnailFileStream = new FileStream(thumbnailDiskPath, FileMode.Open, FileAccess.Read);
|
var thumbnailStorageFileName = $"{baseNameFromOriginalStorageKey}-thumbnail.webp";
|
||||||
// Use the new naming convention for storage
|
|
||||||
var thumbnailStorageFileName = $"{baseNameFromOriginalPath}-thumbnail.webp";
|
|
||||||
|
|
||||||
string storedThumbnailPath = await storageService.ExecuteAsync(
|
string storedThumbnailPath = await storageService.ExecuteAsync(
|
||||||
picture.StorageType,
|
picture.StorageModeId,
|
||||||
provider => provider.SaveAsync(thumbnailFileStream, thumbnailStorageFileName, "image/webp"));
|
provider => provider.SaveAsync(thumbnailFileStream, thumbnailStorageFileName, "image/webp"));
|
||||||
picture.ThumbnailPath = storedThumbnailPath;
|
picture.ThumbnailPath = storedThumbnailPath;
|
||||||
|
|
||||||
if (Directory.Exists(tempThumbContainer)) Directory.Delete(tempThumbContainer, true);
|
if (Directory.Exists(tempThumbContainer)) Directory.Delete(tempThumbContainer, true);
|
||||||
}
|
}
|
||||||
await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Processing, 80, currentBackgroundTaskState: currentBackgroundTaskState); // Adjusted progress
|
|
||||||
|
await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Processing, 80,
|
||||||
|
currentBackgroundTaskState: currentBackgroundTaskState);
|
||||||
var exifInfo = await ImageHelper.ExtractExifInfoAsync(localFilePath);
|
var exifInfo = await ImageHelper.ExtractExifInfoAsync(localFilePath);
|
||||||
picture.ExifInfo = exifInfo;
|
picture.ExifInfo = exifInfo;
|
||||||
picture.TakenAt = ImageHelper.ParseExifDateTime(exifInfo.DateTimeOriginal);
|
picture.TakenAt = ImageHelper.ParseExifDateTime(exifInfo.DateTimeOriginal);
|
||||||
await dbContext.SaveChangesAsync();
|
await dbContext.SaveChangesAsync();
|
||||||
await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Completed, 100, completedAt: DateTime.UtcNow, currentBackgroundTaskState: currentBackgroundTaskState);
|
await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Completed, 100,
|
||||||
|
completedAt: DateTime.UtcNow, currentBackgroundTaskState: currentBackgroundTaskState);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "图片元数据处理任务失败: TaskId={TaskId}, PictureId={PictureId}", currentBackgroundTaskState.Id, pictureId);
|
logger.LogError(ex, "图片元数据处理任务失败: TaskId={TaskId}, PictureId={PictureId}",
|
||||||
await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Failed, currentBackgroundTaskState.Progress, ex.Message, currentBackgroundTaskState: currentBackgroundTaskState);
|
currentBackgroundTaskState.Id, pictureId);
|
||||||
|
await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Failed,
|
||||||
|
currentBackgroundTaskState.Progress, ex.Message,
|
||||||
|
currentBackgroundTaskState: currentBackgroundTaskState);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
if (isTempFile && File.Exists(localFilePath))
|
if (isTempFile && File.Exists(localFilePath))
|
||||||
{
|
{
|
||||||
try { File.Delete(localFilePath); } catch (Exception ex) { _logger.LogWarning(ex, "删除临时主图片文件失败: {FilePath}", localFilePath); }
|
try
|
||||||
|
{
|
||||||
|
File.Delete(localFilePath);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "删除临时主图片文件失败: {FilePath}", localFilePath);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task UpdateTaskStatusInDb(Guid taskId, TaskExecutionStatus status, int progress, string? error = null, DateTime? startedAt = null, DateTime? completedAt = null, BackgroundTask? currentBackgroundTaskState = null)
|
private async Task UpdateTaskStatusInDb(Guid taskId, TaskExecutionStatus status, int progress,
|
||||||
|
string? error = null, DateTime? startedAt = null, DateTime? completedAt = null,
|
||||||
|
BackgroundTask? currentBackgroundTaskState = null)
|
||||||
{
|
{
|
||||||
await using var dbContext = await _contextFactory.CreateDbContextAsync();
|
await using var dbContext = await contextFactory.CreateDbContextAsync();
|
||||||
var taskToUpdate = currentBackgroundTaskState ?? await dbContext.BackgroundTasks.FindAsync(taskId);
|
var taskToUpdate = currentBackgroundTaskState ?? await dbContext.BackgroundTasks.FindAsync(taskId);
|
||||||
|
|
||||||
if (taskToUpdate != null)
|
if (taskToUpdate != null)
|
||||||
{
|
{
|
||||||
if (currentBackgroundTaskState != null && dbContext.Entry(currentBackgroundTaskState).State == EntityState.Detached)
|
if (currentBackgroundTaskState != null &&
|
||||||
|
dbContext.Entry(currentBackgroundTaskState).State == EntityState.Detached)
|
||||||
{
|
{
|
||||||
dbContext.BackgroundTasks.Attach(currentBackgroundTaskState);
|
dbContext.BackgroundTasks.Attach(currentBackgroundTaskState);
|
||||||
}
|
}
|
||||||
@@ -183,23 +178,25 @@ namespace Foxel.Services.Background.Processors
|
|||||||
if (startedAt.HasValue) taskToUpdate.StartedAt = startedAt;
|
if (startedAt.HasValue) taskToUpdate.StartedAt = startedAt;
|
||||||
if (completedAt.HasValue) taskToUpdate.CompletedAt = completedAt;
|
if (completedAt.HasValue) taskToUpdate.CompletedAt = completedAt;
|
||||||
|
|
||||||
if ((status == TaskExecutionStatus.Completed || status == TaskExecutionStatus.Failed) && !taskToUpdate.StartedAt.HasValue)
|
if ((status == TaskExecutionStatus.Completed || status == TaskExecutionStatus.Failed) &&
|
||||||
|
!taskToUpdate.StartedAt.HasValue)
|
||||||
{
|
{
|
||||||
taskToUpdate.StartedAt = taskToUpdate.CreatedAt;
|
taskToUpdate.StartedAt = taskToUpdate.CreatedAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status == TaskExecutionStatus.Completed || status == TaskExecutionStatus.Failed)
|
if (status == TaskExecutionStatus.Completed || status == TaskExecutionStatus.Failed)
|
||||||
{
|
{
|
||||||
taskToUpdate.CompletedAt ??= DateTime.UtcNow;
|
taskToUpdate.CompletedAt ??= DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
await dbContext.SaveChangesAsync();
|
await dbContext.SaveChangesAsync();
|
||||||
_logger.LogInformation("任务状态更新 (Processor): TaskId={TaskId}, Status={Status}, Progress={Progress}%", taskId, status, progress);
|
logger.LogInformation("任务状态更新 (Processor): TaskId={TaskId}, Status={Status}, Progress={Progress}%",
|
||||||
|
taskId, status, progress);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_logger.LogWarning("尝试在 Processor 中更新不存在的任务状态: TaskId={TaskId}", taskId);
|
logger.LogWarning("尝试在 Processor 中更新不存在的任务状态: TaskId={TaskId}", taskId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,7 +5,10 @@ using Foxel.Services.VectorDB;
|
|||||||
using Foxel.Utils;
|
using Foxel.Utils;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Foxel.Services.Attributes;
|
// using Foxel.Services.Attributes; // StorageType enum might not be directly needed here anymore
|
||||||
|
using Microsoft.Extensions.DependencyInjection; // For CreateScope
|
||||||
|
using Microsoft.AspNetCore.Hosting; // For IWebHostEnvironment
|
||||||
|
using Microsoft.Extensions.Logging; // For ILogger
|
||||||
|
|
||||||
namespace Foxel.Services.Background.Processors
|
namespace Foxel.Services.Background.Processors
|
||||||
{
|
{
|
||||||
@@ -15,19 +18,32 @@ namespace Foxel.Services.Background.Processors
|
|||||||
public int? UserIdForPicture { get; set; }
|
public int? UserIdForPicture { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class VisualRecognitionTaskProcessor(
|
public class VisualRecognitionTaskProcessor : ITaskProcessor
|
||||||
IDbContextFactory<MyDbContext> contextFactory,
|
|
||||||
IServiceProvider serviceProvider,
|
|
||||||
ILogger<VisualRecognitionTaskProcessor> logger,
|
|
||||||
IWebHostEnvironment environment)
|
|
||||||
: ITaskProcessor
|
|
||||||
{
|
{
|
||||||
|
private readonly IDbContextFactory<MyDbContext> _contextFactory;
|
||||||
|
private readonly IServiceProvider _serviceProvider;
|
||||||
|
private readonly ILogger<VisualRecognitionTaskProcessor> _logger;
|
||||||
|
private readonly IWebHostEnvironment _environment;
|
||||||
|
|
||||||
|
public VisualRecognitionTaskProcessor(
|
||||||
|
IDbContextFactory<MyDbContext> contextFactory,
|
||||||
|
IServiceProvider serviceProvider,
|
||||||
|
ILogger<VisualRecognitionTaskProcessor> logger,
|
||||||
|
IWebHostEnvironment environment)
|
||||||
|
{
|
||||||
|
_contextFactory = contextFactory;
|
||||||
|
_serviceProvider = serviceProvider;
|
||||||
|
_logger = logger;
|
||||||
|
_environment = environment;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task ProcessAsync(BackgroundTask backgroundTask)
|
public async Task ProcessAsync(BackgroundTask backgroundTask)
|
||||||
{
|
{
|
||||||
|
// ... (payload deserialization and validation logic remains the same) ...
|
||||||
if (backgroundTask.Payload == null)
|
if (backgroundTask.Payload == null)
|
||||||
{
|
{
|
||||||
await UpdateTaskStatusInDb(backgroundTask.Id, TaskExecutionStatus.Failed, 0, "任务 Payload 为空。");
|
await UpdateTaskStatusInDb(backgroundTask.Id, TaskExecutionStatus.Failed, 0, "任务 Payload 为空。");
|
||||||
logger.LogError("视觉识别任务 Payload 为空: TaskId={TaskId}", backgroundTask.Id);
|
_logger.LogError("视觉识别任务 Payload 为空: TaskId={TaskId}", backgroundTask.Id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,33 +54,37 @@ namespace Foxel.Services.Background.Processors
|
|||||||
}
|
}
|
||||||
catch (JsonException ex)
|
catch (JsonException ex)
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "无法解析视觉识别任务的 Payload: TaskId={TaskId}", backgroundTask.Id);
|
_logger.LogError(ex, "无法解析视觉识别任务的 Payload: TaskId={TaskId}", backgroundTask.Id);
|
||||||
await UpdateTaskStatusInDb(backgroundTask.Id, TaskExecutionStatus.Failed, 0, "Payload 解析失败。");
|
await UpdateTaskStatusInDb(backgroundTask.Id, TaskExecutionStatus.Failed, 0, "Payload 解析失败。");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payload == null || payload.PictureId == 0)
|
if (payload == null || payload.PictureId == 0)
|
||||||
{
|
{
|
||||||
logger.LogError("视觉识别任务的 Payload 无效或缺少 PictureId: TaskId={TaskId}", backgroundTask.Id);
|
_logger.LogError("视觉识别任务的 Payload 无效或缺少 PictureId: TaskId={TaskId}", backgroundTask.Id);
|
||||||
await UpdateTaskStatusInDb(backgroundTask.Id, TaskExecutionStatus.Failed, 0,
|
await UpdateTaskStatusInDb(backgroundTask.Id, TaskExecutionStatus.Failed, 0,
|
||||||
"Payload 无效或缺少 PictureId。");
|
"Payload 无效或缺少 PictureId。");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var pictureId = payload.PictureId;
|
var pictureId = payload.PictureId;
|
||||||
string thumbnailForAiDownloadPath = string.Empty; // Path if thumbnail needs to be downloaded
|
string thumbnailForAiDownloadPath = string.Empty;
|
||||||
bool isTempThumbnailFile = false;
|
bool isTempThumbnailFile = false;
|
||||||
|
|
||||||
await using var dbContext = await contextFactory.CreateDbContextAsync();
|
await using var dbContext = await _contextFactory.CreateDbContextAsync();
|
||||||
var currentBackgroundTaskState = await dbContext.BackgroundTasks.FindAsync(backgroundTask.Id);
|
var currentBackgroundTaskState = await dbContext.BackgroundTasks.FindAsync(backgroundTask.Id);
|
||||||
if (currentBackgroundTaskState == null)
|
if (currentBackgroundTaskState == null)
|
||||||
{
|
{
|
||||||
logger.LogError("在 VisualRecognitionTaskProcessor 中找不到后台任务: TaskId={TaskId}", backgroundTask.Id);
|
_logger.LogError("在 VisualRecognitionTaskProcessor 中找不到后台任务: TaskId={TaskId}", backgroundTask.Id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var picture = await dbContext.Pictures.Include(p => p.User).ThenInclude(u => u.Tags)
|
var picture = await dbContext.Pictures
|
||||||
.FirstOrDefaultAsync(p => p.Id == pictureId);
|
.Include(p => p.User)
|
||||||
|
.ThenInclude(u => u!.Tags) // Ensure Tags on User is loaded if needed
|
||||||
|
.Include(p => p.StorageMode) // Include StorageMode
|
||||||
|
.Include(p => p.Tags) // Include picture's own tags
|
||||||
|
.FirstOrDefaultAsync(p => p.Id == pictureId);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -75,35 +95,48 @@ namespace Foxel.Services.Background.Processors
|
|||||||
{
|
{
|
||||||
throw new Exception($"找不到ID为{pictureId}的图片。");
|
throw new Exception($"找不到ID为{pictureId}的图片。");
|
||||||
}
|
}
|
||||||
|
if (picture.StorageMode == null || picture.StorageModeId < 0)
|
||||||
|
{
|
||||||
|
throw new Exception($"图片ID {pictureId} 缺少有效的 StorageMode 配置。");
|
||||||
|
}
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(picture.ThumbnailPath))
|
if (string.IsNullOrEmpty(picture.ThumbnailPath))
|
||||||
{
|
{
|
||||||
|
// It's possible the thumbnail is generated by PictureTaskProcessor but this task runs before it completes.
|
||||||
|
// Or thumbnail generation failed.
|
||||||
|
_logger.LogWarning("图片ID {PictureId} 的缩略图路径为空。AI分析将无法进行或可能失败。", pictureId);
|
||||||
|
// Depending on requirements, you might throw, or try to generate it here, or skip AI.
|
||||||
|
// For now, let's assume it should exist.
|
||||||
throw new Exception($"图片ID {pictureId} 的缩略图路径为空,无法进行AI分析。");
|
throw new Exception($"图片ID {pictureId} 的缩略图路径为空,无法进行AI分析。");
|
||||||
}
|
}
|
||||||
|
|
||||||
using var scope = serviceProvider.CreateScope();
|
using var scope = _serviceProvider.CreateScope();
|
||||||
var aiService = scope.ServiceProvider.GetRequiredService<IAiService>();
|
var aiService = scope.ServiceProvider.GetRequiredService<IAiService>();
|
||||||
var storageService = scope.ServiceProvider.GetRequiredService<IStorageService>();
|
var storageService = scope.ServiceProvider.GetRequiredService<IStorageService>();
|
||||||
string contentRootPath = environment.ContentRootPath;
|
string contentRootPath = _environment.ContentRootPath;
|
||||||
string actualThumbnailPathForAI;
|
string actualThumbnailPathForAI;
|
||||||
|
|
||||||
if (picture.StorageType == StorageType.Local)
|
// Check the StorageType of the associated StorageMode
|
||||||
|
if (picture.StorageMode.StorageType == Attributes.StorageType.Local)
|
||||||
{
|
{
|
||||||
actualThumbnailPathForAI = Path.Combine(contentRootPath, picture.ThumbnailPath.TrimStart('/'));
|
// As with PictureTaskProcessor, safer to use DownloadFileAsync for consistency
|
||||||
}
|
_logger.LogInformation("Picture {PictureId} thumbnail is Local. Attempting to download via StorageService for AI.", pictureId);
|
||||||
else // Remote storage
|
// Fall-through
|
||||||
{
|
|
||||||
await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Processing, 15,
|
|
||||||
currentBackgroundTaskState: currentBackgroundTaskState);
|
|
||||||
thumbnailForAiDownloadPath = await storageService.ExecuteAsync(picture.StorageType,
|
|
||||||
provider => provider.DownloadFileAsync(picture.ThumbnailPath));
|
|
||||||
actualThumbnailPathForAI = thumbnailForAiDownloadPath;
|
|
||||||
isTempThumbnailFile = true;
|
|
||||||
}
|
}
|
||||||
|
// else // Remote storage or consistent handling for Local
|
||||||
|
// {
|
||||||
|
await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Processing, 15,
|
||||||
|
currentBackgroundTaskState: currentBackgroundTaskState);
|
||||||
|
// Use picture.StorageModeId to download the thumbnail
|
||||||
|
thumbnailForAiDownloadPath = await storageService.ExecuteAsync(picture.StorageModeId,
|
||||||
|
provider => provider.DownloadFileAsync(picture.ThumbnailPath));
|
||||||
|
actualThumbnailPathForAI = thumbnailForAiDownloadPath;
|
||||||
|
isTempThumbnailFile = true;
|
||||||
|
// }
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(actualThumbnailPathForAI) || !File.Exists(actualThumbnailPathForAI))
|
if (string.IsNullOrEmpty(actualThumbnailPathForAI) || !File.Exists(actualThumbnailPathForAI))
|
||||||
{
|
{
|
||||||
throw new Exception($"找不到用于AI分析的缩略图文件: {actualThumbnailPathForAI} (源路径: {picture.ThumbnailPath})");
|
throw new Exception($"找不到用于AI分析的缩略图文件: {actualThumbnailPathForAI} (源存储路径: {picture.ThumbnailPath})");
|
||||||
}
|
}
|
||||||
|
|
||||||
await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Processing, 20,
|
await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Processing, 20,
|
||||||
@@ -116,11 +149,11 @@ namespace Foxel.Services.Background.Processors
|
|||||||
|
|
||||||
string finalTitle = !string.IsNullOrWhiteSpace(title) && title != "AI生成的标题"
|
string finalTitle = !string.IsNullOrWhiteSpace(title) && title != "AI生成的标题"
|
||||||
? title
|
? title
|
||||||
: Path.GetFileNameWithoutExtension(picture.Name);
|
: Path.GetFileNameWithoutExtension(picture.Name); // Fallback to existing name
|
||||||
string finalDescription = !string.IsNullOrWhiteSpace(description) && description != "AI生成的描述"
|
string finalDescription = !string.IsNullOrWhiteSpace(description) && description != "AI生成的描述"
|
||||||
? description
|
? description
|
||||||
: picture.Description;
|
: picture.Description; // Fallback to existing description
|
||||||
picture.Name = finalTitle; // Potentially overwrites name set from filename
|
picture.Name = finalTitle;
|
||||||
picture.Description = finalDescription;
|
picture.Description = finalDescription;
|
||||||
|
|
||||||
await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Processing, 60,
|
await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Processing, 60,
|
||||||
@@ -135,7 +168,7 @@ namespace Foxel.Services.Background.Processors
|
|||||||
await vectorDbService.AddPictureToUserCollectionAsync(picture.UserId.Value,
|
await vectorDbService.AddPictureToUserCollectionAsync(picture.UserId.Value,
|
||||||
new Models.Vector.PictureVector
|
new Models.Vector.PictureVector
|
||||||
{
|
{
|
||||||
Id = (ulong)picture.Id,
|
Id = (ulong)picture.Id, // Ensure Picture.Id can be cast to ulong or adjust type
|
||||||
Name = picture.Name,
|
Name = picture.Name,
|
||||||
Embedding = embedding
|
Embedding = embedding
|
||||||
});
|
});
|
||||||
@@ -151,7 +184,7 @@ namespace Foxel.Services.Background.Processors
|
|||||||
|
|
||||||
await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Processing, 90,
|
await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Processing, 90,
|
||||||
currentBackgroundTaskState: currentBackgroundTaskState);
|
currentBackgroundTaskState: currentBackgroundTaskState);
|
||||||
if (picture.User != null && matchedTagNames.Any())
|
if (matchedTagNames.Any()) // Apply tags even if user is null, if that's desired
|
||||||
{
|
{
|
||||||
picture.Tags ??= new List<Tag>();
|
picture.Tags ??= new List<Tag>();
|
||||||
foreach (var tagName in matchedTagNames)
|
foreach (var tagName in matchedTagNames)
|
||||||
@@ -162,12 +195,17 @@ namespace Foxel.Services.Background.Processors
|
|||||||
{
|
{
|
||||||
existingTag = new Tag { Name = tagName.Trim(), Description = tagName.Trim() };
|
existingTag = new Tag { Name = tagName.Trim(), Description = tagName.Trim() };
|
||||||
dbContext.Tags.Add(existingTag);
|
dbContext.Tags.Add(existingTag);
|
||||||
|
// await dbContext.SaveChangesAsync(); // Save tag immediately or batch
|
||||||
}
|
}
|
||||||
|
|
||||||
if (picture.Tags.All(t => t.Id != existingTag.Id)) picture.Tags.Add(existingTag);
|
if (picture.Tags.All(t => t.Id != existingTag.Id)) picture.Tags.Add(existingTag);
|
||||||
|
|
||||||
picture.User.Tags ??= new List<Tag>();
|
// Add to user's tags if user exists
|
||||||
if (picture.User.Tags.All(t => t.Id != existingTag.Id)) picture.User.Tags.Add(existingTag);
|
if (picture.User != null)
|
||||||
|
{
|
||||||
|
picture.User.Tags ??= new List<Tag>();
|
||||||
|
if (picture.User.Tags.All(t => t.Id != existingTag.Id)) picture.User.Tags.Add(existingTag);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,7 +215,7 @@ namespace Foxel.Services.Background.Processors
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "视觉识别任务失败: TaskId={TaskId}, PictureId={PictureId}", currentBackgroundTaskState.Id,
|
_logger.LogError(ex, "视觉识别任务失败: TaskId={TaskId}, PictureId={PictureId}", currentBackgroundTaskState.Id,
|
||||||
pictureId);
|
pictureId);
|
||||||
await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Failed,
|
await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Failed,
|
||||||
currentBackgroundTaskState.Progress, ex.Message,
|
currentBackgroundTaskState.Progress, ex.Message,
|
||||||
@@ -193,17 +231,18 @@ namespace Foxel.Services.Background.Processors
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogWarning(ex, "删除临时AI缩略图文件失败: {FilePath}", thumbnailForAiDownloadPath);
|
_logger.LogWarning(ex, "删除临时AI缩略图文件失败: {FilePath}", thumbnailForAiDownloadPath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ... (UpdateTaskStatusInDb remains the same) ...
|
||||||
private async Task UpdateTaskStatusInDb(Guid taskId, TaskExecutionStatus status, int progress,
|
private async Task UpdateTaskStatusInDb(Guid taskId, TaskExecutionStatus status, int progress,
|
||||||
string? error = null, DateTime? startedAt = null, DateTime? completedAt = null,
|
string? error = null, DateTime? startedAt = null, DateTime? completedAt = null,
|
||||||
BackgroundTask? currentBackgroundTaskState = null)
|
BackgroundTask? currentBackgroundTaskState = null)
|
||||||
{
|
{
|
||||||
await using var dbContext = await contextFactory.CreateDbContextAsync();
|
await using var dbContext = await _contextFactory.CreateDbContextAsync();
|
||||||
var taskToUpdate = currentBackgroundTaskState ?? await dbContext.BackgroundTasks.FindAsync(taskId);
|
var taskToUpdate = currentBackgroundTaskState ?? await dbContext.BackgroundTasks.FindAsync(taskId);
|
||||||
|
|
||||||
if (taskToUpdate != null)
|
if (taskToUpdate != null)
|
||||||
@@ -219,30 +258,30 @@ namespace Foxel.Services.Background.Processors
|
|||||||
taskToUpdate.ErrorMessage =
|
taskToUpdate.ErrorMessage =
|
||||||
string.IsNullOrEmpty(error)
|
string.IsNullOrEmpty(error)
|
||||||
? taskToUpdate.ErrorMessage
|
? taskToUpdate.ErrorMessage
|
||||||
: error; // Keep existing error if new one is null/empty
|
: error;
|
||||||
if (startedAt.HasValue) taskToUpdate.StartedAt = startedAt;
|
if (startedAt.HasValue) taskToUpdate.StartedAt = startedAt;
|
||||||
if (completedAt.HasValue) taskToUpdate.CompletedAt = completedAt;
|
if (completedAt.HasValue) taskToUpdate.CompletedAt = completedAt;
|
||||||
|
|
||||||
if ((status == TaskExecutionStatus.Completed || status == TaskExecutionStatus.Failed) &&
|
if ((status == TaskExecutionStatus.Completed || status == TaskExecutionStatus.Failed) &&
|
||||||
!taskToUpdate.StartedAt.HasValue)
|
!taskToUpdate.StartedAt.HasValue)
|
||||||
{
|
{
|
||||||
taskToUpdate.StartedAt = taskToUpdate.CreatedAt; // Ensure StartedAt is set
|
taskToUpdate.StartedAt = taskToUpdate.CreatedAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status == TaskExecutionStatus.Completed || status == TaskExecutionStatus.Failed)
|
if (status == TaskExecutionStatus.Completed || status == TaskExecutionStatus.Failed)
|
||||||
{
|
{
|
||||||
taskToUpdate.CompletedAt ??= DateTime.UtcNow; // Ensure CompletedAt is set
|
taskToUpdate.CompletedAt ??= DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
await dbContext.SaveChangesAsync();
|
await dbContext.SaveChangesAsync();
|
||||||
logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"任务状态更新 (VisualRecognitionProcessor): TaskId={TaskId}, Status={Status}, Progress={Progress}%",
|
"任务状态更新 (VisualRecognitionProcessor): TaskId={TaskId}, Status={Status}, Progress={Progress}%",
|
||||||
taskId, status, progress);
|
taskId, status, progress);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
logger.LogWarning("尝试在 VisualRecognitionProcessor 中更新不存在的任务状态: TaskId={TaskId}", taskId);
|
_logger.LogWarning("尝试在 VisualRecognitionProcessor 中更新不存在的任务状态: TaskId={TaskId}", taskId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,12 +16,6 @@ public class ConfigService(
|
|||||||
{
|
{
|
||||||
"AI:ApiKey",
|
"AI:ApiKey",
|
||||||
"Authentication:GitHubClientSecret",
|
"Authentication:GitHubClientSecret",
|
||||||
"Storage:TelegramStorageBotToken",
|
|
||||||
"Storage:S3StorageAccessKey",
|
|
||||||
"Storage:S3StorageSecretKey",
|
|
||||||
"Storage:CosStorageSecretId",
|
|
||||||
"Storage:CosStorageSecretKey",
|
|
||||||
"Storage:WebDAVPassword",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 缓存过期时间
|
// 缓存过期时间
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Foxel.Models.DataBase;
|
using Foxel.Models.DataBase;
|
||||||
|
using Foxel.Services.Attributes;
|
||||||
using Foxel.Services.Configuration;
|
using Foxel.Services.Configuration;
|
||||||
using Foxel.Services.Logging;
|
using Foxel.Services.Logging;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -17,7 +18,7 @@ public class DatabaseInitializer(
|
|||||||
{
|
{
|
||||||
// 在初始化期间禁用数据库日志记录
|
// 在初始化期间禁用数据库日志记录
|
||||||
DatabaseLogger.SetDatabaseReady(false);
|
DatabaseLogger.SetDatabaseReady(false);
|
||||||
|
|
||||||
logger.LogInformation("开始检查数据库初始化状态...");
|
logger.LogInformation("开始检查数据库初始化状态...");
|
||||||
|
|
||||||
// 执行数据库迁移
|
// 执行数据库迁移
|
||||||
@@ -28,7 +29,6 @@ public class DatabaseInitializer(
|
|||||||
configService[InitializationFlag] == "true")
|
configService[InitializationFlag] == "true")
|
||||||
{
|
{
|
||||||
logger.LogInformation("数据库已完成初始化,跳过初始化步骤");
|
logger.LogInformation("数据库已完成初始化,跳过初始化步骤");
|
||||||
// 启用数据库日志记录
|
|
||||||
DatabaseLogger.SetDatabaseReady(true);
|
DatabaseLogger.SetDatabaseReady(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -64,12 +64,14 @@ public class DatabaseInitializer(
|
|||||||
"Please generate **5 most relevant tags** for the given image. Each tag should be a **short and descriptive** word or phrase that accurately reflects key visual or thematic elements of the image.\n\nReturn your response in **valid JSON format** as shown below:\n\n```json\n{\n \"tags\": [\"标签1\", \"标签2\", \"标签3\", \"标签4\", \"标签5\"]\n}\n```\n\nMake sure the output is **strictly valid JSON**.",
|
"Please generate **5 most relevant tags** for the given image. Each tag should be a **short and descriptive** word or phrase that accurately reflects key visual or thematic elements of the image.\n\nReturn your response in **valid JSON format** as shown below:\n\n```json\n{\n \"tags\": [\"标签1\", \"标签2\", \"标签3\", \"标签4\", \"标签5\"]\n}\n```\n\nMake sure the output is **strictly valid JSON**.",
|
||||||
["AI:TagMatchingPrompt"] =
|
["AI:TagMatchingPrompt"] =
|
||||||
"Given a list of tags: `[{tagsText}]`\n\nPlease strictly select only those tags that are **highly relevant** to the following description (select **up to 5**). Only include tags that **exactly or strongly match** the content. If **none** of the tags are a good match, return an **empty array** instead of including loosely related ones.\n\n**Description:**\n{description}\n\nReturn your response in **valid JSON format** as shown below:\n\n```json\n{\n \"tags\": [\"标签1\", \"标签2\", \"标签3\", \"标签4\", \"标签5\"]\n}\n```\n\n⚠️ Do **not** include code fences (no triple backticks), and ensure the JSON is **valid** and includes only truly matching tag names.",
|
"Given a list of tags: `[{tagsText}]`\n\nPlease strictly select only those tags that are **highly relevant** to the following description (select **up to 5**). Only include tags that **exactly or strongly match** the content. If **none** of the tags are a good match, return an **empty array** instead of including loosely related ones.\n\n**Description:**\n{description}\n\nReturn your response in **valid JSON format** as shown below:\n\n```json\n{\n \"tags\": [\"标签1\", \"标签2\", \"标签3\", \"标签4\", \"标签5\"]\n}\n```\n\n⚠️ Do **not** include code fences (no triple backticks), and ensure the JSON is **valid** and includes only truly matching tag names.",
|
||||||
// 存储配置
|
|
||||||
["Storage:TelegramStorageBotToken"] = "",
|
// 上传配置
|
||||||
["Storage:TelegramStorageChatId"] = "",
|
["Upload:HighQualityImageCompressionQuality"] = "85",
|
||||||
["Storage:DefaultStorage"] = "Local",
|
["Upload:ThumbnailMaxWidth"] = "500",
|
||||||
|
["Upload:ThumbnailCompressionQuality"] = "75",
|
||||||
|
|
||||||
// 其他配置
|
// 其他配置
|
||||||
|
["Storage:DefaultStorageModeId"] = "1",
|
||||||
["AppSettings:ServerUrl"] = "",
|
["AppSettings:ServerUrl"] = "",
|
||||||
["VectorDb:Type"] = "InMemory"
|
["VectorDb:Type"] = "InMemory"
|
||||||
};
|
};
|
||||||
@@ -88,11 +90,14 @@ public class DatabaseInitializer(
|
|||||||
// 初始化管理员角色和用户
|
// 初始化管理员角色和用户
|
||||||
await InitializeAdminRoleAndUserAsync();
|
await InitializeAdminRoleAndUserAsync();
|
||||||
|
|
||||||
|
// 初始化默认存储模式
|
||||||
|
await InitializeDefaultStorageModeAsync();
|
||||||
|
|
||||||
// 标记初始化已完成
|
// 标记初始化已完成
|
||||||
await configService.SetConfigAsync(InitializationFlag, "true", "系统初始化完成标志");
|
await configService.SetConfigAsync(InitializationFlag, "true", "系统初始化完成标志");
|
||||||
|
|
||||||
logger.LogInformation("数据库配置初始化完成");
|
logger.LogInformation("数据库配置初始化完成");
|
||||||
|
|
||||||
// 初始化完成后启用数据库日志记录
|
// 初始化完成后启用数据库日志记录
|
||||||
DatabaseLogger.SetDatabaseReady(true);
|
DatabaseLogger.SetDatabaseReady(true);
|
||||||
}
|
}
|
||||||
@@ -162,4 +167,32 @@ public class DatabaseInitializer(
|
|||||||
|
|
||||||
logger.LogInformation("请注意,第一个注册的用户将自动成为管理员");
|
logger.LogInformation("请注意,第一个注册的用户将自动成为管理员");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task InitializeDefaultStorageModeAsync()
|
||||||
|
{
|
||||||
|
await using var context = await contextFactory.CreateDbContextAsync();
|
||||||
|
|
||||||
|
const string defaultStorageModeName = "本地数据";
|
||||||
|
if (!await context.StorageModes.AnyAsync(sm => sm.Name == defaultStorageModeName))
|
||||||
|
{
|
||||||
|
logger.LogInformation("创建默认本地存储模式: {StorageModeName}", defaultStorageModeName);
|
||||||
|
var localDefaultStorageMode = new StorageMode
|
||||||
|
{
|
||||||
|
Name = defaultStorageModeName,
|
||||||
|
IsEnabled = true,
|
||||||
|
StorageType = StorageType.Local,
|
||||||
|
ConfigurationJson =
|
||||||
|
"{\"BasePath\": \"/app/Uploads\", \"ServerUrl\": \"\", \"PublicBasePath\": \"/Uploads\"}",
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
UpdatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
await context.StorageModes.AddAsync(localDefaultStorageMode);
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
logger.LogInformation("默认本地存储模式创建成功");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
logger.LogInformation("默认本地存储模式 '{StorageModeName}' 已存在,跳过创建。", defaultStorageModeName);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
19
Services/Management/IStorageManagementService.cs
Normal file
19
Services/Management/IStorageManagementService.cs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
using Foxel.Models; // For PaginatedResult
|
||||||
|
using Foxel.Models.Request.Storage;
|
||||||
|
using Foxel.Models.Response.Storage;
|
||||||
|
using Foxel.Services.Attributes; // For StorageType
|
||||||
|
|
||||||
|
namespace Foxel.Services.Management;
|
||||||
|
|
||||||
|
public interface IStorageManagementService
|
||||||
|
{
|
||||||
|
Task<PaginatedResult<StorageModeResponse>> GetStorageModesAsync(int page = 1, int pageSize = 10, string? searchQuery = null, StorageType? storageType = null, bool? isEnabled = null);
|
||||||
|
Task<StorageModeResponse> GetStorageModeByIdAsync(int id);
|
||||||
|
Task<StorageModeResponse> CreateStorageModeAsync(CreateStorageModeRequest request);
|
||||||
|
Task<StorageModeResponse> UpdateStorageModeAsync(UpdateStorageModeRequest request);
|
||||||
|
Task<bool> DeleteStorageModeAsync(int id);
|
||||||
|
Task<BatchDeleteResult> BatchDeleteStorageModesAsync(List<int> ids);
|
||||||
|
Task<IEnumerable<StorageTypeResponse>> GetStorageTypesAsync();
|
||||||
|
Task<int?> GetDefaultStorageModeIdAsync();
|
||||||
|
Task<bool> SetDefaultStorageModeAsync(int storageModeId);
|
||||||
|
}
|
||||||
@@ -48,10 +48,10 @@ public class PictureManagementService(
|
|||||||
{
|
{
|
||||||
Id = picture.Id,
|
Id = picture.Id,
|
||||||
Name = picture.Name,
|
Name = picture.Name,
|
||||||
Path = storageService.ExecuteAsync(picture.StorageType, provider =>
|
Path = storageService.ExecuteAsync(picture.StorageModeId, provider =>
|
||||||
Task.FromResult(provider.GetUrl(picture.Path))).Result,
|
Task.FromResult(provider.GetUrl(picture.Id,picture.Path))).Result,
|
||||||
ThumbnailPath = storageService.ExecuteAsync(picture.StorageType, provider =>
|
ThumbnailPath = storageService.ExecuteAsync(picture.StorageModeId, provider =>
|
||||||
Task.FromResult(provider.GetUrl(picture.ThumbnailPath ?? string.Empty))).Result,
|
Task.FromResult(provider.GetUrl(picture.Id,picture.ThumbnailPath ?? string.Empty))).Result,
|
||||||
Description = picture.Description,
|
Description = picture.Description,
|
||||||
CreatedAt = picture.CreatedAt,
|
CreatedAt = picture.CreatedAt,
|
||||||
TakenAt = picture.TakenAt,
|
TakenAt = picture.TakenAt,
|
||||||
@@ -119,7 +119,7 @@ public class PictureManagementService(
|
|||||||
// 保存文件路径信息用于后续删除
|
// 保存文件路径信息用于后续删除
|
||||||
var filePath = picture.Path;
|
var filePath = picture.Path;
|
||||||
var thumbnailPath = picture.ThumbnailPath;
|
var thumbnailPath = picture.ThumbnailPath;
|
||||||
var storageType = picture.StorageType;
|
var storageType = picture.StorageModeId;
|
||||||
|
|
||||||
// 删除数据库记录
|
// 删除数据库记录
|
||||||
dbContext.Pictures.Remove(picture);
|
dbContext.Pictures.Remove(picture);
|
||||||
|
|||||||
310
Services/Management/StorageManagementService.cs
Normal file
310
Services/Management/StorageManagementService.cs
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
using Foxel.Models;
|
||||||
|
using Foxel.Models.DataBase;
|
||||||
|
using Foxel.Models.Request.Storage;
|
||||||
|
using Foxel.Models.Response.Storage;
|
||||||
|
using Foxel.Services.Attributes;
|
||||||
|
using Foxel.Services.Storage.Providers; // Required for config types like LocalStorageConfig, etc.
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Foxel.Services.Configuration; // Added for IConfigService
|
||||||
|
|
||||||
|
namespace Foxel.Services.Management;
|
||||||
|
|
||||||
|
public class StorageManagementService : IStorageManagementService
|
||||||
|
{
|
||||||
|
private readonly IDbContextFactory<MyDbContext> _contextFactory;
|
||||||
|
private readonly ILogger<StorageManagementService> _logger;
|
||||||
|
private readonly IConfigService _configService; // Added IConfigService
|
||||||
|
private const string DefaultStorageModeIdKey = "Storage:DefaultStorageModeId"; // Define key for config
|
||||||
|
|
||||||
|
public StorageManagementService(
|
||||||
|
IDbContextFactory<MyDbContext> contextFactory,
|
||||||
|
ILogger<StorageManagementService> logger,
|
||||||
|
IConfigService configService) // Added IConfigService to constructor
|
||||||
|
{
|
||||||
|
_contextFactory = contextFactory;
|
||||||
|
_logger = logger;
|
||||||
|
_configService = configService; // Initialize IConfigService
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PaginatedResult<StorageModeResponse>> GetStorageModesAsync(int page = 1, int pageSize = 10, string? searchQuery = null, StorageType? storageType = null, bool? isEnabled = null)
|
||||||
|
{
|
||||||
|
if (page < 1) page = 1;
|
||||||
|
if (pageSize < 1) pageSize = 10;
|
||||||
|
|
||||||
|
await using var dbContext = await _contextFactory.CreateDbContextAsync();
|
||||||
|
var query = dbContext.StorageModes.AsQueryable();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(searchQuery))
|
||||||
|
{
|
||||||
|
query = query.Where(sm => sm.Name.Contains(searchQuery));
|
||||||
|
}
|
||||||
|
if (storageType.HasValue)
|
||||||
|
{
|
||||||
|
query = query.Where(sm => sm.StorageType == storageType.Value);
|
||||||
|
}
|
||||||
|
if (isEnabled.HasValue)
|
||||||
|
{
|
||||||
|
query = query.Where(sm => sm.IsEnabled == isEnabled.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
query = query.OrderByDescending(sm => sm.CreatedAt);
|
||||||
|
|
||||||
|
var totalCount = await query.CountAsync();
|
||||||
|
var storageModes = await query
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var responseItems = storageModes.Select(sm => new StorageModeResponse
|
||||||
|
{
|
||||||
|
Id = sm.Id,
|
||||||
|
Name = sm.Name,
|
||||||
|
StorageType = sm.StorageType,
|
||||||
|
ConfigurationJson = sm.ConfigurationJson, // Consider masking sensitive info if necessary
|
||||||
|
IsEnabled = sm.IsEnabled,
|
||||||
|
CreatedAt = sm.CreatedAt,
|
||||||
|
UpdatedAt = sm.UpdatedAt
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
return new PaginatedResult<StorageModeResponse>
|
||||||
|
{
|
||||||
|
Data = responseItems,
|
||||||
|
Page = page,
|
||||||
|
PageSize = pageSize,
|
||||||
|
TotalCount = totalCount
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<StorageModeResponse> GetStorageModeByIdAsync(int id)
|
||||||
|
{
|
||||||
|
await using var dbContext = await _contextFactory.CreateDbContextAsync();
|
||||||
|
var sm = await dbContext.StorageModes.FindAsync(id);
|
||||||
|
if (sm == null)
|
||||||
|
throw new KeyNotFoundException($"StorageMode with ID {id} not found.");
|
||||||
|
|
||||||
|
return new StorageModeResponse
|
||||||
|
{
|
||||||
|
Id = sm.Id,
|
||||||
|
Name = sm.Name,
|
||||||
|
StorageType = sm.StorageType,
|
||||||
|
ConfigurationJson = sm.ConfigurationJson,
|
||||||
|
IsEnabled = sm.IsEnabled,
|
||||||
|
CreatedAt = sm.CreatedAt,
|
||||||
|
UpdatedAt = sm.UpdatedAt
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<StorageModeResponse> CreateStorageModeAsync(CreateStorageModeRequest request)
|
||||||
|
{
|
||||||
|
await using var dbContext = await _contextFactory.CreateDbContextAsync();
|
||||||
|
|
||||||
|
ValidateConfiguration(request.StorageType, request.ConfigurationJson, request.Name);
|
||||||
|
|
||||||
|
var storageMode = new Models.DataBase.StorageMode
|
||||||
|
{
|
||||||
|
Name = request.Name,
|
||||||
|
StorageType = request.StorageType,
|
||||||
|
ConfigurationJson = request.ConfigurationJson,
|
||||||
|
IsEnabled = request.IsEnabled,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
UpdatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
dbContext.StorageModes.Add(storageMode);
|
||||||
|
await dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
return await GetStorageModeByIdAsync(storageMode.Id); // Reuse to get full response model
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<StorageModeResponse> UpdateStorageModeAsync(UpdateStorageModeRequest request)
|
||||||
|
{
|
||||||
|
await using var dbContext = await _contextFactory.CreateDbContextAsync();
|
||||||
|
var storageMode = await dbContext.StorageModes.FindAsync(request.Id);
|
||||||
|
|
||||||
|
if (storageMode == null)
|
||||||
|
throw new KeyNotFoundException($"StorageMode with ID {request.Id} not found.");
|
||||||
|
|
||||||
|
ValidateConfiguration(request.StorageType, request.ConfigurationJson, request.Name);
|
||||||
|
|
||||||
|
storageMode.Name = request.Name;
|
||||||
|
storageMode.StorageType = request.StorageType;
|
||||||
|
storageMode.ConfigurationJson = request.ConfigurationJson;
|
||||||
|
storageMode.IsEnabled = request.IsEnabled;
|
||||||
|
storageMode.UpdatedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
|
await dbContext.SaveChangesAsync();
|
||||||
|
return await GetStorageModeByIdAsync(storageMode.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> DeleteStorageModeAsync(int id)
|
||||||
|
{
|
||||||
|
await using var dbContext = await _contextFactory.CreateDbContextAsync();
|
||||||
|
var storageMode = await dbContext.StorageModes.FindAsync(id);
|
||||||
|
if (storageMode == null)
|
||||||
|
throw new KeyNotFoundException($"StorageMode with ID {id} not found.");
|
||||||
|
|
||||||
|
// Check if any pictures are using this storage mode
|
||||||
|
bool isInUse = await dbContext.Pictures.AnyAsync(p => p.StorageModeId == id);
|
||||||
|
if (isInUse)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Attempted to delete StorageMode ID {StorageModeId} which is currently in use by one or more pictures.", id);
|
||||||
|
throw new InvalidOperationException($"StorageMode '{storageMode.Name}' (ID: {id}) cannot be deleted because it is currently in use by one or more pictures.");
|
||||||
|
}
|
||||||
|
|
||||||
|
dbContext.StorageModes.Remove(storageMode);
|
||||||
|
await dbContext.SaveChangesAsync();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<BatchDeleteResult> BatchDeleteStorageModesAsync(List<int> ids)
|
||||||
|
{
|
||||||
|
var result = new BatchDeleteResult();
|
||||||
|
await using var dbContext = await _contextFactory.CreateDbContextAsync();
|
||||||
|
|
||||||
|
foreach (var id in ids)
|
||||||
|
{
|
||||||
|
var storageMode = await dbContext.StorageModes.FindAsync(id);
|
||||||
|
if (storageMode == null)
|
||||||
|
{
|
||||||
|
result.FailedCount++;
|
||||||
|
result.FailedIds.Add(id);
|
||||||
|
_logger.LogWarning("Batch delete: StorageMode with ID {StorageModeId} not found.", id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isInUse = await dbContext.Pictures.AnyAsync(p => p.StorageModeId == id);
|
||||||
|
if (isInUse)
|
||||||
|
{
|
||||||
|
result.FailedCount++;
|
||||||
|
result.FailedIds.Add(id);
|
||||||
|
_logger.LogWarning("Batch delete: StorageMode ID {StorageModeId} ('{StorageModeName}') is in use and cannot be deleted.", id, storageMode.Name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
dbContext.StorageModes.Remove(storageMode);
|
||||||
|
result.SuccessCount++;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
result.FailedCount++;
|
||||||
|
result.FailedIds.Add(id);
|
||||||
|
_logger.LogError(ex, "Batch delete: Error deleting StorageMode ID {StorageModeId}.", id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (result.SuccessCount > 0)
|
||||||
|
{
|
||||||
|
await dbContext.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<IEnumerable<StorageTypeResponse>> GetStorageTypesAsync()
|
||||||
|
{
|
||||||
|
var types = Enum.GetValues(typeof(StorageType))
|
||||||
|
.Cast<StorageType>()
|
||||||
|
.Select(e => new StorageTypeResponse { Value = (int)e, Name = e.ToString() })
|
||||||
|
.ToList();
|
||||||
|
return Task.FromResult<IEnumerable<StorageTypeResponse>>(types);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int?> GetDefaultStorageModeIdAsync()
|
||||||
|
{
|
||||||
|
var idString = await _configService.GetValueAsync(DefaultStorageModeIdKey);
|
||||||
|
if (int.TryParse(idString, out var id))
|
||||||
|
{
|
||||||
|
// Optionally, verify if this ID still exists and is enabled in StorageModes table
|
||||||
|
await using var dbContext = await _contextFactory.CreateDbContextAsync();
|
||||||
|
var storageModeExists = await dbContext.StorageModes.AnyAsync(sm => sm.Id == id && sm.IsEnabled);
|
||||||
|
if (storageModeExists)
|
||||||
|
{
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
_logger.LogWarning("Default storage mode ID {DefaultStorageModeId} from config does not exist or is not enabled.", id);
|
||||||
|
// If it doesn't exist or isn't enabled, perhaps clear the setting or return null
|
||||||
|
// For now, returning null as it's not a valid/usable default
|
||||||
|
await _configService.DeleteConfigAsync(DefaultStorageModeIdKey); // Clean up invalid setting
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> SetDefaultStorageModeAsync(int storageModeId)
|
||||||
|
{
|
||||||
|
await using var dbContext = await _contextFactory.CreateDbContextAsync();
|
||||||
|
var storageMode = await dbContext.StorageModes.FindAsync(storageModeId);
|
||||||
|
|
||||||
|
if (storageMode == null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Attempted to set default storage mode to a non-existent ID: {StorageModeId}", storageModeId);
|
||||||
|
throw new KeyNotFoundException($"StorageMode with ID {storageModeId} not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!storageMode.IsEnabled)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Attempted to set default storage mode to a disabled StorageMode ID: {StorageModeId}, Name: {StorageModeName}", storageModeId, storageMode.Name);
|
||||||
|
throw new InvalidOperationException($"StorageMode '{storageMode.Name}' (ID: {storageModeId}) is disabled and cannot be set as default.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate configuration before setting as default
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ValidateConfiguration(storageMode.StorageType, storageMode.ConfigurationJson, storageMode.Name);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Validation failed for StorageMode ID {StorageModeId} ('{StorageModeName}') when trying to set as default. Configuration: {ConfigurationJson}", storageModeId, storageMode.Name, storageMode.ConfigurationJson);
|
||||||
|
throw new InvalidOperationException($"StorageMode '{storageMode.Name}' (ID: {storageModeId}) has an invalid configuration and cannot be set as default. Error: {ex.Message}", ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
await _configService.SetConfigAsync(DefaultStorageModeIdKey, storageModeId.ToString(), "The ID of the default storage mode to be used by the application.");
|
||||||
|
_logger.LogInformation("Default storage mode set to ID: {StorageModeId}, Name: {StorageModeName}", storageModeId, storageMode.Name);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ValidateConfiguration(StorageType storageType, string? jsonConfig, string storageModeName)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(jsonConfig))
|
||||||
|
{
|
||||||
|
// Configuration can be optional for some types or scenarios,
|
||||||
|
// but if a type inherently requires config, this check might need adjustment.
|
||||||
|
// For now, we assume if jsonConfig is null/empty, it's intentional.
|
||||||
|
// The actual provider instantiation will fail if config is required but missing.
|
||||||
|
// This validation step is more about format if config IS provided.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
object? deserializedConfig = storageType switch
|
||||||
|
{
|
||||||
|
StorageType.Local => JsonSerializer.Deserialize<LocalStorageConfig>(jsonConfig),
|
||||||
|
StorageType.Telegram => JsonSerializer.Deserialize<TelegramStorageConfig>(jsonConfig),
|
||||||
|
StorageType.S3 => JsonSerializer.Deserialize<S3StorageConfig>(jsonConfig),
|
||||||
|
StorageType.Cos => JsonSerializer.Deserialize<CosStorageConfig>(jsonConfig),
|
||||||
|
StorageType.WebDAV => JsonSerializer.Deserialize<WebDavStorageConfig>(jsonConfig),
|
||||||
|
_ => throw new NotSupportedException($"StorageType {storageType} configuration validation is not supported.")
|
||||||
|
};
|
||||||
|
|
||||||
|
if (deserializedConfig == null)
|
||||||
|
{
|
||||||
|
throw new JsonException($"Unable to deserialize configuration for {storageType}. JSON: {jsonConfig}");
|
||||||
|
}
|
||||||
|
// Further property-level validation can be added here if needed (e.g., checking required fields within the config object)
|
||||||
|
}
|
||||||
|
catch (JsonException ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Invalid JSON configuration for StorageMode '{StorageModeName}' (Type: {StorageType}). JSON: {JsonConfig}", storageModeName, storageType, jsonConfig);
|
||||||
|
throw new ArgumentException($"Configuration for StorageMode '{storageModeName}' (Type: {storageType}) is invalid: {ex.Message}", nameof(jsonConfig), ex);
|
||||||
|
}
|
||||||
|
catch (NotSupportedException ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Validation not supported for StorageType '{StorageType}' in StorageMode '{StorageModeName}'.", storageType, storageModeName);
|
||||||
|
throw new ArgumentException(ex.Message, nameof(storageType), ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +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;
|
|
||||||
|
|
||||||
namespace Foxel.Services.Media;
|
namespace Foxel.Services.Media;
|
||||||
|
|
||||||
@@ -34,7 +32,7 @@ public interface IPictureService
|
|||||||
int? userId,
|
int? userId,
|
||||||
PermissionType permission = PermissionType.Public,
|
PermissionType permission = PermissionType.Public,
|
||||||
int? albumId = null,
|
int? albumId = null,
|
||||||
StorageType? storageType = null
|
int? storageModeId = null
|
||||||
);
|
);
|
||||||
|
|
||||||
Task<ExifInfo> GetPictureExifInfoAsync(int pictureId);
|
Task<ExifInfo> GetPictureExifInfoAsync(int pictureId);
|
||||||
@@ -85,5 +83,10 @@ public interface IPictureService
|
|||||||
/// <returns>是否收藏</returns>
|
/// <returns>是否收藏</returns>
|
||||||
Task<bool> IsPictureFavoritedByUserAsync(int pictureId, int userId);
|
Task<bool> IsPictureFavoritedByUserAsync(int pictureId, int userId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 根据ID获取图片详情
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="pictureId">图片ID</param>
|
||||||
|
/// <returns>图片响应对象,如果未找到则返回null</returns>
|
||||||
|
Task<Picture?> GetPictureByIdAsync(int pictureId);
|
||||||
}
|
}
|
||||||
@@ -4,14 +4,12 @@ using Foxel.Models.DataBase;
|
|||||||
using Foxel.Models.Enums;
|
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.Background;
|
using Foxel.Services.Background;
|
||||||
using Foxel.Services.Configuration;
|
using Foxel.Services.Configuration;
|
||||||
using Foxel.Services.Storage;
|
using Foxel.Services.Storage;
|
||||||
using Foxel.Services.VectorDB;
|
using Foxel.Services.VectorDB;
|
||||||
using Foxel.Utils;
|
using Foxel.Utils;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
|
|
||||||
namespace Foxel.Services.Media;
|
namespace Foxel.Services.Media;
|
||||||
|
|
||||||
@@ -25,6 +23,48 @@ public class PictureService(
|
|||||||
ILogger<PictureService> logger)
|
ILogger<PictureService> logger)
|
||||||
: IPictureService
|
: IPictureService
|
||||||
{
|
{
|
||||||
|
private async Task<PictureResponse> MapPictureToResponseAsync(Picture picture)
|
||||||
|
{
|
||||||
|
string pathUrl = string.Empty;
|
||||||
|
if (!string.IsNullOrEmpty(picture.Path))
|
||||||
|
{
|
||||||
|
pathUrl = await storageService.ExecuteAsync(picture.StorageModeId, provider =>
|
||||||
|
Task.FromResult(provider.GetUrl(picture.Id,picture.Path)));
|
||||||
|
}
|
||||||
|
|
||||||
|
string originalPathUrl = string.Empty;
|
||||||
|
if (!string.IsNullOrEmpty(picture.OriginalPath))
|
||||||
|
{
|
||||||
|
originalPathUrl = await storageService.ExecuteAsync(picture.StorageModeId, provider =>
|
||||||
|
Task.FromResult(provider.GetUrl(picture.Id,picture.OriginalPath)));
|
||||||
|
}
|
||||||
|
|
||||||
|
string? thumbnailPathUrl = null;
|
||||||
|
if (!string.IsNullOrEmpty(picture.ThumbnailPath))
|
||||||
|
{
|
||||||
|
thumbnailPathUrl = await storageService.ExecuteAsync(picture.StorageModeId, provider =>
|
||||||
|
Task.FromResult(provider.GetUrl(picture.Id,picture.ThumbnailPath)));
|
||||||
|
}
|
||||||
|
return new PictureResponse
|
||||||
|
{
|
||||||
|
Id = picture.Id,
|
||||||
|
Name = picture.Name,
|
||||||
|
Path = pathUrl,
|
||||||
|
OriginalPath = originalPathUrl,
|
||||||
|
ThumbnailPath = thumbnailPathUrl,
|
||||||
|
Description = picture.Description,
|
||||||
|
CreatedAt = picture.CreatedAt,
|
||||||
|
Tags = picture.Tags != null ? picture.Tags.Select(t => t.Name).ToList() : new List<string>(),
|
||||||
|
TakenAt = picture.TakenAt,
|
||||||
|
ExifInfo = picture.ExifInfo ?? new ExifInfo(),
|
||||||
|
UserId = picture.UserId,
|
||||||
|
Username = picture.User?.UserName,
|
||||||
|
AlbumId = picture.AlbumId,
|
||||||
|
Permission = picture.Permission,
|
||||||
|
StorageModeName = picture.StorageMode?.Name
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<PaginatedResult<PictureResponse>> GetPicturesAsync(
|
public async Task<PaginatedResult<PictureResponse>> GetPicturesAsync(
|
||||||
int page = 1,
|
int page = 1,
|
||||||
int pageSize = 8,
|
int pageSize = 8,
|
||||||
@@ -103,18 +143,21 @@ public class PictureService(
|
|||||||
var picturesData = await dbContext.Pictures
|
var picturesData = await dbContext.Pictures
|
||||||
.Include(p => p.Tags)
|
.Include(p => p.Tags)
|
||||||
.Include(p => p.User)
|
.Include(p => p.User)
|
||||||
|
.Include(p=>p.StorageMode)
|
||||||
.Where(p => ids.Contains((ulong)p.Id))
|
.Where(p => ids.Contains((ulong)p.Id))
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
var picturesOrdered = ids
|
var picturesOrdered = ids
|
||||||
.Select(id => picturesData.FirstOrDefault(p => p.Id == (int)id))
|
.Select(id => picturesData.FirstOrDefault(p => p.Id == (int)id))
|
||||||
.Where(p => p != null)
|
.Where(p => p != null)
|
||||||
.ToList();
|
.ToList();
|
||||||
var paginatedResults = picturesOrdered
|
var paginatedResultsTasks = picturesOrdered
|
||||||
.Skip((page - 1) * pageSize)
|
.Skip((page - 1) * pageSize)
|
||||||
.Take(pageSize)
|
.Take(pageSize)
|
||||||
.Select(p => MapPictureToResponse(p!))
|
.Select(async p => await MapPictureToResponseAsync(p!))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
|
var paginatedResults = (await Task.WhenAll(paginatedResultsTasks)).ToList();
|
||||||
|
|
||||||
var totalCount = picturesOrdered.Count;
|
var totalCount = picturesOrdered.Count;
|
||||||
|
|
||||||
await PopulateFavoriteInfo(dbContext, paginatedResults, userId);
|
await PopulateFavoriteInfo(dbContext, paginatedResults, userId);
|
||||||
@@ -177,14 +220,16 @@ public class PictureService(
|
|||||||
|
|
||||||
// 获取分页数据
|
// 获取分页数据
|
||||||
var picturesData = await query
|
var picturesData = await query
|
||||||
|
.Include(x=>x.StorageMode)
|
||||||
.Skip((page - 1) * pageSize)
|
.Skip((page - 1) * pageSize)
|
||||||
.Take(pageSize)
|
.Take(pageSize)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
// 转换为响应格式
|
// 转换为响应格式
|
||||||
var pictures = picturesData
|
var picturesTasks = picturesData
|
||||||
.Select(p => MapPictureToResponse(p))
|
.Select(async p => await MapPictureToResponseAsync(p))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
var pictures = (await Task.WhenAll(picturesTasks)).ToList();
|
||||||
|
|
||||||
// 处理收藏信息
|
// 处理收藏信息
|
||||||
await PopulateFavoriteInfo(dbContext, pictures, userId);
|
await PopulateFavoriteInfo(dbContext, pictures, userId);
|
||||||
@@ -334,34 +379,6 @@ public class PictureService(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 将数据库实体映射到响应对象
|
|
||||||
private PictureResponse MapPictureToResponse(Picture picture)
|
|
||||||
{
|
|
||||||
return new PictureResponse
|
|
||||||
{
|
|
||||||
Id = picture.Id,
|
|
||||||
Name = picture.Name,
|
|
||||||
Path = storageService.ExecuteAsync(picture.StorageType, provider =>
|
|
||||||
Task.FromResult(provider.GetUrl(picture.Path ?? string.Empty))).Result,
|
|
||||||
OriginalPath = storageService.ExecuteAsync(picture.StorageType, provider => // Added OriginalPath
|
|
||||||
Task.FromResult(provider.GetUrl(picture.OriginalPath ?? string.Empty))).Result,
|
|
||||||
ThumbnailPath = !string.IsNullOrEmpty(picture.ThumbnailPath) ?
|
|
||||||
storageService.ExecuteAsync(picture.StorageType, provider =>
|
|
||||||
Task.FromResult(provider.GetUrl(picture.ThumbnailPath))).Result
|
|
||||||
: null, // 如果没有缩略图路径,则为null
|
|
||||||
Description = picture.Description,
|
|
||||||
CreatedAt = picture.CreatedAt,
|
|
||||||
Tags = picture.Tags != null ? picture.Tags.Select(t => t.Name).ToList() : new List<string>(),
|
|
||||||
TakenAt = picture.TakenAt,
|
|
||||||
ExifInfo = picture.ExifInfo ?? new ExifInfo(),
|
|
||||||
UserId = picture.UserId,
|
|
||||||
Username = picture.User?.UserName,
|
|
||||||
AlbumId = picture.AlbumId,
|
|
||||||
Permission = picture.Permission
|
|
||||||
// ProcessingStatus 字段已移除,客户端应通过 BackgroundTaskController 获取状态
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 填充收藏信息
|
// 填充收藏信息
|
||||||
private async Task PopulateFavoriteInfo(MyDbContext dbContext, List<PictureResponse> pictures, int? userId)
|
private async Task PopulateFavoriteInfo(MyDbContext dbContext, List<PictureResponse> pictures, int? userId)
|
||||||
{
|
{
|
||||||
@@ -444,41 +461,60 @@ public class PictureService(
|
|||||||
int? userId,
|
int? userId,
|
||||||
PermissionType permission = PermissionType.Public,
|
PermissionType permission = PermissionType.Public,
|
||||||
int? albumId = null,
|
int? albumId = null,
|
||||||
StorageType? storageType = null)
|
int? storageModeId = null)
|
||||||
{
|
{
|
||||||
StorageType GetConfigStorageType(string configKey)
|
await using var dbContext = await contextFactory.CreateDbContextAsync();
|
||||||
|
if (!storageModeId.HasValue)
|
||||||
{
|
{
|
||||||
string? configValue = configuration[configKey];
|
string configKey = userId == null
|
||||||
return !string.IsNullOrEmpty(configValue) &&
|
? "Storage:DefaultStorageModeId"
|
||||||
Enum.TryParse<StorageType>(configValue, out var configStorageType)
|
: "Storage:DefaultStorageModeId";
|
||||||
? configStorageType
|
string? defaultMode = configuration[configKey];
|
||||||
: StorageType.Local;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (userId == null)
|
if (string.IsNullOrEmpty(defaultMode))
|
||||||
{
|
|
||||||
storageType = GetConfigStorageType("Storage:AnonymousDefaultStorage");
|
|
||||||
}
|
|
||||||
else if (storageType == null)
|
|
||||||
{
|
|
||||||
storageType = GetConfigStorageType("Storage:DefaultStorage");
|
|
||||||
}
|
|
||||||
|
|
||||||
ImageFormat convertToFormat = ImageFormat.Original;
|
|
||||||
string defaultFormatConfig = configuration["Upload:DefaultImageFormat"];
|
|
||||||
if (!string.IsNullOrEmpty(defaultFormatConfig))
|
|
||||||
{
|
|
||||||
if (Enum.TryParse<ImageFormat>(defaultFormatConfig, true, out var parsedFormat))
|
|
||||||
{
|
{
|
||||||
convertToFormat = parsedFormat;
|
logger.LogError("未配置默认存储模式ID: {ConfigKey}", configKey);
|
||||||
|
throw new InvalidOperationException($"未配置默认存储模式: {configKey}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var defaultStorageMode = await dbContext.Set<StorageMode>()
|
||||||
|
.FirstOrDefaultAsync(sm => sm.Id == int.Parse(defaultMode) && sm.IsEnabled);
|
||||||
|
if (defaultStorageMode == null)
|
||||||
|
{
|
||||||
|
logger.LogError("根据名称 '{DefaultModeName}' 找不到已启用的默认存储模式。", defaultMode);
|
||||||
|
throw new InvalidOperationException($"找不到默认存储模式 '{defaultMode}'。");
|
||||||
|
}
|
||||||
|
|
||||||
|
storageModeId = defaultStorageMode.Id;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var specifiedMode =
|
||||||
|
await dbContext.Set<StorageMode>().FirstOrDefaultAsync(sm => sm.Id == storageModeId.Value);
|
||||||
|
if (specifiedMode == null)
|
||||||
|
{
|
||||||
|
throw new ArgumentException($"找不到 ID 为 {storageModeId.Value} 的存储模式。");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!specifiedMode.IsEnabled)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"存储模式 '{specifiedMode.Name}' (ID: {storageModeId.Value}) 未启用。");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
int quality = 100;
|
ImageFormat convertToFormat = ImageFormat.WebP;
|
||||||
string defaultQualityConfig = configuration["Upload:DefaultImageQuality"];
|
|
||||||
if (!string.IsNullOrEmpty(defaultQualityConfig))
|
// 高清图片压缩质量
|
||||||
|
int quality = 100; // 默认值
|
||||||
|
string hdQualityConfigKey = "Upload:HighQualityImageCompressionQuality";
|
||||||
|
string? hdQualityConfig = configuration[hdQualityConfigKey];
|
||||||
|
if (!string.IsNullOrEmpty(hdQualityConfig) && int.TryParse(hdQualityConfig, out int parsedHdQuality))
|
||||||
{
|
{
|
||||||
quality = int.Parse(defaultQualityConfig);
|
quality = Math.Clamp(parsedHdQuality, 50, 100); // 限制在 50-100 之间
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
logger.LogWarning("配置项 '{ConfigKey}' 未找到或无效,使用默认压缩质量: {DefaultQuality}", hdQualityConfigKey, quality);
|
||||||
}
|
}
|
||||||
|
|
||||||
string baseName = Guid.NewGuid().ToString();
|
string baseName = Guid.NewGuid().ToString();
|
||||||
@@ -488,92 +524,104 @@ public class PictureService(
|
|||||||
string? tempOriginalLocalPath = null;
|
string? tempOriginalLocalPath = null;
|
||||||
string? tempConvertedHdLocalPath = null;
|
string? tempConvertedHdLocalPath = null;
|
||||||
string? tempThumbnailLocalPath = null;
|
string? tempThumbnailLocalPath = null;
|
||||||
|
|
||||||
string storedOriginalPath;
|
|
||||||
string storedHdPath;
|
|
||||||
string? storedThumbnailPath = null;
|
string? storedThumbnailPath = null;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
tempOriginalLocalPath = Path.GetTempFileName() + originalFileExtension;
|
tempOriginalLocalPath = Path.GetTempFileName() + originalFileExtension;
|
||||||
File.Move(Path.GetTempFileName(), tempOriginalLocalPath);
|
File.Move(Path.GetTempFileName(), tempOriginalLocalPath);
|
||||||
await using (var tempFileStream = new FileStream(tempOriginalLocalPath, FileMode.Create))
|
await using (var tempFileStream = new FileStream(tempOriginalLocalPath, FileMode.Create))
|
||||||
{
|
{
|
||||||
await fileStream.CopyToAsync(tempFileStream);
|
await fileStream.CopyToAsync(tempFileStream);
|
||||||
}
|
}
|
||||||
|
|
||||||
await using (var originalLocalStream = new FileStream(tempOriginalLocalPath, FileMode.Open, FileAccess.Read))
|
string storedOriginalPath;
|
||||||
|
await using (var originalLocalStream =
|
||||||
|
new FileStream(tempOriginalLocalPath, FileMode.Open, FileAccess.Read))
|
||||||
{
|
{
|
||||||
storedOriginalPath = await storageService.ExecuteAsync(storageType.Value,
|
storedOriginalPath = await storageService.ExecuteAsync(storageModeId.Value,
|
||||||
provider => provider.SaveAsync(originalLocalStream, originalStorageFileName, contentType));
|
provider => provider.SaveAsync(originalLocalStream, originalStorageFileName, contentType));
|
||||||
}
|
}
|
||||||
|
|
||||||
string hdStorageFileName;
|
|
||||||
string hdContentType;
|
|
||||||
string sourceForHdProcessing = tempOriginalLocalPath;
|
string sourceForHdProcessing = tempOriginalLocalPath;
|
||||||
|
|
||||||
if (convertToFormat != ImageFormat.Original)
|
string convertedExtension = ImageHelper.GetFileExtensionFromFormat(convertToFormat);
|
||||||
{
|
var hdStorageFileName = $"{baseName}-high-definition{convertedExtension}";
|
||||||
string convertedExtension = ImageHelper.GetFileExtensionFromFormat(convertToFormat);
|
var hdContentType = ImageHelper.GetMimeTypeFromFormat(convertToFormat);
|
||||||
hdStorageFileName = $"{baseName}-high-definition{convertedExtension}";
|
|
||||||
hdContentType = ImageHelper.GetMimeTypeFromFormat(convertToFormat);
|
|
||||||
|
|
||||||
tempConvertedHdLocalPath = Path.GetTempFileName() + convertedExtension;
|
|
||||||
File.Move(Path.GetTempFileName(), tempConvertedHdLocalPath);
|
|
||||||
|
|
||||||
await ImageHelper.ConvertImageFormatAsync(sourceForHdProcessing, tempConvertedHdLocalPath, convertToFormat, quality);
|
tempConvertedHdLocalPath = Path.GetTempFileName() + convertedExtension;
|
||||||
|
File.Move(Path.GetTempFileName(), tempConvertedHdLocalPath);
|
||||||
await using var convertedHdStream = new FileStream(tempConvertedHdLocalPath, FileMode.Open, FileAccess.Read);
|
|
||||||
storedHdPath = await storageService.ExecuteAsync(storageType.Value,
|
|
||||||
provider => provider.SaveAsync(convertedHdStream, hdStorageFileName, hdContentType));
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
hdStorageFileName = originalStorageFileName; // Same as original
|
|
||||||
hdContentType = contentType;
|
|
||||||
// No separate upload needed if it's the same as original; Path will point to the same stored object as OriginalPath.
|
|
||||||
// However, to ensure distinctness or if storage provider handles it, we can re-upload or copy.
|
|
||||||
// For simplicity, if no conversion, Path = OriginalPath.
|
|
||||||
storedHdPath = storedOriginalPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Generate and upload thumbnail (Picture.ThumbnailPath)
|
await ImageHelper.ConvertImageFormatAsync(sourceForHdProcessing, tempConvertedHdLocalPath, convertToFormat,
|
||||||
bool shouldGenerateThumbnailNow = userId.HasValue;
|
quality);
|
||||||
|
|
||||||
|
await using var convertedHdStream =
|
||||||
|
new FileStream(tempConvertedHdLocalPath!, FileMode.Open, FileAccess.Read);
|
||||||
|
var storedHdPath = await storageService.ExecuteAsync(storageModeId.Value,
|
||||||
|
provider => provider.SaveAsync(convertedHdStream, hdStorageFileName!, hdContentType!));
|
||||||
|
|
||||||
|
bool shouldGenerateThumbnailNow = userId.HasValue;
|
||||||
if (shouldGenerateThumbnailNow)
|
if (shouldGenerateThumbnailNow)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
// 缩略图最大宽度
|
||||||
|
int thumbnailMaxWidth = 500; // 默认值
|
||||||
|
string thumbnailMaxWidthConfigKey = "Upload:ThumbnailMaxWidth";
|
||||||
|
string? thumbnailMaxWidthConfig = configuration[thumbnailMaxWidthConfigKey];
|
||||||
|
if (!string.IsNullOrEmpty(thumbnailMaxWidthConfig) && int.TryParse(thumbnailMaxWidthConfig, out int parsedMaxWidth))
|
||||||
|
{
|
||||||
|
thumbnailMaxWidth = Math.Max(100, parsedMaxWidth); // 最小宽度 100
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
logger.LogWarning("配置项 '{ConfigKey}' 未找到或无效,使用默认缩略图最大宽度: {DefaultMaxWidth}", thumbnailMaxWidthConfigKey, thumbnailMaxWidth);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 缩略图压缩质量
|
||||||
|
int thumbnailQuality = 75; // 默认值
|
||||||
|
string thumbnailQualityConfigKey = "Upload:ThumbnailCompressionQuality";
|
||||||
|
string? thumbnailQualityConfig = configuration[thumbnailQualityConfigKey];
|
||||||
|
if (!string.IsNullOrEmpty(thumbnailQualityConfig) && int.TryParse(thumbnailQualityConfig, out int parsedThumbQuality))
|
||||||
|
{
|
||||||
|
thumbnailQuality = Math.Clamp(parsedThumbQuality, 30, 90); // 限制在 30-90 之间
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
logger.LogWarning("配置项 '{ConfigKey}' 未找到或无效,使用默认缩略图压缩质量: {DefaultThumbQuality}", thumbnailQualityConfigKey, thumbnailQuality);
|
||||||
|
}
|
||||||
|
|
||||||
tempThumbnailLocalPath = Path.GetTempFileName() + ".webp";
|
tempThumbnailLocalPath = Path.GetTempFileName() + ".webp";
|
||||||
File.Move(Path.GetTempFileName(), tempThumbnailLocalPath);
|
File.Move(Path.GetTempFileName(), tempThumbnailLocalPath);
|
||||||
|
await ImageHelper.CreateThumbnailAsync(tempOriginalLocalPath, tempThumbnailLocalPath, thumbnailMaxWidth, thumbnailQuality);
|
||||||
await ImageHelper.CreateThumbnailAsync(tempOriginalLocalPath, tempThumbnailLocalPath, 500);
|
|
||||||
|
|
||||||
string thumbnailUploadFileName = $"{baseName}-thumbnail.webp";
|
string thumbnailUploadFileName = $"{baseName}-thumbnail.webp";
|
||||||
await using var thumbnailFileStream = new FileStream(tempThumbnailLocalPath, FileMode.Open, FileAccess.Read);
|
await using var thumbnailFileStream =
|
||||||
storedThumbnailPath = await storageService.ExecuteAsync(storageType.Value,
|
new FileStream(tempThumbnailLocalPath!, FileMode.Open, FileAccess.Read);
|
||||||
provider => provider.SaveAsync(thumbnailFileStream, thumbnailUploadFileName, "image/webp"));
|
storedThumbnailPath = await storageService.ExecuteAsync(storageModeId.Value,
|
||||||
|
provider => provider.SaveAsync(thumbnailFileStream, thumbnailUploadFileName!, "image/webp"));
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "生成和上传缩略图失败 during initial upload");
|
logger.LogError(ex, "生成和上传缩略图失败 during initial upload");
|
||||||
// Continue without thumbnail if it fails here, background task can try later if needed
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
string initialTitle = Path.GetFileNameWithoutExtension(fileName);
|
string initialTitle = Path.GetFileNameWithoutExtension(fileName);
|
||||||
string initialDescription = $"Uploaded on {DateTime.UtcNow}";
|
string initialDescription = $"Uploaded on {DateTime.UtcNow}";
|
||||||
|
|
||||||
await using var dbContext = await contextFactory.CreateDbContextAsync();
|
await using var dbContextAsync = await contextFactory.CreateDbContextAsync();
|
||||||
User? user = null;
|
User? user = null;
|
||||||
if (userId is not null)
|
if (userId is not null)
|
||||||
{
|
{
|
||||||
user = await dbContext.Users.FirstOrDefaultAsync(u => u.Id == userId);
|
user = await dbContext.Users.FirstOrDefaultAsync(u => u.Id == userId);
|
||||||
if (user == null) throw new Exception("找不到指定的用户");
|
if (user == null) throw new Exception("找不到指定的用户");
|
||||||
}
|
}
|
||||||
Album? album = null;
|
|
||||||
if (albumId.HasValue)
|
if (albumId.HasValue)
|
||||||
{
|
{
|
||||||
album = await dbContext.Albums.Include(a => a.User)
|
var album = await dbContext.Albums.Include(a => a.User)
|
||||||
.FirstOrDefaultAsync(a => a.Id == albumId.Value);
|
.FirstOrDefaultAsync(a => a.Id == albumId.Value);
|
||||||
|
|
||||||
if (album == null)
|
if (album == null)
|
||||||
@@ -592,24 +640,21 @@ public class PictureService(
|
|||||||
{
|
{
|
||||||
Name = initialTitle,
|
Name = initialTitle,
|
||||||
Description = initialDescription,
|
Description = initialDescription,
|
||||||
OriginalPath = storedOriginalPath, // Store path to original uploaded file
|
OriginalPath = storedOriginalPath,
|
||||||
Path = storedHdPath, // Store path to HD (possibly converted) file
|
Path = storedHdPath,
|
||||||
ThumbnailPath = storedThumbnailPath, // Store path to thumbnail
|
ThumbnailPath = storedThumbnailPath,
|
||||||
User = user,
|
User = user,
|
||||||
Permission = permission,
|
Permission = permission,
|
||||||
AlbumId = albumId,
|
AlbumId = albumId,
|
||||||
StorageType = storageType.Value,
|
StorageModeId = storageModeId.Value,
|
||||||
// ProcessingStatus 等字段已移除
|
|
||||||
};
|
};
|
||||||
|
|
||||||
dbContext.Pictures.Add(picture);
|
dbContext.Pictures.Add(picture);
|
||||||
await dbContext.SaveChangesAsync();
|
await dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
if (userId != null) // Only queue for registered users
|
if (userId != null)
|
||||||
{
|
{
|
||||||
// Pass OriginalPath for EXIF extraction and other initial processing
|
|
||||||
await backgroundTaskQueue.QueuePictureProcessingTaskAsync(picture.Id, picture.OriginalPath);
|
await backgroundTaskQueue.QueuePictureProcessingTaskAsync(picture.Id, picture.OriginalPath);
|
||||||
|
|
||||||
var visualRecognitionPayload = new Background.Processors.VisualRecognitionPayload
|
var visualRecognitionPayload = new Background.Processors.VisualRecognitionPayload
|
||||||
{
|
{
|
||||||
PictureId = picture.Id,
|
PictureId = picture.Id,
|
||||||
@@ -618,43 +663,50 @@ public class PictureService(
|
|||||||
await backgroundTaskQueue.QueueVisualRecognitionTaskAsync(visualRecognitionPayload);
|
await backgroundTaskQueue.QueueVisualRecognitionTaskAsync(visualRecognitionPayload);
|
||||||
}
|
}
|
||||||
|
|
||||||
var pictureResponse = new PictureResponse
|
var pictureResponse = await MapPictureToResponseAsync(picture);
|
||||||
{
|
|
||||||
Id = picture.Id,
|
|
||||||
Name = picture.Name,
|
|
||||||
Path = await storageService.ExecuteAsync(picture.StorageType, provider =>
|
|
||||||
Task.FromResult(provider.GetUrl(picture.Path))),
|
|
||||||
OriginalPath = await storageService.ExecuteAsync(picture.StorageType, provider => // Added OriginalPath
|
|
||||||
Task.FromResult(provider.GetUrl(picture.OriginalPath))),
|
|
||||||
ThumbnailPath = !string.IsNullOrEmpty(picture.ThumbnailPath)
|
|
||||||
? await storageService.ExecuteAsync(picture.StorageType, provider =>
|
|
||||||
Task.FromResult(provider.GetUrl(picture.ThumbnailPath)))
|
|
||||||
: null,
|
|
||||||
Description = picture.Description,
|
|
||||||
CreatedAt = picture.CreatedAt,
|
|
||||||
Tags = new List<string>(),
|
|
||||||
Permission = permission,
|
|
||||||
AlbumId = albumId,
|
|
||||||
AlbumName = album?.Name,
|
|
||||||
// ProcessingStatus 字段已移除
|
|
||||||
};
|
|
||||||
|
|
||||||
return (pictureResponse, picture.Id);
|
return (pictureResponse, picture.Id);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
// Clean up temporary local files
|
|
||||||
if (!string.IsNullOrEmpty(tempOriginalLocalPath) && File.Exists(tempOriginalLocalPath))
|
if (!string.IsNullOrEmpty(tempOriginalLocalPath) && File.Exists(tempOriginalLocalPath))
|
||||||
{
|
{
|
||||||
try { File.Delete(tempOriginalLocalPath); } catch { /* ignored */ }
|
try
|
||||||
|
{
|
||||||
|
File.Delete(tempOriginalLocalPath);
|
||||||
|
Console.WriteLine(tempOriginalLocalPath);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
/* ignored */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(tempConvertedHdLocalPath) && File.Exists(tempConvertedHdLocalPath))
|
if (!string.IsNullOrEmpty(tempConvertedHdLocalPath) && File.Exists(tempConvertedHdLocalPath))
|
||||||
{
|
{
|
||||||
try { File.Delete(tempConvertedHdLocalPath); } catch { /* ignored */ }
|
try
|
||||||
|
{
|
||||||
|
File.Delete(tempConvertedHdLocalPath);
|
||||||
|
Console.WriteLine(tempConvertedHdLocalPath);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
/* ignored */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(tempThumbnailLocalPath) && File.Exists(tempThumbnailLocalPath))
|
if (!string.IsNullOrEmpty(tempThumbnailLocalPath) && File.Exists(tempThumbnailLocalPath))
|
||||||
{
|
{
|
||||||
try { File.Delete(tempThumbnailLocalPath); } catch { /* ignored */ }
|
try
|
||||||
|
{
|
||||||
|
File.Delete(tempThumbnailLocalPath);
|
||||||
|
Console.WriteLine(tempThumbnailLocalPath);
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
/* ignored */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -693,9 +745,19 @@ public class PictureService(
|
|||||||
|
|
||||||
await using var dbContext = await contextFactory.CreateDbContextAsync();
|
await using var dbContext = await contextFactory.CreateDbContextAsync();
|
||||||
|
|
||||||
|
// 在查询时包含 StorageModeId
|
||||||
var picturesToDelete = await dbContext.Pictures
|
var picturesToDelete = await dbContext.Pictures
|
||||||
.Include(p => p.User)
|
.Include(p => p.User)
|
||||||
.Where(p => pictureIds.Contains(p.Id))
|
.Where(p => pictureIds.Contains(p.Id))
|
||||||
|
.Select(p => new
|
||||||
|
{
|
||||||
|
p.Id,
|
||||||
|
p.Path,
|
||||||
|
p.ThumbnailPath,
|
||||||
|
p.OriginalPath,
|
||||||
|
UserId = p.User != null ? (int?)p.User.Id : null,
|
||||||
|
p.StorageModeId // 获取 StorageModeId
|
||||||
|
})
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
var foundPictureIds = picturesToDelete.Select(p => p.Id).ToHashSet();
|
var foundPictureIds = picturesToDelete.Select(p => p.Id).ToHashSet();
|
||||||
@@ -704,60 +766,63 @@ public class PictureService(
|
|||||||
results[id] = (false, "找不到此图片", null);
|
results[id] = (false, "找不到此图片", null);
|
||||||
}
|
}
|
||||||
|
|
||||||
var filesToDelete =
|
// 从数据库中删除记录
|
||||||
new List<(int PictureId, string Path, string? ThumbnailPath, string OriginalPath, int? UserId, StorageType StorageType)>();
|
|
||||||
foreach (var picture in picturesToDelete)
|
|
||||||
{
|
|
||||||
filesToDelete.Add((picture.Id, picture.Path, picture.ThumbnailPath, picture.OriginalPath, picture.User?.Id,
|
|
||||||
picture.StorageType));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (picturesToDelete.Any())
|
if (picturesToDelete.Any())
|
||||||
{
|
{
|
||||||
dbContext.Pictures.RemoveRange(picturesToDelete);
|
var idsToRemove = picturesToDelete.Select(p => p.Id).ToList();
|
||||||
await dbContext.SaveChangesAsync();
|
// EF Core 7+ 可以使用 ExecuteDeleteAsync
|
||||||
|
await dbContext.Pictures.Where(p => idsToRemove.Contains(p.Id)).ExecuteDeleteAsync();
|
||||||
|
// 对于旧版本 EF Core:
|
||||||
|
// var entitiesToRemove = await dbContext.Pictures.Where(p => idsToRemove.Contains(p.Id)).ToListAsync();
|
||||||
|
// dbContext.Pictures.RemoveRange(entitiesToRemove);
|
||||||
|
// await dbContext.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var (pictureId, path, thumbnailPath, originalPath, userId, storageType) in filesToDelete)
|
// 从存储中删除文件
|
||||||
|
foreach (var picInfo in picturesToDelete)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
string? errorMsg = null;
|
string? errorMsg = null;
|
||||||
|
if (picInfo.StorageModeId < 0)
|
||||||
|
{
|
||||||
|
results[picInfo.Id] = (false, "图片记录缺少有效的StorageModeId,无法删除文件。", picInfo.UserId);
|
||||||
|
logger.LogWarning("图片 {PictureId} 缺少 StorageModeId,跳过文件删除。", picInfo.Id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Delete original file
|
if (!string.IsNullOrEmpty(picInfo.OriginalPath))
|
||||||
if (!string.IsNullOrEmpty(originalPath))
|
|
||||||
{
|
{
|
||||||
await storageService.ExecuteAsync(storageType,
|
await storageService.ExecuteAsync(picInfo.StorageModeId,
|
||||||
provider => provider.DeleteAsync(originalPath));
|
provider => provider.DeleteAsync(picInfo.OriginalPath));
|
||||||
}
|
|
||||||
|
|
||||||
// Delete HD/processed file
|
|
||||||
if (!string.IsNullOrEmpty(path) && path != originalPath) // Avoid double delete if Path is same as OriginalPath
|
|
||||||
{
|
|
||||||
await storageService.ExecuteAsync(storageType,
|
|
||||||
provider => provider.DeleteAsync(path));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete thumbnail
|
if (!string.IsNullOrEmpty(picInfo.Path) && picInfo.Path != picInfo.OriginalPath)
|
||||||
if (!string.IsNullOrEmpty(thumbnailPath))
|
|
||||||
{
|
{
|
||||||
await storageService.ExecuteAsync(storageType,
|
await storageService.ExecuteAsync(picInfo.StorageModeId,
|
||||||
provider => provider.DeleteAsync(thumbnailPath));
|
provider => provider.DeleteAsync(picInfo.Path));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(picInfo.ThumbnailPath))
|
||||||
|
{
|
||||||
|
await storageService.ExecuteAsync(picInfo.StorageModeId,
|
||||||
|
provider => provider.DeleteAsync(picInfo.ThumbnailPath));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
errorMsg = $"数据库记录已删除,但删除文件失败: {ex.Message}";
|
errorMsg = $"数据库记录已删除,但删除文件失败: {ex.Message}";
|
||||||
logger.LogError(ex, "删除图片文件时出错");
|
logger.LogError(ex, "删除图片文件时出错 (ID: {PictureId})", picInfo.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
results[pictureId] = (true, errorMsg, userId);
|
results[picInfo.Id] = (true, errorMsg, picInfo.UserId);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
results[pictureId] = (false, $"处理图片删除时出错: {ex.Message}", userId);
|
results[picInfo.Id] = (false, $"处理图片删除时出错: {ex.Message}", picInfo.UserId);
|
||||||
|
logger.LogError(ex, "处理图片删除的外部循环出错 (ID: {PictureId})", picInfo.Id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -775,6 +840,7 @@ public class PictureService(
|
|||||||
var picture = await dbContext.Pictures
|
var picture = await dbContext.Pictures
|
||||||
.Include(p => p.User)
|
.Include(p => p.User)
|
||||||
.Include(p => p.Tags)
|
.Include(p => p.Tags)
|
||||||
|
.Include(p => p.StorageMode)
|
||||||
.FirstOrDefaultAsync(p => p.Id == pictureId);
|
.FirstOrDefaultAsync(p => p.Id == pictureId);
|
||||||
|
|
||||||
if (picture == null)
|
if (picture == null)
|
||||||
@@ -840,25 +906,7 @@ public class PictureService(
|
|||||||
picture.UpdatedAt = DateTime.UtcNow;
|
picture.UpdatedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
await dbContext.SaveChangesAsync();
|
await dbContext.SaveChangesAsync();
|
||||||
|
var pictureResponse = await MapPictureToResponseAsync(picture);
|
||||||
var pictureResponse = new PictureResponse
|
|
||||||
{
|
|
||||||
Id = picture.Id,
|
|
||||||
Name = picture.Name,
|
|
||||||
Path = await storageService.ExecuteAsync(picture.StorageType, provider =>
|
|
||||||
Task.FromResult(provider.GetUrl(picture.Path ?? string.Empty))),
|
|
||||||
OriginalPath = await storageService.ExecuteAsync(picture.StorageType, provider => // Added OriginalPath
|
|
||||||
Task.FromResult(provider.GetUrl(picture.OriginalPath ?? string.Empty))),
|
|
||||||
ThumbnailPath = !string.IsNullOrEmpty(picture.ThumbnailPath) ?
|
|
||||||
await storageService.ExecuteAsync(picture.StorageType, provider => Task.FromResult(provider.GetUrl(picture.ThumbnailPath)))
|
|
||||||
: null,
|
|
||||||
Description = picture.Description,
|
|
||||||
CreatedAt = picture.CreatedAt,
|
|
||||||
Tags = picture.Tags?.Select(t => t.Name).ToList() ?? new List<string>(),
|
|
||||||
TakenAt = picture.TakenAt,
|
|
||||||
ExifInfo = picture.ExifInfo
|
|
||||||
};
|
|
||||||
|
|
||||||
return (pictureResponse, userId);
|
return (pictureResponse, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -921,4 +969,25 @@ public class PictureService(
|
|||||||
return await dbContext.Favorites
|
return await dbContext.Favorites
|
||||||
.AnyAsync(f => f.PictureId == pictureId && f.User.Id == userId);
|
.AnyAsync(f => f.PictureId == pictureId && f.User.Id == userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<Picture?> GetPictureByIdAsync(int pictureId)
|
||||||
|
{
|
||||||
|
await using var dbContext = await contextFactory.CreateDbContextAsync();
|
||||||
|
var picture = await dbContext.Pictures
|
||||||
|
.Include(p => p.User)
|
||||||
|
.Include(p => p.Tags)
|
||||||
|
.Include(p => p.StorageMode) // 确保加载 StorageMode 以便 MapPictureToResponseAsync 正确工作
|
||||||
|
.AsNoTracking() // 如果只是读取数据,使用 AsNoTracking 可以提高性能
|
||||||
|
.FirstOrDefaultAsync(p => p.Id == pictureId);
|
||||||
|
|
||||||
|
if (picture == null)
|
||||||
|
{
|
||||||
|
logger.LogWarning("GetPictureByIdAsync: Picture with ID {PictureId} not found.", pictureId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var pictureResponse = await MapPictureToResponseAsync(picture);
|
||||||
|
|
||||||
|
return picture;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -15,7 +15,7 @@ public interface IStorageProvider
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 获取文件URL
|
/// 获取文件URL
|
||||||
/// </summary>
|
/// </summary>
|
||||||
string GetUrl(string storagePath);
|
string GetUrl(int pictureId,string storagePath);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 下载文件到本地临时目录
|
/// 下载文件到本地临时目录
|
||||||
|
|||||||
@@ -8,18 +8,18 @@ namespace Foxel.Services.Storage;
|
|||||||
public interface IStorageService
|
public interface IStorageService
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 在指定存储类型上执行操作
|
/// 在指定存储模式上执行操作
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <typeparam name="TResult">操作结果类型</typeparam>
|
/// <typeparam name="TResult">操作结果类型</typeparam>
|
||||||
/// <param name="storageType">存储类型</param>
|
/// <param name="storageModeId">存储模式的ID</param>
|
||||||
/// <param name="operation">要执行的操作</param>
|
/// <param name="operation">要执行的操作</param>
|
||||||
/// <returns>操作结果</returns>
|
/// <returns>操作结果</returns>
|
||||||
Task<TResult> ExecuteAsync<TResult>(StorageType storageType, Func<IStorageProvider, Task<TResult>> operation);
|
Task<TResult> ExecuteAsync<TResult>(int storageModeId, Func<IStorageProvider, Task<TResult>> operation);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 在指定存储类型上执行无返回值的操作
|
/// 在指定存储模式上执行无返回值的操作
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="storageType">存储类型</param>
|
/// <param name="storageModeId">存储模式的ID</param>
|
||||||
/// <param name="operation">要执行的操作</param>
|
/// <param name="operation">要执行的操作</param>
|
||||||
Task ExecuteAsync(StorageType storageType, Func<IStorageProvider, Task> operation);
|
Task ExecuteAsync(int storageModeId, Func<IStorageProvider, Task> operation);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,15 +10,26 @@ using Microsoft.Extensions.Logging;
|
|||||||
|
|
||||||
namespace Foxel.Services.Storage.Providers;
|
namespace Foxel.Services.Storage.Providers;
|
||||||
|
|
||||||
|
public class CosStorageConfig
|
||||||
|
{
|
||||||
|
public string Region { get; set; } = string.Empty;
|
||||||
|
public string SecretId { get; set; } = string.Empty;
|
||||||
|
public string SecretKey { get; set; } = string.Empty;
|
||||||
|
public string? Token { get; set; } // Token 可能为空
|
||||||
|
public string BucketName { get; set; } = string.Empty;
|
||||||
|
public string? CdnUrl { get; set; }
|
||||||
|
public bool PublicRead { get; set; } = false;
|
||||||
|
}
|
||||||
|
|
||||||
public class CustomQCloudCredentialProvider : DefaultSessionQCloudCredentialProvider
|
public class CustomQCloudCredentialProvider : DefaultSessionQCloudCredentialProvider
|
||||||
{
|
{
|
||||||
private readonly IConfigService _configService;
|
private readonly CosStorageConfig _config;
|
||||||
private readonly ILogger<CustomQCloudCredentialProvider> _logger;
|
private readonly ILogger<CustomQCloudCredentialProvider> _logger;
|
||||||
|
|
||||||
public CustomQCloudCredentialProvider(IConfigService configService, ILogger<CustomQCloudCredentialProvider> logger)
|
public CustomQCloudCredentialProvider(CosStorageConfig config, ILogger<CustomQCloudCredentialProvider> logger)
|
||||||
: base(null, null, 0L, null)
|
: base(null, null, 0L, null) // Base constructor parameters are set in Refresh
|
||||||
{
|
{
|
||||||
_configService = configService;
|
_config = config;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
Refresh();
|
Refresh();
|
||||||
}
|
}
|
||||||
@@ -27,11 +38,12 @@ public class CustomQCloudCredentialProvider : DefaultSessionQCloudCredentialProv
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
string tmpSecretId = _configService["Storage:CosStorageSecretId"];
|
string tmpSecretId = _config.SecretId;
|
||||||
string tmpSecretKey = _configService["Storage:CosStorageSecretKey"];
|
string tmpSecretKey = _config.SecretKey;
|
||||||
string tmpToken = _configService["Storage:CosStorageToken"];
|
string? tmpToken = _config.Token;
|
||||||
long tmpStartTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
long tmpStartTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||||
long tmpExpiredTime = tmpStartTime + 7200;
|
// 腾讯云建议临时密钥有效期最长2小时(7200秒)
|
||||||
|
long tmpExpiredTime = tmpStartTime + 7200;
|
||||||
SetQCloudCredential(tmpSecretId, tmpSecretKey,
|
SetQCloudCredential(tmpSecretId, tmpSecretKey,
|
||||||
$"{tmpStartTime};{tmpExpiredTime}", tmpToken);
|
$"{tmpStartTime};{tmpExpiredTime}", tmpToken);
|
||||||
}
|
}
|
||||||
@@ -44,18 +56,38 @@ public class CustomQCloudCredentialProvider : DefaultSessionQCloudCredentialProv
|
|||||||
}
|
}
|
||||||
|
|
||||||
[StorageProvider(StorageType.Cos)]
|
[StorageProvider(StorageType.Cos)]
|
||||||
public class CosStorageProvider(IConfigService configService, ILogger<CosStorageProvider> logger) : IStorageProvider
|
public class CosStorageProvider : IStorageProvider
|
||||||
{
|
{
|
||||||
|
private readonly CosStorageConfig _cosConfig;
|
||||||
|
private readonly IConfigService _configService; // 保留用于可能的应用级配置
|
||||||
|
private readonly ILogger<CosStorageProvider> _logger;
|
||||||
|
|
||||||
|
public CosStorageProvider(CosStorageConfig cosConfig, IConfigService configService, ILogger<CosStorageProvider> logger)
|
||||||
|
{
|
||||||
|
_cosConfig = cosConfig;
|
||||||
|
_configService = configService; // 存储起来以备后用
|
||||||
|
_logger = logger;
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(_cosConfig.Region) ||
|
||||||
|
string.IsNullOrEmpty(_cosConfig.SecretId) ||
|
||||||
|
string.IsNullOrEmpty(_cosConfig.SecretKey) ||
|
||||||
|
string.IsNullOrEmpty(_cosConfig.BucketName))
|
||||||
|
{
|
||||||
|
_logger.LogError("COS Storage配置不完整 (Region, SecretId, SecretKey, BucketName 都是必需的).");
|
||||||
|
throw new InvalidOperationException("COS Storage配置不完整。");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private CosXml CreateClient()
|
private CosXml CreateClient()
|
||||||
{
|
{
|
||||||
var config = new CosXmlConfig.Builder()
|
var config = new CosXmlConfig.Builder()
|
||||||
.IsHttps(true)
|
.IsHttps(true)
|
||||||
.SetRegion(configService["Storage:CosStorageRegion"])
|
.SetRegion(_cosConfig.Region)
|
||||||
.SetDebugLog(true)
|
.SetDebugLog(true)
|
||||||
.Build();
|
.Build();
|
||||||
|
|
||||||
var cosCredentialProvider = new CustomQCloudCredentialProvider(configService,
|
var cosCredentialProvider = new CustomQCloudCredentialProvider(_cosConfig,
|
||||||
logger.IsEnabled(LogLevel.Debug) ?
|
_logger.IsEnabled(LogLevel.Debug) ?
|
||||||
Microsoft.Extensions.Logging.LoggerFactory.Create(builder => builder.AddConsole()).CreateLogger<CustomQCloudCredentialProvider>() :
|
Microsoft.Extensions.Logging.LoggerFactory.Create(builder => builder.AddConsole()).CreateLogger<CustomQCloudCredentialProvider>() :
|
||||||
Microsoft.Extensions.Logging.Abstractions.NullLogger<CustomQCloudCredentialProvider>.Instance);
|
Microsoft.Extensions.Logging.Abstractions.NullLogger<CustomQCloudCredentialProvider>.Instance);
|
||||||
|
|
||||||
@@ -85,7 +117,7 @@ public class CosStorageProvider(IConfigService configService, ILogger<CosStorage
|
|||||||
var cosXmlClient = CreateClient();
|
var cosXmlClient = CreateClient();
|
||||||
var transferConfig = new TransferConfig();
|
var transferConfig = new TransferConfig();
|
||||||
var transferManager = new TransferManager(cosXmlClient, transferConfig);
|
var transferManager = new TransferManager(cosXmlClient, transferConfig);
|
||||||
var uploadTask = new COSXMLUploadTask(configService["Storage:CosStorageBucketName"], objectKey);
|
var uploadTask = new COSXMLUploadTask(_cosConfig.BucketName, objectKey);
|
||||||
uploadTask.SetSrcPath(tempPath);
|
uploadTask.SetSrcPath(tempPath);
|
||||||
await transferManager.UploadAsync(uploadTask);
|
await transferManager.UploadAsync(uploadTask);
|
||||||
return objectKey;
|
return objectKey;
|
||||||
@@ -101,17 +133,17 @@ public class CosStorageProvider(IConfigService configService, ILogger<CosStorage
|
|||||||
}
|
}
|
||||||
catch (CosClientException clientEx)
|
catch (CosClientException clientEx)
|
||||||
{
|
{
|
||||||
logger.LogError(clientEx, "COS客户端异常");
|
_logger.LogError(clientEx, "COS客户端异常");
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
catch (CosServerException serverEx)
|
catch (CosServerException serverEx)
|
||||||
{
|
{
|
||||||
logger.LogError(serverEx, "COS服务器异常: {ServerInfo}", serverEx.GetInfo());
|
_logger.LogError(serverEx, "COS服务器异常: {ServerInfo}", serverEx.GetInfo());
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "上传文件到腾讯云COS时出错");
|
_logger.LogError(ex, "上传文件到腾讯云COS时出错");
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -124,38 +156,38 @@ public class CosStorageProvider(IConfigService configService, ILogger<CosStorage
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
var cosXmlClient = CreateClient();
|
var cosXmlClient = CreateClient();
|
||||||
var request = new DeleteObjectRequest(configService["Storage:CosStorageBucketName"], storagePath);
|
var request = new DeleteObjectRequest(_cosConfig.BucketName, storagePath);
|
||||||
await Task.Run(() => cosXmlClient.DeleteObject(request));
|
await Task.Run(() => cosXmlClient.DeleteObject(request));
|
||||||
}
|
}
|
||||||
catch (CosClientException clientEx)
|
catch (CosClientException clientEx)
|
||||||
{
|
{
|
||||||
logger.LogWarning(clientEx, "COS客户端异常");
|
_logger.LogWarning(clientEx, "COS客户端异常");
|
||||||
}
|
}
|
||||||
catch (CosServerException serverEx)
|
catch (CosServerException serverEx)
|
||||||
{
|
{
|
||||||
logger.LogWarning(serverEx, "COS服务器异常: {ServerInfo}", serverEx.GetInfo());
|
_logger.LogWarning(serverEx, "COS服务器异常: {ServerInfo}", serverEx.GetInfo());
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogWarning(ex, "从腾讯云COS删除文件时出错");
|
_logger.LogWarning(ex, "从腾讯云COS删除文件时出错");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public string GetUrl(string storagePath)
|
public string GetUrl(int pictureId,string storagePath)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(storagePath))
|
if (string.IsNullOrEmpty(storagePath))
|
||||||
return "/images/unavailable.gif";
|
return "/images/unavailable.gif";
|
||||||
|
|
||||||
string cdnUrl = configService["Storage:CosStorageCdnUrl"];
|
string? cdnUrl = _cosConfig.CdnUrl;
|
||||||
string bucketName = configService["Storage:CosStorageBucketName"];
|
string bucketName = _cosConfig.BucketName;
|
||||||
string region = configService["Storage:CosStorageRegion"];
|
string region = _cosConfig.Region;
|
||||||
bool isPublicRead = bool.TryParse(configService["Storage:CosStoragePublicRead"], out var publicRead) && publicRead;
|
bool isPublicRead = _cosConfig.PublicRead;
|
||||||
|
|
||||||
// 优先使用CDN
|
// 优先使用CDN
|
||||||
if (!string.IsNullOrEmpty(cdnUrl))
|
if (!string.IsNullOrEmpty(cdnUrl))
|
||||||
return $"{cdnUrl}/{storagePath}";
|
return $"{cdnUrl.TrimEnd('/')}/{storagePath}";
|
||||||
|
|
||||||
// 公开读取的桶可直接访问
|
// 公开读取的桶可直接访问
|
||||||
if (isPublicRead)
|
if (isPublicRead)
|
||||||
@@ -179,7 +211,7 @@ public class CosStorageProvider(IConfigService configService, ILogger<CosStorage
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "生成腾讯云COS文件URL时出错");
|
_logger.LogError(ex, "生成腾讯云COS文件URL时出错");
|
||||||
return "/images/unavailable.gif";
|
return "/images/unavailable.gif";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -200,7 +232,7 @@ public class CosStorageProvider(IConfigService configService, ILogger<CosStorage
|
|||||||
Directory.CreateDirectory(tempDir);
|
Directory.CreateDirectory(tempDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
string bucketName = configService["Storage:CosStorageBucketName"];
|
string bucketName = _cosConfig.BucketName;
|
||||||
string fileName = Path.GetFileName(storagePath);
|
string fileName = Path.GetFileName(storagePath);
|
||||||
|
|
||||||
var cosXmlClient = CreateClient();
|
var cosXmlClient = CreateClient();
|
||||||
@@ -212,17 +244,17 @@ public class CosStorageProvider(IConfigService configService, ILogger<CosStorage
|
|||||||
}
|
}
|
||||||
catch (CosClientException clientEx)
|
catch (CosClientException clientEx)
|
||||||
{
|
{
|
||||||
logger.LogError(clientEx, "COS客户端异常");
|
_logger.LogError(clientEx, "COS客户端异常");
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
catch (CosServerException serverEx)
|
catch (CosServerException serverEx)
|
||||||
{
|
{
|
||||||
logger.LogError(serverEx, "COS服务器异常: {ServerInfo}", serverEx.GetInfo());
|
_logger.LogError(serverEx, "COS服务器异常: {ServerInfo}", serverEx.GetInfo());
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "从腾讯云COS下载文件时出错");
|
_logger.LogError(ex, "从腾讯云COS下载文件时出错");
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,49 +4,134 @@ using Microsoft.Extensions.Logging;
|
|||||||
|
|
||||||
namespace Foxel.Services.Storage.Providers;
|
namespace Foxel.Services.Storage.Providers;
|
||||||
|
|
||||||
[StorageProvider(StorageType.Local)]
|
public class LocalStorageConfig
|
||||||
public class LocalStorageProvider(IConfigService configService) : IStorageProvider
|
|
||||||
{
|
{
|
||||||
private readonly string _baseDirectory = Path.Combine(Directory.GetCurrentDirectory(), "Uploads");
|
public string BasePath { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string ServerUrl { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string PublicBasePath { get; set; } = "/Uploads";
|
||||||
|
}
|
||||||
|
|
||||||
|
[StorageProvider(StorageType.Local)]
|
||||||
|
public class LocalStorageProvider : IStorageProvider
|
||||||
|
{
|
||||||
|
private readonly LocalStorageConfig _config;
|
||||||
|
private readonly ILogger<LocalStorageProvider> _logger;
|
||||||
|
|
||||||
|
public LocalStorageProvider(LocalStorageConfig config, ILogger<LocalStorageProvider> logger)
|
||||||
|
{
|
||||||
|
_config = config;
|
||||||
|
_logger = logger;
|
||||||
|
if (string.IsNullOrWhiteSpace(_config.BasePath))
|
||||||
|
{
|
||||||
|
var defaultPath = Path.Combine(Directory.GetCurrentDirectory(), "DefaultUploads");
|
||||||
|
_logger.LogWarning("LocalStorageConfig.BasePath 未配置,将使用默认路径: {DefaultPath}", defaultPath);
|
||||||
|
throw new InvalidOperationException("LocalStorageConfig.BasePath 必须在配置中提供。");
|
||||||
|
}
|
||||||
|
|
||||||
|
Directory.CreateDirectory(_config.BasePath);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<string> SaveAsync(Stream fileStream, string fileName, string contentType)
|
public async Task<string> SaveAsync(Stream fileStream, string fileName, string contentType)
|
||||||
{
|
{
|
||||||
string currentDate = DateTime.Now.ToString("yyyy/MM");
|
try
|
||||||
string folder = Path.Combine(_baseDirectory, currentDate);
|
{
|
||||||
Directory.CreateDirectory(folder);
|
string currentDate = DateTime.Now.ToString("yyyy/MM");
|
||||||
|
string folder = Path.Combine(_config.BasePath, currentDate);
|
||||||
|
Directory.CreateDirectory(folder);
|
||||||
|
|
||||||
string newFileName = fileName;
|
string newFileName = fileName;
|
||||||
string filePath = Path.Combine(folder, newFileName);
|
string filePath = Path.Combine(folder, newFileName);
|
||||||
|
|
||||||
await using var output = new FileStream(filePath, FileMode.Create);
|
await using var output = new FileStream(filePath, FileMode.Create);
|
||||||
await fileStream.CopyToAsync(output);
|
await fileStream.CopyToAsync(output);
|
||||||
return $"/Uploads/{currentDate}/{newFileName}";
|
return $"{_config.PublicBasePath.TrimEnd('/')}/{currentDate}/{newFileName}";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "保存文件到本地存储时出错。BasePath: {BasePath}, FileName: {FileName}", _config.BasePath, fileName);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public Task DeleteAsync(string storagePath)
|
public Task DeleteAsync(string storagePath)
|
||||||
{
|
{
|
||||||
string fullPath = Path.Combine(Directory.GetCurrentDirectory(), storagePath.TrimStart('/'));
|
try
|
||||||
if (File.Exists(fullPath))
|
{
|
||||||
File.Delete(fullPath);
|
string relativePath = storagePath;
|
||||||
|
if (!string.IsNullOrEmpty(_config.PublicBasePath) && storagePath.StartsWith(_config.PublicBasePath))
|
||||||
|
{
|
||||||
|
relativePath = storagePath.Substring(_config.PublicBasePath.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
string fullPath = Path.Combine(_config.BasePath, relativePath.TrimStart('/'));
|
||||||
|
|
||||||
|
if (File.Exists(fullPath))
|
||||||
|
{
|
||||||
|
File.Delete(fullPath);
|
||||||
|
_logger.LogInformation("已删除本地文件: {FullPath}", fullPath);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogWarning("尝试删除本地文件但文件未找到: {FullPath}", fullPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "删除本地文件时出错。StoragePath: {StoragePath}, BasePath: {BasePath}", storagePath,
|
||||||
|
_config.BasePath);
|
||||||
|
}
|
||||||
|
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
public string GetUrl(string? storagePath)
|
public string GetUrl(int pictureId,string? storagePath)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(storagePath))
|
if (string.IsNullOrEmpty(storagePath))
|
||||||
return $"/images/unavailable.gif";
|
return $"/images/unavailable.gif";
|
||||||
|
|
||||||
string serverUrl = configService["AppSettings:ServerUrl"];
|
string serverUrl = _config.ServerUrl.TrimEnd('/');
|
||||||
return $"{serverUrl}{storagePath}";
|
return string.IsNullOrEmpty(serverUrl) ? storagePath : $"{serverUrl}{storagePath}";
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<string> DownloadFileAsync(string storagePath)
|
public Task<string> DownloadFileAsync(string storagePath)
|
||||||
{
|
{
|
||||||
string fullPath = Path.Combine(Directory.GetCurrentDirectory(), storagePath.TrimStart('/'));
|
try
|
||||||
if (!File.Exists(fullPath))
|
|
||||||
{
|
{
|
||||||
throw new FileNotFoundException($"找不到文件: {fullPath}");
|
string relativePath = storagePath;
|
||||||
|
if (!string.IsNullOrEmpty(_config.PublicBasePath) && storagePath.StartsWith(_config.PublicBasePath))
|
||||||
|
{
|
||||||
|
relativePath = storagePath.Substring(_config.PublicBasePath.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
string fullPath = Path.Combine(_config.BasePath, relativePath.TrimStart('/'));
|
||||||
|
|
||||||
|
if (!File.Exists(fullPath))
|
||||||
|
{
|
||||||
|
_logger.LogError("尝试下载但文件未找到: {FullPath}", fullPath);
|
||||||
|
throw new FileNotFoundException($"本地存储中找不到文件: {fullPath}", fullPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
string tempFileName = Path.GetRandomFileName();
|
||||||
|
if (Path.HasExtension(fullPath))
|
||||||
|
{
|
||||||
|
tempFileName = Path.ChangeExtension(tempFileName, Path.GetExtension(fullPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
string tempFilePath = Path.Combine(Path.GetTempPath(), tempFileName);
|
||||||
|
|
||||||
|
File.Copy(fullPath, tempFilePath, true);
|
||||||
|
_logger.LogInformation("已将文件 {FullPath} 复制到临时位置 {TempFilePath} 以供下载/处理", fullPath, tempFilePath);
|
||||||
|
|
||||||
|
return Task.FromResult(tempFilePath); // 返回临时文件的路径
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "下载本地文件时出错。StoragePath: {StoragePath}, BasePath: {BasePath}", storagePath,
|
||||||
|
_config.BasePath);
|
||||||
|
throw;
|
||||||
}
|
}
|
||||||
return Task.FromResult(fullPath);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7,16 +7,47 @@ using Microsoft.Extensions.Logging;
|
|||||||
|
|
||||||
namespace Foxel.Services.Storage.Providers;
|
namespace Foxel.Services.Storage.Providers;
|
||||||
|
|
||||||
[StorageProvider(StorageType.S3)]
|
public class S3StorageConfig
|
||||||
public class S3StorageProvider(IConfigService configService, ILogger<S3StorageProvider> logger) : IStorageProvider
|
|
||||||
{
|
{
|
||||||
|
public string AccessKey { get; set; } = string.Empty;
|
||||||
|
public string SecretKey { get; set; } = string.Empty;
|
||||||
|
public string Endpoint { get; set; } = string.Empty;
|
||||||
|
public string? Region { get; set; } // Region 可能为空,特别是对于非AWS S3兼容存储
|
||||||
|
public bool UsePathStyleUrls { get; set; } = false;
|
||||||
|
public string BucketName { get; set; } = string.Empty;
|
||||||
|
public string? CdnUrl { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[StorageProvider(StorageType.S3)]
|
||||||
|
public class S3StorageProvider : IStorageProvider
|
||||||
|
{
|
||||||
|
private readonly S3StorageConfig _s3Config;
|
||||||
|
private readonly IConfigService _configService; // 保留用于可能的应用级配置
|
||||||
|
private readonly ILogger<S3StorageProvider> _logger;
|
||||||
|
|
||||||
|
public S3StorageProvider(S3StorageConfig s3Config, IConfigService configService, ILogger<S3StorageProvider> logger)
|
||||||
|
{
|
||||||
|
_s3Config = s3Config;
|
||||||
|
_configService = configService;
|
||||||
|
_logger = logger;
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(_s3Config.AccessKey) ||
|
||||||
|
string.IsNullOrEmpty(_s3Config.SecretKey) ||
|
||||||
|
string.IsNullOrEmpty(_s3Config.Endpoint) ||
|
||||||
|
string.IsNullOrEmpty(_s3Config.BucketName))
|
||||||
|
{
|
||||||
|
_logger.LogError("S3 Storage配置不完整 (AccessKey, SecretKey, Endpoint, BucketName 都是必需的).");
|
||||||
|
throw new InvalidOperationException("S3 Storage配置不完整。");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private AmazonS3Client CreateClient()
|
private AmazonS3Client CreateClient()
|
||||||
{
|
{
|
||||||
string accessKey = configService["Storage:S3StorageAccessKey"];
|
string accessKey = _s3Config.AccessKey;
|
||||||
string secretKey = configService["Storage:S3StorageSecretKey"];
|
string secretKey = _s3Config.SecretKey;
|
||||||
string endpoint = configService["Storage:S3StorageEndpoint"];
|
string endpoint = _s3Config.Endpoint;
|
||||||
string region = configService["Storage:S3StorageRegion"];
|
string? region = _s3Config.Region;
|
||||||
bool usePathStyleUrls = bool.TryParse(configService["Storage:S3StorageUsePathStyleUrls"], out var usePathStyle) && usePathStyle;
|
bool usePathStyleUrls = _s3Config.UsePathStyleUrls;
|
||||||
|
|
||||||
var config = new AmazonS3Config
|
var config = new AmazonS3Config
|
||||||
{
|
{
|
||||||
@@ -25,7 +56,7 @@ public class S3StorageProvider(IConfigService configService, ILogger<S3StoragePr
|
|||||||
ForcePathStyle = usePathStyleUrls
|
ForcePathStyle = usePathStyleUrls
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(region) && endpoint.Contains("amazonaws.com"))
|
if (!string.IsNullOrEmpty(region) && endpoint.Contains("amazonaws.com", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
config.RegionEndpoint = Amazon.RegionEndpoint.GetBySystemName(region);
|
config.RegionEndpoint = Amazon.RegionEndpoint.GetBySystemName(region);
|
||||||
}
|
}
|
||||||
@@ -55,7 +86,7 @@ public class S3StorageProvider(IConfigService configService, ILogger<S3StoragePr
|
|||||||
{
|
{
|
||||||
InputStream = fileStream,
|
InputStream = fileStream,
|
||||||
Key = objectKey,
|
Key = objectKey,
|
||||||
BucketName = configService["Storage:S3StorageBucketName"],
|
BucketName = _s3Config.BucketName,
|
||||||
ContentType = contentType
|
ContentType = contentType
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -66,7 +97,7 @@ public class S3StorageProvider(IConfigService configService, ILogger<S3StoragePr
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "上传文件到S3时出错");
|
_logger.LogError(ex, "上传文件到S3时出错");
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -81,7 +112,7 @@ public class S3StorageProvider(IConfigService configService, ILogger<S3StoragePr
|
|||||||
using var client = CreateClient();
|
using var client = CreateClient();
|
||||||
var deleteRequest = new DeleteObjectRequest
|
var deleteRequest = new DeleteObjectRequest
|
||||||
{
|
{
|
||||||
BucketName = configService["Storage:S3StorageBucketName"],
|
BucketName = _s3Config.BucketName,
|
||||||
Key = storagePath
|
Key = storagePath
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -89,30 +120,30 @@ public class S3StorageProvider(IConfigService configService, ILogger<S3StoragePr
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogWarning(ex, "从S3删除文件时出错");
|
_logger.LogWarning(ex, "从S3删除文件时出错");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public string GetUrl(string storagePath)
|
public string GetUrl(int pictureId,string storagePath)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(storagePath))
|
if (string.IsNullOrEmpty(storagePath))
|
||||||
return "/images/unavailable.gif";
|
return "/images/unavailable.gif";
|
||||||
|
|
||||||
string cdnUrl = configService["Storage:S3StorageCdnUrl"];
|
string? cdnUrl = _s3Config.CdnUrl;
|
||||||
|
|
||||||
// 如果配置了CDN URL,使用CDN
|
// 如果配置了CDN URL,使用CDN
|
||||||
if (!string.IsNullOrEmpty(cdnUrl))
|
if (!string.IsNullOrEmpty(cdnUrl))
|
||||||
{
|
{
|
||||||
return $"{cdnUrl}/{storagePath}";
|
return $"{cdnUrl.TrimEnd('/')}/{storagePath}";
|
||||||
}
|
}
|
||||||
|
|
||||||
// 否则使用S3直链或生成预签名URL
|
// 否则使用S3直链或生成预签名URL
|
||||||
using var client = CreateClient();
|
using var client = CreateClient();
|
||||||
var request = new GetPreSignedUrlRequest
|
var request = new GetPreSignedUrlRequest
|
||||||
{
|
{
|
||||||
BucketName = configService["Storage:S3StorageBucketName"],
|
BucketName = _s3Config.BucketName,
|
||||||
Key = storagePath,
|
Key = storagePath,
|
||||||
Expires = DateTime.UtcNow.AddHours(1) // URL有效期1小时
|
Expires = DateTime.UtcNow.AddHours(1) // URL有效期1小时
|
||||||
};
|
};
|
||||||
@@ -121,7 +152,7 @@ public class S3StorageProvider(IConfigService configService, ILogger<S3StoragePr
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "生成S3文件URL时出错");
|
_logger.LogError(ex, "生成S3文件URL时出错");
|
||||||
return "/images/unavailable.gif";
|
return "/images/unavailable.gif";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -150,7 +181,7 @@ public class S3StorageProvider(IConfigService configService, ILogger<S3StoragePr
|
|||||||
using var client = CreateClient();
|
using var client = CreateClient();
|
||||||
var request = new GetObjectRequest
|
var request = new GetObjectRequest
|
||||||
{
|
{
|
||||||
BucketName = configService["Storage:S3StorageBucketName"],
|
BucketName = _s3Config.BucketName,
|
||||||
Key = storagePath
|
Key = storagePath
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -162,7 +193,7 @@ public class S3StorageProvider(IConfigService configService, ILogger<S3StoragePr
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "从S3下载文件时出错");
|
_logger.LogError(ex, "从S3下载文件时出错");
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,21 +4,37 @@ using System.Text.Json.Serialization;
|
|||||||
using Foxel.Services.Attributes;
|
using Foxel.Services.Attributes;
|
||||||
using Foxel.Services.Configuration;
|
using Foxel.Services.Configuration;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
|
|
||||||
namespace Foxel.Services.Storage.Providers;
|
namespace Foxel.Services.Storage.Providers;
|
||||||
|
|
||||||
|
public class TelegramStorageConfig
|
||||||
|
{
|
||||||
|
public string BotToken { get; set; } = string.Empty;
|
||||||
|
public string ChatId { get; set; } = string.Empty;
|
||||||
|
public string? ProxyAddress { get; set; }
|
||||||
|
public string? ProxyPort { get; set; }
|
||||||
|
public string? ProxyUsername { get; set; }
|
||||||
|
public string? ProxyPassword { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
[StorageProvider(StorageType.Telegram)]
|
[StorageProvider(StorageType.Telegram)]
|
||||||
public class TelegramStorageProvider(IConfigService configService, ILogger<TelegramStorageProvider> logger) : IStorageProvider
|
public class TelegramStorageProvider(TelegramStorageConfig _telegramConfig, IConfigService configService, ILogger<TelegramStorageProvider> logger) : IStorageProvider
|
||||||
{
|
{
|
||||||
public async Task<string> SaveAsync(Stream fileStream, string fileName, string contentType)
|
public async Task<string> SaveAsync(Stream fileStream, string fileName, string contentType)
|
||||||
{
|
{
|
||||||
string botToken = configService["Storage:TelegramStorageBotToken"];
|
string botToken = _telegramConfig.BotToken;
|
||||||
string chatId = configService["Storage:TelegramStorageChatId"];
|
string chatId = _telegramConfig.ChatId;
|
||||||
|
if (string.IsNullOrEmpty(botToken) || string.IsNullOrEmpty(chatId))
|
||||||
|
{
|
||||||
|
logger.LogError("Telegram BotToken 或 ChatId 未在配置中提供。");
|
||||||
|
throw new InvalidOperationException("Telegram BotToken 或 ChatId 未配置。");
|
||||||
|
}
|
||||||
|
|
||||||
using var httpClient = CreateHttpClient();
|
using var httpClient = CreateHttpClient();
|
||||||
using var formData = new MultipartFormDataContent();
|
using var formData = new MultipartFormDataContent
|
||||||
formData.Add(new StringContent(chatId), "chat_id");
|
{
|
||||||
|
{ new StringContent(chatId), "chat_id" }
|
||||||
|
};
|
||||||
var safeFileName = Path.GetFileNameWithoutExtension(fileName);
|
var safeFileName = Path.GetFileNameWithoutExtension(fileName);
|
||||||
if (safeFileName.Length > 100)
|
if (safeFileName.Length > 100)
|
||||||
safeFileName = safeFileName.Substring(0, 100);
|
safeFileName = safeFileName.Substring(0, 100);
|
||||||
@@ -103,23 +119,37 @@ public class TelegramStorageProvider(IConfigService configService, ILogger<Teleg
|
|||||||
var metadata = JsonSerializer.Deserialize<TelegramFileMetadata>(storagePath);
|
var metadata = JsonSerializer.Deserialize<TelegramFileMetadata>(storagePath);
|
||||||
if (metadata == null || string.IsNullOrEmpty(metadata.ChatId) || metadata.MessageId <= 0)
|
if (metadata == null || string.IsNullOrEmpty(metadata.ChatId) || metadata.MessageId <= 0)
|
||||||
{
|
{
|
||||||
|
logger.LogWarning("无效的 Telegram 元数据,无法删除: {StoragePath}", storagePath);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
string botToken = configService["Storage:TelegramStorageBotToken"];
|
string botToken = _telegramConfig.BotToken;
|
||||||
|
if (string.IsNullOrEmpty(botToken))
|
||||||
|
{
|
||||||
|
logger.LogError("Telegram BotToken 未在配置中提供,无法删除文件。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
using var httpClient = CreateHttpClient();
|
using var httpClient = CreateHttpClient();
|
||||||
var url =
|
var url =
|
||||||
$"https://api.telegram.org/bot{botToken}/deleteMessage?chat_id={metadata.ChatId}&message_id={metadata.MessageId}";
|
$"https://api.telegram.org/bot{botToken}/deleteMessage?chat_id={metadata.ChatId}&message_id={metadata.MessageId}";
|
||||||
await httpClient.GetAsync(url);
|
var response = await httpClient.GetAsync(url);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var errorContent = await response.Content.ReadAsStringAsync();
|
||||||
|
logger.LogWarning("删除 Telegram 消息失败: ChatId={ChatId}, MessageId={MessageId}, Status={StatusCode}, Response={ErrorContent}", metadata.ChatId, metadata.MessageId, response.StatusCode, errorContent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (JsonException jsonEx)
|
||||||
|
{
|
||||||
|
logger.LogWarning(jsonEx, "解析 Telegram 元数据以进行删除时出错: {StoragePath}", storagePath);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogWarning(ex, "删除 Telegram 文件时出错");
|
logger.LogWarning(ex, "删除 Telegram 文件时出错: {StoragePath}", storagePath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public string GetUrl(string storagePath)
|
public string GetUrl(int pictureId,string storagePath)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -130,12 +160,17 @@ public class TelegramStorageProvider(IConfigService configService, ILogger<Teleg
|
|||||||
}
|
}
|
||||||
|
|
||||||
string serverUrl = configService["AppSettings:ServerUrl"];
|
string serverUrl = configService["AppSettings:ServerUrl"];
|
||||||
return $"{serverUrl}/api/picture/get_telegram_file?fileId={metadata.FileId}";
|
return $"{serverUrl}/api/picture/file/{pictureId}";
|
||||||
|
}
|
||||||
|
catch (JsonException jsonEx)
|
||||||
|
{
|
||||||
|
logger.LogError(jsonEx, "解析 Telegram 元数据以生成 URL 时出错: {StoragePath}", storagePath);
|
||||||
|
return "/images/unavailable.gif";
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "生成 Telegram 文件 URL 时出错");
|
logger.LogError(ex, "生成 Telegram 文件 URL 时出错: {StoragePath}", storagePath);
|
||||||
return $"/images/unavailable.gif";
|
return "/images/unavailable.gif";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,7 +189,12 @@ public class TelegramStorageProvider(IConfigService configService, ILogger<Teleg
|
|||||||
throw new ApplicationException("无效的存储路径或元数据");
|
throw new ApplicationException("无效的存储路径或元数据");
|
||||||
}
|
}
|
||||||
|
|
||||||
string botToken = configService["Storage:TelegramStorageBotToken"];
|
string botToken = _telegramConfig.BotToken;
|
||||||
|
if (string.IsNullOrEmpty(botToken))
|
||||||
|
{
|
||||||
|
logger.LogError("Telegram BotToken 未在配置中提供,无法下载文件。");
|
||||||
|
throw new InvalidOperationException("Telegram BotToken 未配置。");
|
||||||
|
}
|
||||||
|
|
||||||
using var httpClient = CreateHttpClient();
|
using var httpClient = CreateHttpClient();
|
||||||
var getFileUrl = $"https://api.telegram.org/bot{botToken}/getFile?file_id={metadata.FileId}";
|
var getFileUrl = $"https://api.telegram.org/bot{botToken}/getFile?file_id={metadata.FileId}";
|
||||||
@@ -218,10 +258,10 @@ public class TelegramStorageProvider(IConfigService configService, ILogger<Teleg
|
|||||||
HttpClient client;
|
HttpClient client;
|
||||||
|
|
||||||
// 检查是否有代理配置
|
// 检查是否有代理配置
|
||||||
string proxyAddress = configService["Storage:TelegramProxyAddress"];
|
string? proxyAddress = _telegramConfig.ProxyAddress;
|
||||||
string proxyPort = configService["Storage:TelegramProxyPort"];
|
string? proxyPort = _telegramConfig.ProxyPort;
|
||||||
string proxyUsername = configService["Storage:TelegramProxyUsername"];
|
string? proxyUsername = _telegramConfig.ProxyUsername;
|
||||||
string proxyPassword = configService["Storage:TelegramProxyPassword"];
|
string? proxyPassword = _telegramConfig.ProxyPassword;
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(proxyAddress) && !string.IsNullOrEmpty(proxyPort) && int.TryParse(proxyPort, out int port))
|
if (!string.IsNullOrEmpty(proxyAddress) && !string.IsNullOrEmpty(proxyPort) && int.TryParse(proxyPort, out int port))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,18 +2,43 @@ using System.Net.Http.Headers;
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using Foxel.Services.Attributes;
|
using Foxel.Services.Attributes;
|
||||||
using Foxel.Services.Configuration;
|
using Foxel.Services.Configuration;
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
|
|
||||||
namespace Foxel.Services.Storage.Providers;
|
namespace Foxel.Services.Storage.Providers;
|
||||||
|
|
||||||
[StorageProvider(StorageType.WebDAV)]
|
public class WebDavStorageConfig
|
||||||
public class WebDavStorageProvider(IConfigService configService, ILogger<WebDavStorageProvider> logger) : IStorageProvider
|
|
||||||
{
|
{
|
||||||
|
public string ServerUrl { get; set; } = string.Empty;
|
||||||
|
public string BasePath { get; set; } = string.Empty;
|
||||||
|
public string? UserName { get; set; }
|
||||||
|
public string? Password { get; set; }
|
||||||
|
public string? PublicUrl { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[StorageProvider(StorageType.WebDAV)]
|
||||||
|
public class WebDavStorageProvider : IStorageProvider
|
||||||
|
{
|
||||||
|
private readonly WebDavStorageConfig _webDavConfig;
|
||||||
|
private readonly IConfigService _configService;
|
||||||
|
private readonly ILogger<WebDavStorageProvider> _logger;
|
||||||
|
|
||||||
|
public WebDavStorageProvider(WebDavStorageConfig webDavConfig, IConfigService configService, ILogger<WebDavStorageProvider> logger)
|
||||||
|
{
|
||||||
|
_webDavConfig = webDavConfig;
|
||||||
|
_configService = configService;
|
||||||
|
_logger = logger;
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(_webDavConfig.ServerUrl))
|
||||||
|
{
|
||||||
|
_logger.LogError("WebDAV Storage配置不完整 (ServerUrl 是必需的).");
|
||||||
|
throw new InvalidOperationException("WebDAV Storage配置不完整。");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private HttpClient CreateClient()
|
private HttpClient CreateClient()
|
||||||
{
|
{
|
||||||
var httpClient = new HttpClient();
|
var httpClient = new HttpClient();
|
||||||
var userName = configService["Storage:WebDAVUserName"];
|
var userName = _webDavConfig.UserName;
|
||||||
var password = configService["Storage:WebDAVPassword"];
|
var password = _webDavConfig.Password;
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(userName) && !string.IsNullOrEmpty(password))
|
if (!string.IsNullOrEmpty(userName) && !string.IsNullOrEmpty(password))
|
||||||
{
|
{
|
||||||
@@ -28,8 +53,8 @@ public class WebDavStorageProvider(IConfigService configService, ILogger<WebDavS
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
string webDavServerUrl = configService["Storage:WebDAVServerUrl"].TrimEnd('/');
|
string webDavServerUrl = _webDavConfig.ServerUrl.TrimEnd('/');
|
||||||
string basePath = configService["Storage:WebDAVBasePath"].Trim('/');
|
string basePath = _webDavConfig.BasePath?.Trim('/') ?? string.Empty;
|
||||||
|
|
||||||
// 创建唯一的文件存储路径
|
// 创建唯一的文件存储路径
|
||||||
string currentDate = DateTime.Now.ToString("yyyy/MM");
|
string currentDate = DateTime.Now.ToString("yyyy/MM");
|
||||||
@@ -58,7 +83,7 @@ public class WebDavStorageProvider(IConfigService configService, ILogger<WebDavS
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "上传文件到WebDAV时出错");
|
_logger.LogError(ex, "上传文件到WebDAV时出错");
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,7 +95,7 @@ public class WebDavStorageProvider(IConfigService configService, ILogger<WebDavS
|
|||||||
if (string.IsNullOrEmpty(storagePath))
|
if (string.IsNullOrEmpty(storagePath))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
string webDavServerUrl = configService["Storage:WebDAVServerUrl"].TrimEnd('/');
|
string webDavServerUrl = _webDavConfig.ServerUrl.TrimEnd('/');
|
||||||
var requestUri = $"{webDavServerUrl}/{storagePath}";
|
var requestUri = $"{webDavServerUrl}/{storagePath}";
|
||||||
|
|
||||||
using var client = CreateClient();
|
using var client = CreateClient();
|
||||||
@@ -83,30 +108,30 @@ public class WebDavStorageProvider(IConfigService configService, ILogger<WebDavS
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogWarning(ex, "从WebDAV删除文件时出错");
|
_logger.LogWarning(ex, "从WebDAV删除文件时出错");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public string GetUrl(string storagePath)
|
public string GetUrl(int pictureId,string storagePath)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(storagePath))
|
if (string.IsNullOrEmpty(storagePath))
|
||||||
return "/images/unavailable.gif";
|
return "/images/unavailable.gif";
|
||||||
|
|
||||||
string publicUrl = configService["Storage:WebDAVPublicUrl"].TrimEnd('/');
|
string? publicUrl = _webDavConfig.PublicUrl?.TrimEnd('/');
|
||||||
string serverUrl = configService["AppSettings:ServerUrl"];
|
string serverUrl = _configService["AppSettings:ServerUrl"];
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(publicUrl))
|
if (!string.IsNullOrEmpty(publicUrl))
|
||||||
{
|
{
|
||||||
return $"{publicUrl}/{storagePath}";
|
return $"{publicUrl}/{storagePath}";
|
||||||
}
|
}
|
||||||
|
|
||||||
return $"{serverUrl}/api/picture/proxy?path={Uri.EscapeDataString(storagePath)}";
|
return $"{serverUrl}/api/picture/file/{pictureId}";
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "生成WebDAV文件URL时出错");
|
_logger.LogError(ex, "生成WebDAV文件URL时出错");
|
||||||
return "/images/unavailable.gif";
|
return "/images/unavailable.gif";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -120,7 +145,7 @@ public class WebDavStorageProvider(IConfigService configService, ILogger<WebDavS
|
|||||||
throw new ArgumentException("存储路径不能为空");
|
throw new ArgumentException("存储路径不能为空");
|
||||||
}
|
}
|
||||||
|
|
||||||
string webDavServerUrl = configService["Storage:WebDAVServerUrl"].TrimEnd('/');
|
string webDavServerUrl = _webDavConfig.ServerUrl.TrimEnd('/');
|
||||||
|
|
||||||
// 创建临时目录
|
// 创建临时目录
|
||||||
var tempDir = Path.Combine(Path.GetTempPath(), "FoxelWebDAVTemp");
|
var tempDir = Path.Combine(Path.GetTempPath(), "FoxelWebDAVTemp");
|
||||||
@@ -147,7 +172,7 @@ public class WebDavStorageProvider(IConfigService configService, ILogger<WebDavS
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "从WebDAV下载文件时出错");
|
_logger.LogError(ex, "从WebDAV下载文件时出错");
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -159,7 +184,7 @@ public class WebDavStorageProvider(IConfigService configService, ILogger<WebDavS
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
string webDavServerUrl = configService["Storage:WebDAVServerUrl"].TrimEnd('/');
|
string webDavServerUrl = _webDavConfig.ServerUrl.TrimEnd('/');
|
||||||
var requestUri = $"{webDavServerUrl}/{directoryPath}";
|
var requestUri = $"{webDavServerUrl}/{directoryPath}";
|
||||||
using var client = CreateClient();
|
using var client = CreateClient();
|
||||||
|
|
||||||
@@ -213,7 +238,7 @@ public class WebDavStorageProvider(IConfigService configService, ILogger<WebDavS
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "确保WebDAV目录存在时出错");
|
_logger.LogError(ex, "确保WebDAV目录存在时出错");
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using Foxel.Services.Attributes;
|
using Foxel.Services.Attributes;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.EntityFrameworkCore; // For IDbContextFactory
|
||||||
|
using System.Text.Json; // For JsonSerializer
|
||||||
|
using Foxel.Services.Storage.Providers; // For specific config classes
|
||||||
|
|
||||||
namespace Foxel.Services.Storage;
|
namespace Foxel.Services.Storage;
|
||||||
|
|
||||||
@@ -12,11 +14,16 @@ public class StorageService : IStorageService
|
|||||||
private readonly IServiceProvider _serviceProvider;
|
private readonly IServiceProvider _serviceProvider;
|
||||||
private readonly ILogger<StorageService> _logger;
|
private readonly ILogger<StorageService> _logger;
|
||||||
private readonly Dictionary<StorageType, Type> _storageProviders = new();
|
private readonly Dictionary<StorageType, Type> _storageProviders = new();
|
||||||
|
private readonly IDbContextFactory<MyDbContext> _contextFactory;
|
||||||
|
|
||||||
public StorageService(IServiceProvider serviceProvider, ILogger<StorageService> logger)
|
public StorageService(
|
||||||
|
IServiceProvider serviceProvider,
|
||||||
|
ILogger<StorageService> logger,
|
||||||
|
IDbContextFactory<MyDbContext> contextFactory)
|
||||||
{
|
{
|
||||||
_serviceProvider = serviceProvider;
|
_serviceProvider = serviceProvider;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_contextFactory = contextFactory;
|
||||||
RegisterStorageProviders();
|
RegisterStorageProviders();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,14 +34,14 @@ public class StorageService : IStorageService
|
|||||||
{
|
{
|
||||||
// 获取当前应用程序域中所有程序集
|
// 获取当前应用程序域中所有程序集
|
||||||
var assemblies = AppDomain.CurrentDomain.GetAssemblies();
|
var assemblies = AppDomain.CurrentDomain.GetAssemblies();
|
||||||
|
|
||||||
foreach (var assembly in assemblies)
|
foreach (var assembly in assemblies)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// 扫描每个程序集中的所有类型
|
// 扫描每个程序集中的所有类型
|
||||||
var types = assembly.GetTypes()
|
var types = assembly.GetTypes()
|
||||||
.Where(type => type is { IsClass: true, IsAbstract: false } &&
|
.Where(type => type is { IsClass: true, IsAbstract: false } &&
|
||||||
type.GetInterfaces().Contains(typeof(IStorageProvider)) &&
|
type.GetInterfaces().Contains(typeof(IStorageProvider)) &&
|
||||||
type.GetCustomAttribute<StorageProviderAttribute>() != null);
|
type.GetCustomAttribute<StorageProviderAttribute>() != null);
|
||||||
|
|
||||||
@@ -45,45 +52,123 @@ public class StorageService : IStorageService
|
|||||||
{
|
{
|
||||||
// 注册存储提供者类型与对应的存储类型
|
// 注册存储提供者类型与对应的存储类型
|
||||||
_storageProviders[attribute.StorageType] = type;
|
_storageProviders[attribute.StorageType] = type;
|
||||||
|
_logger.LogInformation("已注册存储提供者: {StorageType} -> {ProviderType}", attribute.StorageType, type.FullName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
catch (ReflectionTypeLoadException ex) // 更具体地捕获加载类型时的异常
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "扫描程序集 {AssemblyName} 时发生类型加载错误。详细信息: {LoaderExceptions}", assembly.FullName,
|
||||||
|
string.Join(", ", ex.LoaderExceptions.Select(e => e?.Message ?? "N/A")));
|
||||||
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "扫描程序集 {AssemblyName} 时发生错误", assembly.FullName);
|
_logger.LogWarning(ex, "扫描程序集 {AssemblyName} 时发生错误", assembly.FullName);
|
||||||
// 继续扫描其他程序集
|
// 继续扫描其他程序集
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!_storageProviders.Any())
|
||||||
|
{
|
||||||
|
_logger.LogWarning("未能注册任何存储提供者。请检查提供者是否正确标记了 [StorageProvider] 特性并且位于扫描的程序集中。");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 获取指定存储类型的提供者实例
|
/// 根据 StorageModeId 获取并配置提供者实例
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private IStorageProvider GetProvider(StorageType storageType)
|
private IStorageProvider GetProvider(int storageModeId)
|
||||||
{
|
{
|
||||||
if (!_storageProviders.TryGetValue(storageType, out var providerType))
|
using var context = _contextFactory.CreateDbContext();
|
||||||
|
var storageMode = context.StorageModes
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefault(sm => sm.Id == storageModeId);
|
||||||
|
|
||||||
|
if (storageMode == null)
|
||||||
{
|
{
|
||||||
throw new ArgumentException($"未找到存储类型 {storageType} 的提供者");
|
_logger.LogError("ID 为 {StorageModeId} 的 StorageMode 未找到。", storageModeId);
|
||||||
|
throw new ArgumentException($"ID 为 {storageModeId} 的 StorageMode 未找到。");
|
||||||
}
|
}
|
||||||
|
|
||||||
return (IStorageProvider)_serviceProvider.GetRequiredService(providerType);
|
if (!storageMode.IsEnabled)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("StorageMode {StorageModeId} ({StorageModeName}) 未启用。", storageModeId, storageMode.Name);
|
||||||
|
throw new InvalidOperationException($"StorageMode '{storageMode.Name}' (ID: {storageModeId}) 未启用。");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_storageProviders.TryGetValue(storageMode.StorageType, out var providerType))
|
||||||
|
{
|
||||||
|
_logger.LogError("未找到 StorageType {StorageType} (来自 StorageMode {StorageModeId}) 的已注册提供者。", storageMode.StorageType, storageModeId);
|
||||||
|
throw new ArgumentException($"未找到 StorageType {storageMode.StorageType} 的提供者。");
|
||||||
|
}
|
||||||
|
|
||||||
|
object specificConfig = DeserializeProviderConfig(storageMode.StorageType, storageMode.ConfigurationJson, storageMode.Name);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return (IStorageProvider)ActivatorUtilities.CreateInstance(_serviceProvider, providerType, specificConfig);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "为 StorageMode {StorageModeName} (ID: {StorageModeId}, Type: {StorageType}) 创建提供者 {ProviderType} 的实例失败。",
|
||||||
|
storageMode.Name, storageModeId, storageMode.StorageType, providerType.FullName);
|
||||||
|
throw new InvalidOperationException($"创建存储提供者 '{providerType.Name}' 失败: {ex.Message}", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private object DeserializeProviderConfig(StorageType storageType, string? jsonConfig, string storageModeName)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(jsonConfig))
|
||||||
|
{
|
||||||
|
_logger.LogError("StorageMode '{StorageModeName}' (Type: {StorageType}) 的 ConfigurationJson 为空或空白。", storageModeName, storageType);
|
||||||
|
throw new InvalidOperationException($"StorageMode '{storageModeName}' (Type: {storageType}) 的配置 (ConfigurationJson) 为空。");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
switch (storageType)
|
||||||
|
{
|
||||||
|
case StorageType.Local:
|
||||||
|
return JsonSerializer.Deserialize<LocalStorageConfig>(jsonConfig)
|
||||||
|
?? throw new JsonException($"无法反序列化 LocalStorageConfig。JSON: {jsonConfig}");
|
||||||
|
case StorageType.Telegram:
|
||||||
|
return JsonSerializer.Deserialize<TelegramStorageConfig>(jsonConfig)
|
||||||
|
?? throw new JsonException($"无法反序列化 TelegramStorageConfig。JSON: {jsonConfig}");
|
||||||
|
case StorageType.S3:
|
||||||
|
return JsonSerializer.Deserialize<S3StorageConfig>(jsonConfig)
|
||||||
|
?? throw new JsonException($"无法反序列化 S3StorageConfig。JSON: {jsonConfig}");
|
||||||
|
case StorageType.Cos:
|
||||||
|
return JsonSerializer.Deserialize<CosStorageConfig>(jsonConfig)
|
||||||
|
?? throw new JsonException($"无法反序列化 CosStorageConfig。JSON: {jsonConfig}");
|
||||||
|
case StorageType.WebDAV:
|
||||||
|
return JsonSerializer.Deserialize<WebDavStorageConfig>(jsonConfig)
|
||||||
|
?? throw new JsonException($"无法反序列化 WebDavStorageConfig。JSON: {jsonConfig}");
|
||||||
|
default:
|
||||||
|
_logger.LogError("不支持的存储类型配置反序列化: {StorageType} (来自 StorageMode '{StorageModeName}')", storageType, storageModeName);
|
||||||
|
throw new NotSupportedException($"不支持 StorageType {storageType} 的配置反序列化。");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (JsonException ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "反序列化 StorageMode '{StorageModeName}' (Type: {StorageType}) 的配置失败。JSON: {JsonConfig}", storageModeName, storageType, jsonConfig);
|
||||||
|
throw new InvalidOperationException($"StorageMode '{storageModeName}' (Type: {storageType}) 的配置格式无效。", ex);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 在指定存储类型上执行操作
|
/// 在指定存储模式上执行操作
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<TResult> ExecuteAsync<TResult>(StorageType storageType, Func<IStorageProvider, Task<TResult>> operation)
|
public async Task<TResult> ExecuteAsync<TResult>(int storageModeId, Func<IStorageProvider, Task<TResult>> operation)
|
||||||
{
|
{
|
||||||
var provider = GetProvider(storageType);
|
var provider = GetProvider(storageModeId);
|
||||||
return await operation(provider);
|
return await operation(provider);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 在指定存储类型上执行无返回值的操作
|
/// 在指定存储模式上执行无返回值的操作
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task ExecuteAsync(StorageType storageType, Func<IStorageProvider, Task> operation)
|
public async Task ExecuteAsync(int storageModeId, Func<IStorageProvider, Task> operation)
|
||||||
{
|
{
|
||||||
var provider = GetProvider(storageType);
|
var provider = GetProvider(storageModeId);
|
||||||
await operation(provider);
|
await operation(provider);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -33,10 +33,10 @@ public static class ImageHelper
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="originalPath">原始图片路径</param>
|
/// <param name="originalPath">原始图片路径</param>
|
||||||
/// <param name="thumbnailPath">缩略图保存路径</param>
|
/// <param name="thumbnailPath">缩略图保存路径</param>
|
||||||
/// <param name="width">缩略图宽度</param>
|
/// <param name="maxWidth">缩略图最大宽度</param>
|
||||||
/// <param name="quality">压缩质量(1-100)</param>
|
/// <param name="quality">压缩质量(1-100)</param>
|
||||||
/// <returns>生成的缩略图的文件大小(字节)</returns>
|
/// <returns>生成的缩略图的文件大小(字节)</returns>
|
||||||
public static async Task<long> CreateThumbnailAsync(string originalPath, string thumbnailPath, int width,
|
public static async Task<long> CreateThumbnailAsync(string originalPath, string thumbnailPath, int maxWidth,
|
||||||
int quality = 75)
|
int quality = 75)
|
||||||
{
|
{
|
||||||
// 获取原始文件大小
|
// 获取原始文件大小
|
||||||
@@ -45,16 +45,14 @@ public static class ImageHelper
|
|||||||
|
|
||||||
using var image = await Image.LoadAsync(originalPath);
|
using var image = await Image.LoadAsync(originalPath);
|
||||||
|
|
||||||
// 去除EXIF元数据以减小文件大小
|
|
||||||
image.Metadata.ExifProfile = null;
|
image.Metadata.ExifProfile = null;
|
||||||
|
|
||||||
image.Mutate(x => x.Resize(new ResizeOptions
|
image.Mutate(x => x.Resize(new ResizeOptions
|
||||||
{
|
{
|
||||||
Size = new Size(width, 0),
|
Size = new Size(maxWidth, 0),
|
||||||
Mode = ResizeMode.Max
|
Mode = ResizeMode.Max
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// 强制使用 WebP 格式,修改缩略图路径扩展名
|
|
||||||
string webpThumbnailPath = Path.ChangeExtension(thumbnailPath, ".webp");
|
string webpThumbnailPath = Path.ChangeExtension(thumbnailPath, ".webp");
|
||||||
|
|
||||||
int adjustedQuality = AdjustQualityByFileSize(originalSize, ".webp", quality);
|
int adjustedQuality = AdjustQualityByFileSize(originalSize, ".webp", quality);
|
||||||
@@ -386,28 +384,28 @@ public static class ImageHelper
|
|||||||
|
|
||||||
switch (targetFormat)
|
switch (targetFormat)
|
||||||
{
|
{
|
||||||
case ImageFormat.Jpeg:
|
case ImageFormat.Jpeg:
|
||||||
await convertedImage.SaveAsJpegAsync(finalOutputPath, new SixLabors.ImageSharp.Formats.Jpeg.JpegEncoder
|
await convertedImage.SaveAsJpegAsync(finalOutputPath, new SixLabors.ImageSharp.Formats.Jpeg.JpegEncoder
|
||||||
{
|
{
|
||||||
Quality = quality
|
Quality = quality
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case ImageFormat.Png:
|
case ImageFormat.Png:
|
||||||
await convertedImage.SaveAsPngAsync(finalOutputPath, new SixLabors.ImageSharp.Formats.Png.PngEncoder
|
await convertedImage.SaveAsPngAsync(finalOutputPath, new SixLabors.ImageSharp.Formats.Png.PngEncoder
|
||||||
{
|
{
|
||||||
CompressionLevel = SixLabors.ImageSharp.Formats.Png.PngCompressionLevel.BestCompression,
|
CompressionLevel = SixLabors.ImageSharp.Formats.Png.PngCompressionLevel.BestCompression,
|
||||||
ColorType = SixLabors.ImageSharp.Formats.Png.PngColorType.RgbWithAlpha
|
ColorType = SixLabors.ImageSharp.Formats.Png.PngColorType.RgbWithAlpha
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case ImageFormat.WebP:
|
case ImageFormat.WebP:
|
||||||
await convertedImage.SaveAsWebpAsync(finalOutputPath, new SixLabors.ImageSharp.Formats.Webp.WebpEncoder
|
await convertedImage.SaveAsWebpAsync(finalOutputPath, new SixLabors.ImageSharp.Formats.Webp.WebpEncoder
|
||||||
{
|
{
|
||||||
Quality = quality,
|
Quality = quality,
|
||||||
Method = SixLabors.ImageSharp.Formats.Webp.WebpEncodingMethod.BestQuality
|
Method = SixLabors.ImageSharp.Formats.Webp.WebpEncodingMethod.BestQuality
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,13 @@ export interface PaginatedResult<T> {
|
|||||||
code: number;
|
code: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 通用批量删除结果
|
||||||
|
export interface BatchDeleteResult {
|
||||||
|
successCount: number;
|
||||||
|
failedCount: number;
|
||||||
|
failedIds?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
export const BASE_URL = import.meta.env.PROD ? '/api' : 'http://localhost:5153/api';
|
export const BASE_URL = import.meta.env.PROD ? '/api' : 'http://localhost:5153/api';
|
||||||
|
|
||||||
export async function fetchApi<T = any>(
|
export async function fetchApi<T = any>(
|
||||||
|
|||||||
@@ -8,4 +8,5 @@ export * from './pictureApi';
|
|||||||
export * from './pictureManagementApi';
|
export * from './pictureManagementApi';
|
||||||
export * from './tagApi';
|
export * from './tagApi';
|
||||||
export * from './userManagementApi';
|
export * from './userManagementApi';
|
||||||
export * from './vectorDbApi';
|
export * from './vectorDbApi';
|
||||||
|
export * from './storageManagementApi';
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { fetchApi, type BaseResult, type PaginatedResult } from './fetchClient';
|
import { fetchApi, type BaseResult, type BatchDeleteResult, type PaginatedResult } from './fetchClient';
|
||||||
import { type BatchDeleteResult } from './userManagementApi';
|
|
||||||
|
|
||||||
|
|
||||||
// 日志级别枚举
|
// 日志级别枚举
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export interface PictureResponse {
|
|||||||
permission: number;
|
permission: number;
|
||||||
albumId?: number;
|
albumId?: number;
|
||||||
albumName?: string;
|
albumName?: string;
|
||||||
|
storageModeName:string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 收藏请求
|
// 收藏请求
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { fetchApi, type BaseResult, type PaginatedResult } from './fetchClient';
|
import { fetchApi, type BaseResult, type BatchDeleteResult, type PaginatedResult } from './fetchClient';
|
||||||
import { type PictureResponse } from './pictureApi';
|
import { type PictureResponse } from './pictureApi';
|
||||||
import { type BatchDeleteResult } from './userManagementApi';
|
|
||||||
|
|
||||||
|
|
||||||
// 获取图片列表
|
// 获取图片列表
|
||||||
|
|||||||
156
Web/src/api/storageManagementApi.ts
Normal file
156
Web/src/api/storageManagementApi.ts
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import { fetchApi, type BaseResult, type BatchDeleteResult, type PaginatedResult } from './fetchClient';
|
||||||
|
|
||||||
|
export enum StorageTypeEnum {
|
||||||
|
Local = 0,
|
||||||
|
Telegram = 1,
|
||||||
|
S3 = 2,
|
||||||
|
Cos = 3,
|
||||||
|
WebDAV = 4,
|
||||||
|
}
|
||||||
|
export const StorageTypeLabels: Record<StorageTypeEnum, string> = {
|
||||||
|
[StorageTypeEnum.Local]: "本地存储",
|
||||||
|
[StorageTypeEnum.Telegram]: "Telegram",
|
||||||
|
[StorageTypeEnum.S3]: "S3 对象存储",
|
||||||
|
[StorageTypeEnum.Cos]: "腾讯云 COS",
|
||||||
|
[StorageTypeEnum.WebDAV]: "WebDAV",
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface StorageModeResponse {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
storageType: StorageTypeEnum;
|
||||||
|
storageTypeName: string;
|
||||||
|
configurationJson?: string;
|
||||||
|
isEnabled: boolean;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateStorageModeRequest {
|
||||||
|
name: string;
|
||||||
|
storageType: StorageTypeEnum;
|
||||||
|
configurationJson?: string;
|
||||||
|
isEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateStorageModeRequest {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
storageType: StorageTypeEnum;
|
||||||
|
configurationJson?: string;
|
||||||
|
isEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StorageTypeResponse {
|
||||||
|
value: StorageTypeEnum;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StorageModeFilterRequest {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
searchQuery?: string;
|
||||||
|
storageType?: StorageTypeEnum;
|
||||||
|
isEnabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取存储模式列表
|
||||||
|
export const getStorageModes = async (
|
||||||
|
filters: StorageModeFilterRequest = {}
|
||||||
|
): Promise<PaginatedResult<StorageModeResponse>> => {
|
||||||
|
const { page = 1, pageSize = 10, searchQuery, storageType, isEnabled } = filters;
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page: page.toString(),
|
||||||
|
pageSize: pageSize.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (searchQuery) params.append('searchQuery', searchQuery);
|
||||||
|
if (storageType !== undefined) params.append('storageType', storageType.toString());
|
||||||
|
if (isEnabled !== undefined) params.append('isEnabled', isEnabled.toString());
|
||||||
|
|
||||||
|
const response = await fetchApi(`/management/storage/get_modes?${params.toString()}`);
|
||||||
|
return response as PaginatedResult<StorageModeResponse>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 根据ID获取单个存储模式
|
||||||
|
export const getStorageModeById = async (id: number): Promise<BaseResult<StorageModeResponse>> => {
|
||||||
|
return fetchApi<StorageModeResponse>(
|
||||||
|
`/management/storage/get_mode/${id}`,
|
||||||
|
{ method: 'GET' }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 创建存储模式
|
||||||
|
export const createStorageMode = async (
|
||||||
|
data: CreateStorageModeRequest
|
||||||
|
): Promise<BaseResult<StorageModeResponse>> => {
|
||||||
|
return fetchApi<StorageModeResponse>(
|
||||||
|
'/management/storage/create_mode',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 更新存储模式
|
||||||
|
export const updateStorageMode = async (
|
||||||
|
data: UpdateStorageModeRequest
|
||||||
|
): Promise<BaseResult<StorageModeResponse>> => {
|
||||||
|
return fetchApi<StorageModeResponse>(
|
||||||
|
'/management/storage/update_mode',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 删除存储模式
|
||||||
|
export const deleteStorageMode = async (id: number): Promise<BaseResult<boolean>> => {
|
||||||
|
return fetchApi<boolean>(
|
||||||
|
'/management/storage/delete_mode',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(id) // 后端期望body中直接是id
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 批量删除存储模式
|
||||||
|
export const batchDeleteStorageModes = async (
|
||||||
|
ids: number[]
|
||||||
|
): Promise<BaseResult<BatchDeleteResult>> => {
|
||||||
|
return fetchApi<BatchDeleteResult>(
|
||||||
|
'/management/storage/batch_delete_modes',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(ids)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取所有可用的存储类型
|
||||||
|
export const getStorageTypes = async (): Promise<BaseResult<StorageTypeResponse[]>> => {
|
||||||
|
return fetchApi<StorageTypeResponse[]>(
|
||||||
|
'/management/storage/get_storage_types',
|
||||||
|
{ method: 'GET' }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取默认存储模式ID
|
||||||
|
export const getDefaultStorageModeId = async (): Promise<BaseResult<number | null>> => {
|
||||||
|
return fetchApi<number | null>(
|
||||||
|
'/management/storage/get_default_mode_id',
|
||||||
|
{ method: 'GET' }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 设置默认存储模式
|
||||||
|
export const setDefaultStorageMode = async (id: number): Promise<BaseResult<boolean>> => {
|
||||||
|
return fetchApi<boolean>(
|
||||||
|
`/management/storage/set_default_mode/${id}`,
|
||||||
|
{ method: 'POST' }
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { fetchApi, type BaseResult, type PaginatedResult } from './fetchClient';
|
import { fetchApi, type BaseResult, type BatchDeleteResult, type PaginatedResult } from './fetchClient';
|
||||||
|
|
||||||
export type UserRole = "Administrator" | "User" | "";
|
export type UserRole = "Administrator" | "User" | "";
|
||||||
|
|
||||||
@@ -34,13 +34,6 @@ export interface AdminUpdateUserRequest {
|
|||||||
role?: string;
|
role?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 批量删除结果
|
|
||||||
export interface BatchDeleteResult {
|
|
||||||
successCount: number;
|
|
||||||
failedCount: number;
|
|
||||||
failedIds?: number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// 用户筛选请求参数
|
// 用户筛选请求参数
|
||||||
export interface UserFilterRequest {
|
export interface UserFilterRequest {
|
||||||
page?: number;
|
page?: number;
|
||||||
|
|||||||
@@ -91,10 +91,17 @@ const ImageCard: React.FC<ImageCardProps> = ({
|
|||||||
{!selectable && (
|
{!selectable && (
|
||||||
<>
|
<>
|
||||||
<div className="custom-card-indicators">
|
<div className="custom-card-indicators">
|
||||||
<div className="custom-card-permission" style={{
|
<div className="custom-card-left-indicators">
|
||||||
backgroundColor: permissionTypeMap[image.permission]?.color || 'rgba(0, 0, 0, 0.6)'
|
<div className="custom-card-permission" style={{
|
||||||
}}>
|
backgroundColor: permissionTypeMap[image.permission]?.color || 'rgba(0, 0, 0, 0.6)'
|
||||||
{permissionTypeMap[image.permission]?.icon} {permissionTypeMap[image.permission]?.label || '公开'}
|
}}>
|
||||||
|
{permissionTypeMap[image.permission]?.icon} {permissionTypeMap[image.permission]?.label || '公开'}
|
||||||
|
</div>
|
||||||
|
{image.storageModeName && (
|
||||||
|
<div className="custom-card-storage-mode">
|
||||||
|
{image.storageModeName}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="custom-card-metadata">
|
<div className="custom-card-metadata">
|
||||||
|
|||||||
@@ -121,12 +121,19 @@
|
|||||||
right: 0;
|
right: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
align-items: center; /* 确保垂直对齐 */
|
||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.35s ease;
|
transition: opacity 0.35s ease;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.custom-card-left-indicators { /* 新增样式 */
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px; /* 指示器之间的间距 */
|
||||||
|
}
|
||||||
|
|
||||||
.custom-card:hover .custom-card-indicators {
|
.custom-card:hover .custom-card-indicators {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
@@ -145,6 +152,20 @@
|
|||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.custom-card-storage-mode { /* 新增样式 */
|
||||||
|
background-color: rgba(0, 0, 0, 0.6);
|
||||||
|
color: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
.custom-card-metadata {
|
.custom-card-metadata {
|
||||||
background-color: rgba(0, 0, 0, 0.6);
|
background-color: rgba(0, 0, 0, 0.6);
|
||||||
color: white;
|
color: white;
|
||||||
|
|||||||
571
Web/src/pages/admin/storage/StorageManagement.tsx
Normal file
571
Web/src/pages/admin/storage/StorageManagement.tsx
Normal file
@@ -0,0 +1,571 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
Table, Button, Card, Input, Space, Modal, Form, message, Tag, Typography, Popconfirm, Row, Col, Select, Switch, Tooltip, Alert} from 'antd';
|
||||||
|
import {
|
||||||
|
DeleteOutlined, EditOutlined, SearchOutlined, ExclamationCircleOutlined, ReloadOutlined,
|
||||||
|
PlusOutlined, DatabaseOutlined, FilterOutlined, ClearOutlined, StarOutlined, StarFilled
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import {
|
||||||
|
getStorageModes, deleteStorageMode, createStorageMode, updateStorageMode, batchDeleteStorageModes, getStorageTypes,
|
||||||
|
getDefaultStorageModeId, setDefaultStorageMode,
|
||||||
|
type StorageModeResponse, type CreateStorageModeRequest, type UpdateStorageModeRequest, type StorageModeFilterRequest,
|
||||||
|
type StorageTypeResponse, StorageTypeEnum, StorageTypeLabels
|
||||||
|
} from '../../../api';
|
||||||
|
import { useOutletContext } from 'react-router';
|
||||||
|
import type { Breakpoint } from 'antd';
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
const { Option } = Select;
|
||||||
|
const { confirm } = Modal;
|
||||||
|
|
||||||
|
const StorageManagementPage: React.FC = () => {
|
||||||
|
const { isMobile } = useOutletContext<{ isMobile: boolean }>();
|
||||||
|
|
||||||
|
const [storageModes, setStorageModes] = useState<StorageModeResponse[]>([]);
|
||||||
|
const [availableStorageTypes, setAvailableStorageTypes] = useState<StorageTypeResponse[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(10);
|
||||||
|
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
||||||
|
const [defaultStorageModeId, setDefaultStorageModeId] = useState<number | null>(null);
|
||||||
|
const [, setIsLoadingDefault] = useState(false);
|
||||||
|
const [settingDefaultModeId, setSettingDefaultModeId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const [filters, setFilters] = useState<StorageModeFilterRequest>({});
|
||||||
|
const [showFilters, setShowFilters] = useState(false);
|
||||||
|
const [filterForm] = Form.useForm();
|
||||||
|
|
||||||
|
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||||
|
const [modalTitle, setModalTitle] = useState('');
|
||||||
|
const [editingMode, setEditingMode] = useState<StorageModeResponse | null>(null);
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [currentStorageTypeForHelp, setCurrentStorageTypeForHelp] = useState<StorageTypeEnum | null>(null);
|
||||||
|
|
||||||
|
const fetchStorageTypes = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await getStorageTypes();
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setAvailableStorageTypes(response.data);
|
||||||
|
} else {
|
||||||
|
message.error(response.message || '获取存储类型失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.error('获取存储类型失败,请检查网络');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchDefaultStorageModeId = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setIsLoadingDefault(true);
|
||||||
|
const response = await getDefaultStorageModeId();
|
||||||
|
if (response.success) {
|
||||||
|
setDefaultStorageModeId(response.data ?? null);
|
||||||
|
} else {
|
||||||
|
message.error(response.message || '获取默认存储模式失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.error('获取默认存储模式失败,请检查网络');
|
||||||
|
} finally {
|
||||||
|
setIsLoadingDefault(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchStorageModes = useCallback(async (page = currentPage, size = pageSize, filterParams = filters) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await getStorageModes({
|
||||||
|
page,
|
||||||
|
pageSize: size,
|
||||||
|
...filterParams
|
||||||
|
});
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setStorageModes(response.data.map(m => ({...m, createdAt: new Date(m.createdAt), updatedAt: new Date(m.updatedAt) })));
|
||||||
|
setTotal(response.totalCount || 0);
|
||||||
|
} else {
|
||||||
|
message.error(response.message || '获取存储模式列表失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.error('获取存储模式列表失败,请检查网络连接');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [currentPage, pageSize, filters]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchStorageTypes();
|
||||||
|
fetchStorageModes();
|
||||||
|
fetchDefaultStorageModeId();
|
||||||
|
}, [fetchStorageModes, fetchStorageTypes, fetchDefaultStorageModeId]);
|
||||||
|
|
||||||
|
const handlePageChange = (page: number, size?: number) => {
|
||||||
|
setCurrentPage(page);
|
||||||
|
if (size) setPageSize(size);
|
||||||
|
fetchStorageModes(page, size || pageSize, filters);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFilter = async () => {
|
||||||
|
const values = await filterForm.validateFields();
|
||||||
|
const newFilters: StorageModeFilterRequest = {
|
||||||
|
searchQuery: values.searchQuery,
|
||||||
|
storageType: values.storageType,
|
||||||
|
isEnabled: typeof values.isEnabled === 'boolean' ? values.isEnabled : undefined,
|
||||||
|
};
|
||||||
|
setFilters(newFilters);
|
||||||
|
setCurrentPage(1);
|
||||||
|
fetchStorageModes(1, pageSize, newFilters);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearFilters = () => {
|
||||||
|
filterForm.resetFields();
|
||||||
|
setFilters({});
|
||||||
|
setCurrentPage(1);
|
||||||
|
fetchStorageModes(1, pageSize, {});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleQuickSearch = (searchQuery: string) => {
|
||||||
|
const newFilters = { ...filters, searchQuery };
|
||||||
|
setFilters(newFilters);
|
||||||
|
setCurrentPage(1);
|
||||||
|
fetchStorageModes(1, pageSize, newFilters);
|
||||||
|
};
|
||||||
|
|
||||||
|
const showCreateModal = () => {
|
||||||
|
setModalTitle('创建新存储模式');
|
||||||
|
setEditingMode(null);
|
||||||
|
setCurrentStorageTypeForHelp(null);
|
||||||
|
form.resetFields(); // This will clear all fields, including any 'configuration' fields
|
||||||
|
form.setFieldsValue({ isEnabled: true });
|
||||||
|
setIsModalVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const showEditModal = (mode: StorageModeResponse) => {
|
||||||
|
setModalTitle('编辑存储模式');
|
||||||
|
setEditingMode(mode);
|
||||||
|
setCurrentStorageTypeForHelp(mode.storageType);
|
||||||
|
|
||||||
|
let parsedConfig = {};
|
||||||
|
if (mode.configurationJson) {
|
||||||
|
try {
|
||||||
|
parsedConfig = JSON.parse(mode.configurationJson);
|
||||||
|
} catch (e) {
|
||||||
|
message.error('解析现有配置JSON失败,请检查数据格式。');
|
||||||
|
parsedConfig = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
form.setFieldsValue({
|
||||||
|
name: mode.name,
|
||||||
|
storageType: mode.storageType,
|
||||||
|
isEnabled: mode.isEnabled,
|
||||||
|
configuration: parsedConfig,
|
||||||
|
});
|
||||||
|
setIsModalVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleModalOk = async () => {
|
||||||
|
try {
|
||||||
|
const values = await form.validateFields();
|
||||||
|
const { name, storageType, isEnabled, configuration } = values;
|
||||||
|
const configToSave = configuration || {};
|
||||||
|
const commonData = {
|
||||||
|
name,
|
||||||
|
storageType,
|
||||||
|
configurationJson: JSON.stringify(configToSave),
|
||||||
|
isEnabled,
|
||||||
|
};
|
||||||
|
|
||||||
|
let response;
|
||||||
|
if (editingMode) {
|
||||||
|
const updateData: UpdateStorageModeRequest = { id: editingMode.id, ...commonData };
|
||||||
|
response = await updateStorageMode(updateData);
|
||||||
|
} else {
|
||||||
|
const createData: CreateStorageModeRequest = commonData;
|
||||||
|
response = await createStorageMode(createData);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
message.success(editingMode ? '存储模式更新成功' : '存储模式创建成功');
|
||||||
|
fetchStorageModes(editingMode ? currentPage : 1);
|
||||||
|
setIsModalVisible(false);
|
||||||
|
} else {
|
||||||
|
message.error(response.message || (editingMode ? '更新失败' : '创建失败'));
|
||||||
|
}
|
||||||
|
} catch (errorInfo) {
|
||||||
|
console.error('Form validation failed:', errorInfo);
|
||||||
|
message.error('请检查表单输入。');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: number) => {
|
||||||
|
try {
|
||||||
|
const response = await deleteStorageMode(id);
|
||||||
|
if (response.success) {
|
||||||
|
message.success('存储模式删除成功');
|
||||||
|
fetchStorageModes(); // Refresh
|
||||||
|
} else {
|
||||||
|
message.error(response.message || '删除失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.error('删除失败,请检查网络');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBatchDelete = async () => {
|
||||||
|
if (selectedRowKeys.length === 0) {
|
||||||
|
message.warning('请选择要删除的存储模式');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
confirm({
|
||||||
|
title: `确定要删除 ${selectedRowKeys.length} 个存储模式吗?`,
|
||||||
|
icon: <ExclamationCircleOutlined />,
|
||||||
|
content: '此操作不可逆。如果存储模式仍被图片使用,则无法删除。',
|
||||||
|
okText: '确认',
|
||||||
|
okType: 'danger',
|
||||||
|
cancelText: '取消',
|
||||||
|
async onOk() {
|
||||||
|
try {
|
||||||
|
const response = await batchDeleteStorageModes(selectedRowKeys as number[]);
|
||||||
|
if (response.success && response.data) {
|
||||||
|
message.success(`成功删除 ${response.data.successCount} 个存储模式`);
|
||||||
|
if (response.data.failedCount > 0) {
|
||||||
|
message.warning(`${response.data.failedCount} 个存储模式删除失败 (可能仍在使用中)。失败ID: ${response.data.failedIds?.join(', ')}`);
|
||||||
|
}
|
||||||
|
setSelectedRowKeys([]);
|
||||||
|
fetchStorageModes(); // Refresh
|
||||||
|
} else {
|
||||||
|
message.error(response.message || '批量删除失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.error('批量删除失败,请检查网络');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSetDefault = async (id: number) => {
|
||||||
|
try {
|
||||||
|
setSettingDefaultModeId(id); // 开始为此特定项目设置默认
|
||||||
|
const response = await setDefaultStorageMode(id);
|
||||||
|
if (response.success) {
|
||||||
|
message.success('默认存储模式设置成功');
|
||||||
|
setDefaultStorageModeId(id);
|
||||||
|
// 可选: 如果默认状态会影响列表显示方式(除了星星图标),则重新获取列表
|
||||||
|
// fetchStorageModes(currentPage, pageSize, filters);
|
||||||
|
} else {
|
||||||
|
message.error(response.message || '设置默认存储模式失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.error('设置默认存储模式失败,请检查网络');
|
||||||
|
} finally {
|
||||||
|
setSettingDefaultModeId(null); // 清除此特定项目的加载状态
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderDynamicConfigFields = (storageType: StorageTypeEnum | null) => {
|
||||||
|
if (storageType === null || storageType === undefined) return null;
|
||||||
|
switch (storageType) {
|
||||||
|
case StorageTypeEnum.Local:
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Form.Item name={['configuration', 'BasePath']} label="基础路径 (BasePath)" rules={[{ required: true, message: '请输入基础路径' }]}>
|
||||||
|
<Input placeholder="例如: /path/to/your/uploads" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name={['configuration', 'ServerUrl']} label="服务器URL (ServerUrl)" rules={[{ required: true, message: '请输入服务器URL' }]}>
|
||||||
|
<Input placeholder="例如: http://localhost:5000" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name={['configuration', 'PublicBasePath']} label="公共基础路径 (PublicBasePath)" rules={[{ required: true, message: '请输入公共基础路径'}]}>
|
||||||
|
<Input placeholder="例如: /Uploads (用于拼接图片URL)" />
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case StorageTypeEnum.Telegram:
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Form.Item name={['configuration', 'BotToken']} label="机器人Token (BotToken)" rules={[{ required: true, message: '请输入机器人Token' }]}>
|
||||||
|
<Input.Password placeholder="您的Telegram机器人Token" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name={['configuration', 'ChatId']} label="聊天ID (ChatId)" rules={[{ required: true, message: '请输入聊天ID' }]}>
|
||||||
|
<Input placeholder="目标聊天或频道的ID" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name={['configuration', 'ProxyAddress']} label="代理地址 (ProxyAddress) (可选)">
|
||||||
|
<Input placeholder="例如: 127.0.0.1" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name={['configuration', 'ProxyPort']} label="代理端口 (ProxyPort) (可选)">
|
||||||
|
<Input placeholder="例如: 1080" type="number" />
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case StorageTypeEnum.S3:
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Form.Item name={['configuration', 'AccessKey']} label="AccessKey" rules={[{ required: true, message: '请输入AccessKey' }]}>
|
||||||
|
<Input placeholder="您的S3 AccessKey" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name={['configuration', 'SecretKey']} label="SecretKey" rules={[{ required: true, message: '请输入SecretKey' }]}>
|
||||||
|
<Input.Password placeholder="您的S3 SecretKey" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name={['configuration', 'Endpoint']} label="Endpoint" rules={[{ required: true, message: '请输入Endpoint' }]}>
|
||||||
|
<Input placeholder="例如: s3.us-west-2.amazonaws.com" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name={['configuration', 'Region']} label="区域 (Region)" rules={[{ required: true, message: '请输入区域' }]}>
|
||||||
|
<Input placeholder="例如: us-west-2" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name={['configuration', 'BucketName']} label="存储桶名称 (BucketName)" rules={[{ required: true, message: '请输入存储桶名称' }]}>
|
||||||
|
<Input placeholder="您的S3存储桶名称" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name={['configuration', 'UsePathStyleUrls']} label="使用路径样式URL (UsePathStyleUrls)" valuePropName="checked">
|
||||||
|
<Switch checkedChildren="是" unCheckedChildren="否" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name={['configuration', 'CdnUrl']} label="CDN URL (可选)">
|
||||||
|
<Input placeholder="例如: https://cdn.example.com" />
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case StorageTypeEnum.Cos:
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Form.Item name={['configuration', 'Region']} label="区域 (Region)" rules={[{ required: true, message: '请输入区域' }]}>
|
||||||
|
<Input placeholder="例如: ap-guangzhou" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name={['configuration', 'SecretId']} label="SecretId" rules={[{ required: true, message: '请输入SecretId' }]}>
|
||||||
|
<Input placeholder="您的腾讯云SecretId" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name={['configuration', 'SecretKey']} label="SecretKey" rules={[{ required: true, message: '请输入SecretKey' }]}>
|
||||||
|
<Input.Password placeholder="您的腾讯云SecretKey" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name={['configuration', 'BucketName']} label="存储桶名称 (BucketName)" rules={[{ required: true, message: '请输入存储桶名称' }]}>
|
||||||
|
<Input placeholder="格式: your-bucket-appid" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name={['configuration', 'CdnUrl']} label="CDN URL (可选)">
|
||||||
|
<Input placeholder="例如: https://cdn.example.com" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name={['configuration', 'PublicRead']} label="公共读 (PublicRead)" valuePropName="checked">
|
||||||
|
<Switch checkedChildren="是" unCheckedChildren="否" />
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case StorageTypeEnum.WebDAV:
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Form.Item name={['configuration', 'ServerUrl']} label="服务器URL (ServerUrl)" rules={[{ required: true, message: '请输入WebDAV服务器URL' }]}>
|
||||||
|
<Input placeholder="例如: https://dav.example.com" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name={['configuration', 'BasePath']} label="基础路径 (BasePath)">
|
||||||
|
<Input placeholder="例如: uploads (在服务器上的相对路径)" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name={['configuration', 'UserName']} label="用户名 (UserName)" rules={[{ required: true, message: '请输入用户名' }]}>
|
||||||
|
<Input placeholder="WebDAV用户名" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name={['configuration', 'Password']} label="密码 (Password)" rules={[{ required: true, message: '请输入密码' }]}>
|
||||||
|
<Input.Password placeholder="WebDAV密码" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name={['configuration', 'PublicUrl']} label="公共URL (PublicUrl) (可选)">
|
||||||
|
<Input placeholder="例如: https://public.example.com/dav (如果WebDAV内容可通过不同URL公开访问)" />
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return <Alert message="此存储类型可能不需要额外配置,或配置界面暂未实现。" type="info" showIcon />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ title: 'ID', dataIndex: 'id', key: 'id', responsive: ['md'] as Breakpoint[] },
|
||||||
|
{
|
||||||
|
title: '名称',
|
||||||
|
dataIndex: 'name',
|
||||||
|
key: 'name',
|
||||||
|
render: (name: string, record: StorageModeResponse) => (
|
||||||
|
<Space>
|
||||||
|
{record.id === defaultStorageModeId && (
|
||||||
|
<Tooltip title="默认存储模式">
|
||||||
|
<StarFilled style={{ color: '#faad14' }} />
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{name}
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '类型', dataIndex: 'storageType', key: 'storageType',
|
||||||
|
render: (type: StorageTypeEnum) => <Tag color="blue">{StorageTypeLabels[type] || type}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '配置 (JSON)', dataIndex: 'configurationJson', key: 'configurationJson', responsive: ['lg'] as Breakpoint[],
|
||||||
|
render: (json?: string) => json ? <Tooltip title={json}><Text style={{ maxWidth: 200 }} ellipsis>{json}</Text></Tooltip> : <Text type="secondary">无</Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '启用状态', dataIndex: 'isEnabled', key: 'isEnabled',
|
||||||
|
render: (enabled: boolean) => <Tag color={enabled ? 'green' : 'red'}>{enabled ? '已启用' : '已禁用'}</Tag>,
|
||||||
|
},
|
||||||
|
{ title: '更新时间', dataIndex: 'updatedAt', key: 'updatedAt', responsive: ['lg'] as Breakpoint[], render: (date: Date) => date.toLocaleString() },
|
||||||
|
{
|
||||||
|
title: '操作', key: 'action',
|
||||||
|
render: (_: any, record: StorageModeResponse) => (
|
||||||
|
<Space size="small">
|
||||||
|
{record.isEnabled && record.id !== defaultStorageModeId && (
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<StarOutlined />}
|
||||||
|
onClick={() => handleSetDefault(record.id)}
|
||||||
|
loading={settingDefaultModeId === record.id} // 使用新的行特定加载状态
|
||||||
|
title="设为默认"
|
||||||
|
>
|
||||||
|
{isMobile ? '' : '设为默认'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button type="text" icon={<EditOutlined />} onClick={() => showEditModal(record)}>{isMobile ? '' : '编辑'}</Button>
|
||||||
|
<Popconfirm
|
||||||
|
title="确定删除此存储模式吗?"
|
||||||
|
onConfirm={() => handleDelete(record.id)}
|
||||||
|
okText="确定"
|
||||||
|
cancelText="取消"
|
||||||
|
disabled={record.id === defaultStorageModeId}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
danger
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
disabled={record.id === defaultStorageModeId}
|
||||||
|
>
|
||||||
|
{isMobile ? '' : '删除'}
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Row gutter={[16, 16]} align="middle" justify="space-between">
|
||||||
|
<Col>
|
||||||
|
<Space align="center">
|
||||||
|
<DatabaseOutlined style={{ fontSize: 24 }} />
|
||||||
|
<Title level={2} style={{ margin: 0 }}>存储模式管理</Title>
|
||||||
|
</Space>
|
||||||
|
<Text type="secondary" style={{ marginTop: 8, display: 'block' }}>
|
||||||
|
管理系统中的各种文件存储方式及其配置
|
||||||
|
</Text>
|
||||||
|
</Col>
|
||||||
|
<Col>
|
||||||
|
{defaultStorageModeId && (
|
||||||
|
<Alert
|
||||||
|
type="info"
|
||||||
|
message={
|
||||||
|
<Space>
|
||||||
|
<StarFilled style={{ color: '#faad14' }} />
|
||||||
|
<span>当前默认存储模式ID: {defaultStorageModeId}</span>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
style={{ padding: '4px 12px' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!defaultStorageModeId && (
|
||||||
|
<Alert
|
||||||
|
type="warning"
|
||||||
|
message="未设置默认存储模式"
|
||||||
|
description="请选择一个启用的存储模式设为默认"
|
||||||
|
showIcon
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Card style={{ marginTop: 16 }}>
|
||||||
|
<Row gutter={[16, 16]} justify="space-between" style={{ marginBottom: 16 }}>
|
||||||
|
<Col xs={24} sm={14} md={16}>
|
||||||
|
<Space wrap>
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} onClick={showCreateModal}>创建模式</Button>
|
||||||
|
<Button danger icon={<DeleteOutlined />} onClick={handleBatchDelete} disabled={selectedRowKeys.length === 0}>批量删除</Button>
|
||||||
|
<Button icon={<ReloadOutlined />} onClick={() => fetchStorageModes(currentPage, pageSize, filters)}>刷新</Button>
|
||||||
|
<Button icon={<FilterOutlined />} onClick={() => setShowFilters(!showFilters)} type={showFilters ? 'primary' : 'default'}>高级筛选</Button>
|
||||||
|
</Space>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={10} md={8}>
|
||||||
|
<Input.Search placeholder="搜索模式名称" allowClear enterButton={<SearchOutlined />} onSearch={handleQuickSearch} />
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{showFilters && (
|
||||||
|
<Card size="small" style={{ marginBottom: 16, backgroundColor: '#fafafa' }}>
|
||||||
|
<Form form={filterForm} layout="inline" onFinish={handleFilter}>
|
||||||
|
<Form.Item name="searchQuery" label="名称">
|
||||||
|
<Input placeholder="模式名称" style={{ width: 150 }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="storageType" label="类型">
|
||||||
|
<Select placeholder="选择类型" style={{ width: 150 }} allowClear>
|
||||||
|
{availableStorageTypes.map(st => <Option key={st.value} value={st.value}>{st.name}</Option>)}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="isEnabled" label="状态">
|
||||||
|
<Select placeholder="选择状态" style={{ width: 120 }} allowClear>
|
||||||
|
<Option value={true}>已启用</Option>
|
||||||
|
<Option value={false}>已禁用</Option>
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Space>
|
||||||
|
<Button type="primary" htmlType="submit" icon={<SearchOutlined />}>筛选</Button>
|
||||||
|
<Button icon={<ClearOutlined />} onClick={handleClearFilters}>清除</Button>
|
||||||
|
</Space>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={storageModes}
|
||||||
|
loading={loading}
|
||||||
|
pagination={{
|
||||||
|
current: currentPage, pageSize: pageSize, total: total,
|
||||||
|
showSizeChanger: true, showQuickJumper: true, onChange: handlePageChange,
|
||||||
|
showTotal: (t) => `共 ${t} 条记录`,
|
||||||
|
}}
|
||||||
|
rowSelection={{ selectedRowKeys, onChange: (keys) => setSelectedRowKeys(keys) }}
|
||||||
|
size={isMobile ? "small" : "middle"}
|
||||||
|
scroll={{ x: 'max-content' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={modalTitle}
|
||||||
|
open={isModalVisible}
|
||||||
|
onOk={handleModalOk}
|
||||||
|
onCancel={() => setIsModalVisible(false)}
|
||||||
|
okText={editingMode ? "更新" : "创建"}
|
||||||
|
cancelText="取消"
|
||||||
|
width={isMobile ? '90%' : 700}
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical" initialValues={{ isEnabled: true, configuration: {} }}>
|
||||||
|
<Form.Item name="name" label="模式名称" rules={[{ required: true, message: '请输入模式名称' }]}>
|
||||||
|
<Input placeholder="例如:主图片存储、备份存储等" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="storageType" label="存储类型" rules={[{ required: true, message: '请选择存储类型' }]}>
|
||||||
|
<Select
|
||||||
|
placeholder="选择一个存储类型"
|
||||||
|
onChange={(value) => {
|
||||||
|
setCurrentStorageTypeForHelp(value as StorageTypeEnum);
|
||||||
|
form.setFieldsValue({ configuration: {} });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{availableStorageTypes.map(st => <Option key={st.value} value={st.value}>{StorageTypeLabels[st.value as StorageTypeEnum] || st.name}</Option>)}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
|
||||||
|
{renderDynamicConfigFields(currentStorageTypeForHelp)}
|
||||||
|
|
||||||
|
<Form.Item name="isEnabled" label="启用状态" valuePropName="checked">
|
||||||
|
<Switch checkedChildren="已启用" unCheckedChildren="已禁用" />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StorageManagementPage;
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Tabs, Form, Input, Button, Select, Space, Divider, Typography } from 'antd';
|
import { Tabs, Form, Input, Button, Space, Divider, Slider, InputNumber } from 'antd'; // InputNumber added
|
||||||
import {
|
import {
|
||||||
ApiOutlined, RocketOutlined, PictureOutlined, SaveOutlined,
|
ApiOutlined, RocketOutlined, PictureOutlined, SaveOutlined,
|
||||||
SafetyCertificateOutlined, LockOutlined, GlobalOutlined, SettingOutlined,
|
SafetyCertificateOutlined, LockOutlined, GlobalOutlined, SettingOutlined,
|
||||||
CloudServerOutlined, DatabaseOutlined, UploadOutlined} from '@ant-design/icons';
|
DatabaseOutlined, UploadOutlined
|
||||||
|
} from '@ant-design/icons';
|
||||||
import ConfigFormItem from './ConfigFormItem';
|
import ConfigFormItem from './ConfigFormItem';
|
||||||
import ConfigSection from './ConfigSection';
|
import ConfigSection from './ConfigSection';
|
||||||
import VectorDbConfig from './VectorDbConfig';
|
import VectorDbConfig from './VectorDbConfig';
|
||||||
|
|
||||||
const { TabPane } = Tabs;
|
const { TabPane } = Tabs;
|
||||||
const { Option } = Select;
|
// const { Option } = Select; // Removed
|
||||||
const { Title, Paragraph } = Typography;
|
|
||||||
|
|
||||||
interface ConfigStructure {
|
interface ConfigStructure {
|
||||||
[key: string]: {
|
[key: string]: {
|
||||||
@@ -24,17 +24,13 @@ interface ConfigTabsProps {
|
|||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
activeKey: string;
|
activeKey: string;
|
||||||
onTabChange: (key: string) => void;
|
onTabChange: (key: string) => void;
|
||||||
storageType: string;
|
|
||||||
onStorageTypeChange: (type: string) => void;
|
|
||||||
formsMap: Record<string, any>;
|
formsMap: Record<string, any>;
|
||||||
allDescriptions: Record<string, Record<string, string>>;
|
allDescriptions: Record<string, Record<string, string>>;
|
||||||
onSaveSingleConfig: (formInstance: any, groupName: string, key: string) => Promise<void>;
|
onSaveSingleConfig: (formInstance: any, groupName: string, key: string) => Promise<void>;
|
||||||
onSaveAllForGroup: (formInstance: any, groupName: string, itemKeys: string[]) => Promise<void>;
|
onSaveAllForGroup: (formInstance: any, groupName: string, itemKeys: string[]) => Promise<void>;
|
||||||
onBaseSaveConfig: (group: string, key: string, value: string) => Promise<boolean>;
|
onBaseSaveConfig: (group: string, key: string, value: string) => Promise<boolean>;
|
||||||
setConfigs: React.Dispatch<React.SetStateAction<ConfigStructure>>;
|
setConfigs: React.Dispatch<React.SetStateAction<ConfigStructure>>;
|
||||||
storageOptions: Array<{ value: string; label: string; icon: React.ReactNode; }>;
|
// imageQualityOptions: Array<{ value: string; label: string; description: string; }>; // Removed
|
||||||
imageFormatOptions: Array<{ value: string; label: string; description: string; }>;
|
|
||||||
imageQualityOptions: Array<{ value: string; label: string; description: string; }>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ConfigTabs: React.FC<ConfigTabsProps> = ({
|
const ConfigTabs: React.FC<ConfigTabsProps> = ({
|
||||||
@@ -43,17 +39,13 @@ const ConfigTabs: React.FC<ConfigTabsProps> = ({
|
|||||||
isMobile,
|
isMobile,
|
||||||
activeKey,
|
activeKey,
|
||||||
onTabChange,
|
onTabChange,
|
||||||
storageType,
|
|
||||||
onStorageTypeChange,
|
|
||||||
formsMap,
|
formsMap,
|
||||||
allDescriptions,
|
allDescriptions,
|
||||||
onSaveSingleConfig,
|
onSaveSingleConfig,
|
||||||
onSaveAllForGroup,
|
onSaveAllForGroup,
|
||||||
onBaseSaveConfig,
|
onBaseSaveConfig,
|
||||||
setConfigs,
|
setConfigs,
|
||||||
storageOptions,
|
// imageQualityOptions, // Removed
|
||||||
imageFormatOptions,
|
|
||||||
imageQualityOptions,
|
|
||||||
}) => {
|
}) => {
|
||||||
|
|
||||||
const renderConfigFormItems = (formInstance: any, groupName: string, itemKeys: string[]) => {
|
const renderConfigFormItems = (formInstance: any, groupName: string, itemKeys: string[]) => {
|
||||||
@@ -306,245 +298,93 @@ const ConfigTabs: React.FC<ConfigTabsProps> = ({
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'Storage',
|
key: 'Upload',
|
||||||
label: '存储设置',
|
label: '上传设置',
|
||||||
icon: <CloudServerOutlined />,
|
icon: <UploadOutlined />,
|
||||||
children: (
|
children: (
|
||||||
<>
|
<>
|
||||||
<ConfigSection
|
<ConfigSection
|
||||||
title="存储类型配置"
|
title="上传参数配置"
|
||||||
icon={<DatabaseOutlined />}
|
|
||||||
description="配置系统默认使用的文件存储方式"
|
|
||||||
isMobile={isMobile}
|
|
||||||
>
|
|
||||||
<div style={{
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: isMobile ? '1fr' : 'repeat(auto-fit, minmax(300px, 1fr))',
|
|
||||||
gap: isMobile ? 12 : 16,
|
|
||||||
marginBottom: 0
|
|
||||||
}}>
|
|
||||||
<div>
|
|
||||||
<div style={{ marginBottom: 8, fontSize: 14, fontWeight: 500, color: '#666' }}>
|
|
||||||
登录用户默认存储
|
|
||||||
</div>
|
|
||||||
<Select
|
|
||||||
value={configs.Storage?.DefaultStorage || 'Local'}
|
|
||||||
onChange={(value) => onBaseSaveConfig('Storage', 'DefaultStorage', value)}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
size="large"
|
|
||||||
placeholder="选择登录用户的默认存储方式"
|
|
||||||
optionLabelProp="label"
|
|
||||||
>
|
|
||||||
{storageOptions.map(option => (
|
|
||||||
<Option key={option.value} value={option.value} label={option.label}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
|
||||||
{option.icon}
|
|
||||||
<span style={{ marginLeft: 8 }}>{option.label}</span>
|
|
||||||
</div>
|
|
||||||
</Option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
<div style={{ fontSize: 12, color: '#999', marginTop: 4 }}>
|
|
||||||
{allDescriptions.Storage?.DefaultStorage}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div style={{ marginBottom: 8, fontSize: 14, fontWeight: 500, color: '#666' }}>
|
|
||||||
匿名用户默认存储
|
|
||||||
</div>
|
|
||||||
<Select
|
|
||||||
value={configs.Storage?.AnonymousDefaultStorage || 'Local'}
|
|
||||||
onChange={(value) => onBaseSaveConfig('Storage', 'AnonymousDefaultStorage', value)}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
size="large"
|
|
||||||
placeholder="选择匿名用户的默认存储方式"
|
|
||||||
optionLabelProp="label"
|
|
||||||
>
|
|
||||||
{storageOptions.map(option => (
|
|
||||||
<Option key={option.value} value={option.value} label={option.label}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
|
||||||
{option.icon}
|
|
||||||
<span style={{ marginLeft: 8 }}>{option.label}</span>
|
|
||||||
</div>
|
|
||||||
</Option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
<div style={{ fontSize: 12, color: '#999', marginTop: 4 }}>
|
|
||||||
{allDescriptions.Storage?.AnonymousDefaultStorage}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ConfigSection>
|
|
||||||
|
|
||||||
<ConfigSection
|
|
||||||
title="上传设置配置"
|
|
||||||
icon={<UploadOutlined />}
|
icon={<UploadOutlined />}
|
||||||
description="配置文件上传处理方式和图片转换参数"
|
description="配置文件上传处理方式和图片转换参数"
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
>
|
>
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateColumns: isMobile ? '1fr' : 'repeat(auto-fit, minmax(300px, 1fr))',
|
gridTemplateColumns: '1fr',
|
||||||
gap: isMobile ? 12 : 16,
|
gap: isMobile ? 20 : 24,
|
||||||
marginBottom: 0
|
marginBottom: 0
|
||||||
}}>
|
}}>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ marginBottom: 8, fontSize: 14, fontWeight: 500, color: '#666' }}>
|
<div style={{ marginBottom: 8, fontSize: 14, fontWeight: 500, color: '#666' }}>
|
||||||
默认图片格式
|
缩略图最大宽度 (px)
|
||||||
</div>
|
</div>
|
||||||
<Select
|
<InputNumber
|
||||||
value={configs.Upload?.DefaultImageFormat || 'Original'}
|
min={100}
|
||||||
onChange={(value) => onBaseSaveConfig('Upload', 'DefaultImageFormat', value)}
|
max={1000}
|
||||||
|
step={50}
|
||||||
|
value={parseInt(configs.Upload?.ThumbnailMaxWidth || '400', 10)}
|
||||||
|
onChange={(value) => {
|
||||||
|
if (value !== null) {
|
||||||
|
onBaseSaveConfig('Upload', 'ThumbnailMaxWidth', value.toString())
|
||||||
|
}
|
||||||
|
}}
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
size="large"
|
/>
|
||||||
placeholder="选择上传图片的默认处理格式"
|
|
||||||
optionLabelProp="label"
|
|
||||||
>
|
|
||||||
{imageFormatOptions.map(option => (
|
|
||||||
<Option key={option.value} value={option.value} label={option.label}>
|
|
||||||
<div>
|
|
||||||
<div>{option.label}</div>
|
|
||||||
<div style={{ fontSize: '12px', color: '#999' }}>{option.description}</div>
|
|
||||||
</div>
|
|
||||||
</Option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
<div style={{ fontSize: 12, color: '#999', marginTop: 4 }}>
|
<div style={{ fontSize: 12, color: '#999', marginTop: 4 }}>
|
||||||
{allDescriptions.Upload?.DefaultImageFormat}
|
{allDescriptions.Upload?.ThumbnailMaxWidth}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div style={{ marginBottom: 8, fontSize: 14, fontWeight: 500, color: '#666' }}>
|
<div style={{ marginBottom: 8, fontSize: 14, fontWeight: 500, color: '#666' }}>
|
||||||
默认压缩质量
|
缩略图压缩质量
|
||||||
</div>
|
</div>
|
||||||
<Select
|
<Slider
|
||||||
value={configs.Upload?.DefaultImageQuality || '95'}
|
min={30}
|
||||||
onChange={(value) => onBaseSaveConfig('Upload', 'DefaultImageQuality', value)}
|
max={90}
|
||||||
style={{ width: '100%' }}
|
step={5}
|
||||||
size="large"
|
value={parseInt(configs.Upload?.ThumbnailCompressionQuality || '75', 10)}
|
||||||
placeholder="选择图片压缩质量"
|
onChange={(value) => onBaseSaveConfig('Upload', 'ThumbnailCompressionQuality', value.toString())}
|
||||||
optionLabelProp="label"
|
style={{ margin: isMobile ? '0 5px' : '0 10px' }}
|
||||||
>
|
tooltip={{
|
||||||
{imageQualityOptions.map(option => (
|
formatter: value => `${value}%`
|
||||||
<Option key={option.value} value={option.value} label={option.label}>
|
}}
|
||||||
<div>
|
marks={{
|
||||||
<div>{option.label}</div>
|
30: '30%',
|
||||||
<div style={{ fontSize: '12px', color: '#999' }}>{option.description}</div>
|
60: '60%',
|
||||||
</div>
|
90: '90%'
|
||||||
</Option>
|
}}
|
||||||
))}
|
/>
|
||||||
</Select>
|
<div style={{ fontSize: 12, color: '#999', marginTop: 16, textAlign: 'center' }}>
|
||||||
<div style={{ fontSize: 12, color: '#999', marginTop: 4 }}>
|
{allDescriptions.Upload?.ThumbnailCompressionQuality}
|
||||||
{allDescriptions.Upload?.DefaultImageQuality}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</ConfigSection>
|
|
||||||
|
|
||||||
<ConfigSection
|
<div>
|
||||||
title="存储服务配置"
|
<div style={{ marginBottom: 8, fontSize: 14, fontWeight: 500, color: '#666' }}>
|
||||||
icon={<CloudServerOutlined />}
|
高清图片压缩质量
|
||||||
description="配置各种外部存储服务的连接参数"
|
|
||||||
isMobile={isMobile}
|
|
||||||
>
|
|
||||||
<div style={{ marginBottom: 16 }}>
|
|
||||||
<div style={{ marginBottom: 8, fontSize: 14, fontWeight: 500, color: '#666' }}>
|
|
||||||
选择要配置的存储服务
|
|
||||||
</div>
|
|
||||||
<Select
|
|
||||||
value={storageType}
|
|
||||||
onChange={onStorageTypeChange}
|
|
||||||
style={{ width: isMobile ? '100%' : '300px' }}
|
|
||||||
size="large"
|
|
||||||
placeholder="选择需要配置的存储服务类型"
|
|
||||||
optionLabelProp="label"
|
|
||||||
>
|
|
||||||
{storageOptions.map(option => (
|
|
||||||
<Option key={option.value} value={option.value} label={option.label}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
|
||||||
{option.icon}
|
|
||||||
<span style={{ marginLeft: 8 }}>{option.label}</span>
|
|
||||||
</div>
|
|
||||||
</Option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
<div style={{ fontSize: 12, color: '#999', marginTop: 4, marginBottom: 16 }}>
|
|
||||||
选择后将显示对应存储服务的详细配置选项
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ border: '1px solid #f0f0f0', borderRadius: 6, padding: isMobile ? 12 : 16, backgroundColor: '#fafafa' }}>
|
|
||||||
{storageType === 'Local' && (
|
|
||||||
<div style={{ textAlign: 'center', color: '#999', padding: '30px 0' }}>
|
|
||||||
<DatabaseOutlined style={{ fontSize: 32, color: '#52c41a', marginBottom: 16 }} />
|
|
||||||
<Title level={5}>本地存储无需额外配置</Title>
|
|
||||||
<Paragraph type="secondary">文件将直接存储在服务器的本地文件系统中</Paragraph>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<Slider
|
||||||
{storageType === 'Telegram' && (
|
min={50}
|
||||||
<Form form={formsMap.TelegramStorage} layout="vertical" size={isMobile ? "middle" : "large"}>
|
max={100}
|
||||||
{renderConfigFormItems(formsMap.TelegramStorage, "Storage", ["TelegramStorageBotToken", "TelegramStorageChatId", "TelegramProxyAddress", "TelegramProxyPort", "TelegramProxyUsername", "TelegramProxyPassword"])}
|
step={5}
|
||||||
<Divider style={{ margin: '12px 0 20px' }} />
|
value={parseInt(configs.Upload?.HighQualityImageCompressionQuality || '95', 10)}
|
||||||
<Form.Item style={{ marginBottom: 0, textAlign: 'center' }}>
|
onChange={(value) => onBaseSaveConfig('Upload', 'HighQualityImageCompressionQuality', value.toString())}
|
||||||
<Button
|
style={{ margin: isMobile ? '0 5px' : '0 10px' }}
|
||||||
type="primary"
|
tooltip={{
|
||||||
icon={<SaveOutlined />}
|
formatter: value => `${value}%`
|
||||||
onClick={() => onSaveAllForGroup(formsMap.TelegramStorage, "Storage", ["TelegramStorageBotToken", "TelegramStorageChatId", "TelegramProxyAddress", "TelegramProxyPort", "TelegramProxyUsername", "TelegramProxyPassword"])}
|
}}
|
||||||
style={{ width: isMobile ? '100%' : '240px' }}
|
marks={{
|
||||||
>
|
50: '50%',
|
||||||
保存所有 Telegram 配置
|
75: '75%',
|
||||||
</Button>
|
100: '100%'
|
||||||
</Form.Item>
|
}}
|
||||||
</Form>
|
/>
|
||||||
)}
|
<div style={{ fontSize: 12, color: '#999', marginTop: 16, textAlign: 'center' }}>
|
||||||
{storageType === 'S3' && (
|
{allDescriptions.Upload?.HighQualityImageCompressionQuality}
|
||||||
<Form form={formsMap.S3Storage} layout="vertical" size={isMobile ? "middle" : "large"}>
|
</div>
|
||||||
{renderConfigFormItems(formsMap.S3Storage, "Storage", ["S3StorageAccessKey", "S3StorageSecretKey", "S3StorageBucketName", "S3StorageRegion", "S3StorageEndpoint", "S3StorageCdnUrl", "S3StorageUsePathStyleUrls"])}
|
</div>
|
||||||
<Divider style={{ margin: '12px 0 20px' }} />
|
|
||||||
<Form.Item style={{ marginBottom: 0, textAlign: 'center' }}>
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
icon={<SaveOutlined />}
|
|
||||||
onClick={() => onSaveAllForGroup(formsMap.S3Storage, "Storage", ["S3StorageAccessKey", "S3StorageSecretKey", "S3StorageBucketName", "S3StorageRegion", "S3StorageEndpoint", "S3StorageCdnUrl", "S3StorageUsePathStyleUrls"])}
|
|
||||||
style={{ width: isMobile ? '100%' : '240px' }}
|
|
||||||
>
|
|
||||||
保存所有 S3 配置
|
|
||||||
</Button>
|
|
||||||
</Form.Item>
|
|
||||||
</Form>
|
|
||||||
)}
|
|
||||||
{storageType === 'Cos' && (
|
|
||||||
<Form form={formsMap.CosStorage} layout="vertical" size={isMobile ? "middle" : "large"}>
|
|
||||||
{renderConfigFormItems(formsMap.CosStorage, "Storage", ["CosStorageSecretId", "CosStorageSecretKey", "CosStorageToken", "CosStorageBucketName", "CosStorageRegion", "CosStorageCdnUrl"])}
|
|
||||||
<Divider style={{ margin: '12px 0 20px' }} />
|
|
||||||
<Form.Item style={{ marginBottom: 0, textAlign: 'center' }}>
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
icon={<SaveOutlined />}
|
|
||||||
onClick={() => onSaveAllForGroup(formsMap.CosStorage, "Storage", ["CosStorageSecretId", "CosStorageSecretKey", "CosStorageToken", "CosStorageBucketName", "CosStorageRegion", "CosStorageCdnUrl"])}
|
|
||||||
style={{ width: isMobile ? '100%' : '240px' }}
|
|
||||||
>
|
|
||||||
保存所有 COS 配置
|
|
||||||
</Button>
|
|
||||||
</Form.Item>
|
|
||||||
</Form>
|
|
||||||
)}
|
|
||||||
{storageType === 'WebDAV' && (
|
|
||||||
<Form form={formsMap.WebDAVStorage} layout="vertical" size={isMobile ? "middle" : "large"}>
|
|
||||||
{renderConfigFormItems(formsMap.WebDAVStorage, "Storage", ["WebDAVServerUrl", "WebDAVUserName", "WebDAVPassword", "WebDAVBasePath", "WebDAVPublicUrl"])}
|
|
||||||
<Divider style={{ margin: '12px 0 20px' }} />
|
|
||||||
<Form.Item style={{ marginBottom: 0, textAlign: 'center' }}>
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
icon={<SaveOutlined />}
|
|
||||||
onClick={() => onSaveAllForGroup(formsMap.WebDAVStorage, "Storage", ["WebDAVServerUrl", "WebDAVUserName", "WebDAVPassword", "WebDAVBasePath", "WebDAVPublicUrl"])}
|
|
||||||
style={{ width: isMobile ? '100%' : '240px' }}
|
|
||||||
>
|
|
||||||
保存所有 WebDAV 配置
|
|
||||||
</Button>
|
|
||||||
</Form.Item>
|
|
||||||
</Form>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</ConfigSection>
|
</ConfigSection>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import React, { useEffect, useState, useRef, useCallback } from 'react';
|
import React, { useEffect, useState, useRef, useCallback } from 'react';
|
||||||
import { Card, message, Spin, Button, Upload, Modal, Space, Tooltip, Form, Typography, notification } from 'antd';
|
import { Card, message, Spin, Button, Upload, Modal, Space, Tooltip, Form, Typography, notification } from 'antd';
|
||||||
import {
|
import {
|
||||||
CloudOutlined, DatabaseOutlined, CloudServerOutlined, GlobalOutlined,
|
|
||||||
DownloadOutlined, UploadOutlined, QuestionCircleOutlined,
|
DownloadOutlined, UploadOutlined, QuestionCircleOutlined,
|
||||||
SettingOutlined,
|
SettingOutlined,
|
||||||
CheckCircleOutlined
|
CheckCircleOutlined
|
||||||
@@ -46,37 +45,10 @@ const allDescriptions: Record<string, Record<string, string>> = {
|
|||||||
ServerUrl: '服务器URL',
|
ServerUrl: '服务器URL',
|
||||||
MaxConcurrentTasks: '后台任务最大并发处理数量 (例如: 图像分析、标签生成等)'
|
MaxConcurrentTasks: '后台任务最大并发处理数量 (例如: 图像分析、标签生成等)'
|
||||||
},
|
},
|
||||||
Storage: {
|
|
||||||
DefaultStorage: '已登录用户上传文件时的默认存储位置',
|
|
||||||
AnonymousDefaultStorage: '未登录用户上传文件时的默认存储位置',
|
|
||||||
TelegramStorageBotToken: 'Telegram 机器人令牌',
|
|
||||||
TelegramStorageChatId: 'Telegram 聊天ID',
|
|
||||||
TelegramProxyAddress: '代理服务器地址 (例如: 127.0.0.1)',
|
|
||||||
TelegramProxyPort: '代理服务器端口 (例如: 1080)',
|
|
||||||
TelegramProxyUsername: '代理用户名 (可选)',
|
|
||||||
TelegramProxyPassword: '代理密码 (可选)',
|
|
||||||
S3StorageAccessKey: 'S3访问密钥',
|
|
||||||
S3StorageSecretKey: 'S3私有密钥',
|
|
||||||
S3StorageBucketName: 'S3存储桶名称',
|
|
||||||
S3StorageRegion: 'S3区域 (例如:us-east-1)',
|
|
||||||
S3StorageEndpoint: 'S3端点URL (可选,默认为AWS S3)',
|
|
||||||
S3StorageCdnUrl: 'CDN URL (可选,用于加速文件访问)',
|
|
||||||
S3StorageUsePathStyleUrls: '使用路径形式URLs (true/false,兼容非AWS服务)',
|
|
||||||
CosStorageSecretId: '腾讯云COS密钥ID',
|
|
||||||
CosStorageSecretKey: '腾讯云COS私有密钥',
|
|
||||||
CosStorageToken: '腾讯云COS临时令牌(可选)',
|
|
||||||
CosStorageBucketName: 'COS存储桶名称',
|
|
||||||
CosStorageRegion: 'COS区域 (例如:ap-shanghai)',
|
|
||||||
CosStorageCdnUrl: 'CDN URL (可选,用于加速文件访问)',
|
|
||||||
WebDAVServerUrl: 'WebDAV 服务器 URL (例如: https://dav.example.com)',
|
|
||||||
WebDAVUserName: 'WebDAV 用户名',
|
|
||||||
WebDAVPassword: 'WebDAV 密码',
|
|
||||||
WebDAVBasePath: 'WebDAV 基础路径 (例如: files/upload)',
|
|
||||||
WebDAVPublicUrl: 'WebDAV 公共访问 URL (可选,用于文件访问)',
|
|
||||||
},
|
|
||||||
Upload: {
|
Upload: {
|
||||||
DefaultImageFormat: '上传图片时的默认处理格式,选择合适的格式可以优化存储和显示',
|
HighQualityImageCompressionQuality: '高清图片的压缩质量,越高图片质量越好但文件越大。范围 50-100。',
|
||||||
DefaultImageQuality: '适用于JPEG和WebP格式的图片质量设置,越高图片质量越好但文件越大'
|
ThumbnailMaxWidth: '缩略图的最大宽度(像素),例如设置为 400。',
|
||||||
|
ThumbnailCompressionQuality: '缩略图的压缩质量,用于平衡文件大小和清晰度。范围 30-90。'
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -86,7 +58,6 @@ const System: React.FC = () => {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [configs, setConfigs] = useState<ConfigStructure>({});
|
const [configs, setConfigs] = useState<ConfigStructure>({});
|
||||||
const [activeKey, setActiveKey] = useState('AI');
|
const [activeKey, setActiveKey] = useState('AI');
|
||||||
const [storageType, setStorageType] = useState('Telegram');
|
|
||||||
const [backupLoading, setBackupLoading] = useState(false);
|
const [backupLoading, setBackupLoading] = useState(false);
|
||||||
const [restoreLoading, setRestoreLoading] = useState(false);
|
const [restoreLoading, setRestoreLoading] = useState(false);
|
||||||
const [restoreModalVisible, setRestoreModalVisible] = useState(false);
|
const [restoreModalVisible, setRestoreModalVisible] = useState(false);
|
||||||
@@ -100,10 +71,6 @@ const System: React.FC = () => {
|
|||||||
const [jwtForm] = Form.useForm();
|
const [jwtForm] = Form.useForm();
|
||||||
const [authForm] = Form.useForm();
|
const [authForm] = Form.useForm();
|
||||||
const [appSettingsForm] = Form.useForm();
|
const [appSettingsForm] = Form.useForm();
|
||||||
const [telegramForm] = Form.useForm();
|
|
||||||
const [s3Form] = Form.useForm();
|
|
||||||
const [cosForm] = Form.useForm();
|
|
||||||
const [webDAVForm] = Form.useForm();
|
|
||||||
const [uploadForm] = Form.useForm();
|
const [uploadForm] = Form.useForm();
|
||||||
|
|
||||||
const formsMap: Record<string, any> = {
|
const formsMap: Record<string, any> = {
|
||||||
@@ -111,10 +78,6 @@ const System: React.FC = () => {
|
|||||||
Jwt: jwtForm,
|
Jwt: jwtForm,
|
||||||
Authentication: authForm,
|
Authentication: authForm,
|
||||||
AppSettings: appSettingsForm,
|
AppSettings: appSettingsForm,
|
||||||
TelegramStorage: telegramForm,
|
|
||||||
S3Storage: s3Form,
|
|
||||||
CosStorage: cosForm,
|
|
||||||
WebDAVStorage: webDAVForm,
|
|
||||||
Upload: uploadForm,
|
Upload: uploadForm,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -145,20 +108,11 @@ const System: React.FC = () => {
|
|||||||
setConfigs(configGroups);
|
setConfigs(configGroups);
|
||||||
setSecretFields(secretFieldsMap);
|
setSecretFields(secretFieldsMap);
|
||||||
|
|
||||||
if (configGroups.Storage?.DefaultStorage) {
|
|
||||||
setStorageType(configGroups.Storage.DefaultStorage);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更高效地更新表单值
|
// 更高效地更新表单值
|
||||||
Object.keys(configGroups).forEach(group => {
|
Object.keys(configGroups).forEach(group => {
|
||||||
let formInstanceKey = group;
|
let formInstanceKey = group;
|
||||||
if (group === "Storage") {
|
|
||||||
} else if (group.endsWith("Storage") && formsMap[group]) {
|
|
||||||
formInstanceKey = group;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const formInstance = formsMap[formInstanceKey];
|
||||||
const formInstance = formsMap[formInstanceKey] || (group === "Storage" ? formsMap[`${configGroups.Storage.DefaultStorage}Storage`] : null);
|
|
||||||
|
|
||||||
if (formInstance) {
|
if (formInstance) {
|
||||||
const initialGroupValues: Record<string, string> = {};
|
const initialGroupValues: Record<string, string> = {};
|
||||||
@@ -456,34 +410,6 @@ const System: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 存储类型选项
|
|
||||||
const storageOptions = [
|
|
||||||
{ value: 'Local', label: '本地存储', icon: <DatabaseOutlined style={{ color: '#52c41a' }} /> },
|
|
||||||
{ value: 'Telegram', label: 'Telegram 频道', icon: <CloudOutlined style={{ color: '#0088cc' }} /> },
|
|
||||||
{ value: 'S3', label: '亚马逊 S3', icon: <CloudServerOutlined style={{ color: '#ff9900' }} /> },
|
|
||||||
{ value: 'Cos', label: '腾讯云 COS', icon: <CloudServerOutlined style={{ color: '#00a4ff' }} /> },
|
|
||||||
{ value: 'WebDAV', label: 'WebDAV 存储', icon: <GlobalOutlined style={{ color: '#1890ff' }} /> },
|
|
||||||
];
|
|
||||||
|
|
||||||
// 上传格式选项
|
|
||||||
const imageFormatOptions = [
|
|
||||||
{ value: 'Original', label: '保持原始格式', description: '不改变原始图片格式' },
|
|
||||||
{ value: 'Jpeg', label: '转换为JPEG', description: '适合照片,文件较小但有损压缩' },
|
|
||||||
{ value: 'Png', label: '转换为PNG', description: '适合图形,无损但文件较大' },
|
|
||||||
{ value: 'Webp', label: '转换为WebP', description: '现代格式,体积小且质量好' },
|
|
||||||
];
|
|
||||||
|
|
||||||
// 图片质量选项
|
|
||||||
const imageQualityOptions = [
|
|
||||||
{ value: '100', label: '100% - 最高质量', description: '无损压缩,文件较大' },
|
|
||||||
{ value: '95', label: '95% - 高质量', description: '几乎无损,推荐用于高质量需求' },
|
|
||||||
{ value: '90', label: '90% - 优质', description: '良好平衡,推荐一般用途' },
|
|
||||||
{ value: '85', label: '85% - 良好', description: '适合网页展示,节省空间' },
|
|
||||||
{ value: '80', label: '80% - 节省空间', description: '明显压缩但质量可接受' },
|
|
||||||
{ value: '75', label: '75% - 平衡', description: '显著减小文件大小' },
|
|
||||||
{ value: '70', label: '70% - 压缩', description: '最大压缩,质量较低' },
|
|
||||||
];
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchConfigs();
|
fetchConfigs();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -546,17 +472,12 @@ const System: React.FC = () => {
|
|||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
activeKey={activeKey}
|
activeKey={activeKey}
|
||||||
onTabChange={setActiveKey}
|
onTabChange={setActiveKey}
|
||||||
storageType={storageType}
|
|
||||||
onStorageTypeChange={setStorageType}
|
|
||||||
formsMap={formsMap}
|
formsMap={formsMap}
|
||||||
allDescriptions={allDescriptions}
|
allDescriptions={allDescriptions}
|
||||||
onSaveSingleConfig={handleSaveSingleConfig}
|
onSaveSingleConfig={handleSaveSingleConfig}
|
||||||
onSaveAllForGroup={handleSaveAllForGroup}
|
onSaveAllForGroup={handleSaveAllForGroup}
|
||||||
onBaseSaveConfig={baseSaveConfig}
|
onBaseSaveConfig={baseSaveConfig}
|
||||||
setConfigs={setConfigs}
|
setConfigs={setConfigs}
|
||||||
storageOptions={storageOptions}
|
|
||||||
imageFormatOptions={imageFormatOptions}
|
|
||||||
imageQualityOptions={imageQualityOptions}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import UserManagement from '../pages/admin/users/Index';
|
|||||||
import PictureManagement from '../pages/admin/pictures/Index';
|
import PictureManagement from '../pages/admin/pictures/Index';
|
||||||
import UserDetail from '../pages/admin/users/UserDetail';
|
import UserDetail from '../pages/admin/users/UserDetail';
|
||||||
import AdminLogManagement from '../pages/admin/log/Index';
|
import AdminLogManagement from '../pages/admin/log/Index';
|
||||||
|
import StorageManagementPage from '../pages/admin/storage/StorageManagement';
|
||||||
|
|
||||||
export interface RouteConfig {
|
export interface RouteConfig {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -182,6 +183,17 @@ const routes: RouteConfig[] = [
|
|||||||
breadcrumb: {
|
breadcrumb: {
|
||||||
title: '日志中心'
|
title: '日志中心'
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'storage',
|
||||||
|
key: 'admin-storage',
|
||||||
|
icon: <FileTextOutlined />,
|
||||||
|
label: '存储配置',
|
||||||
|
element: <StorageManagementPage />,
|
||||||
|
area: 'admin',
|
||||||
|
breadcrumb: {
|
||||||
|
title: '存储配置'
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'system',
|
path: 'system',
|
||||||
|
|||||||
Reference in New Issue
Block a user