diff --git a/Api/BackgroundTaskController.cs b/Api/BackgroundTaskController.cs index 8f38cfd..aba36f4 100644 --- a/Api/BackgroundTaskController.cs +++ b/Api/BackgroundTaskController.cs @@ -17,37 +17,20 @@ public class BackgroundTaskController : BaseApiController } [HttpGet("user-tasks")] - public async Task>>> GetUserTasks() + public async Task>>> GetUserTasks() { try { var userId = GetCurrentUserId(); if (userId == null) - return Error>("无法识别用户信息", 401); + return Error>("无法识别用户信息", 401); var tasks = await _backgroundTaskQueue.GetUserTasksStatusAsync(userId.Value); return Success(tasks, "成功获取任务列表"); } catch (Exception ex) { - return Error>($"获取任务状态失败: {ex.Message}", 500); - } - } - - [HttpGet("picture-status/{pictureId}")] - public async Task>> GetPictureStatus(int pictureId) - { - try - { - var status = await _backgroundTaskQueue.GetPictureProcessingStatusAsync(pictureId); - if (status == null) - return Error("找不到该图片的处理状态", 404); - - return Success(status, "成功获取图片处理状态"); - } - catch (Exception ex) - { - return Error($"获取图片处理状态失败: {ex.Message}", 500); + return Error>($"获取任务状态失败: {ex.Message}", 500); } } } diff --git a/Extensions/ServiceCollectionExtensions.cs b/Extensions/ServiceCollectionExtensions.cs index cd0f5a4..3f987af 100644 --- a/Extensions/ServiceCollectionExtensions.cs +++ b/Extensions/ServiceCollectionExtensions.cs @@ -14,6 +14,7 @@ using Foxel.Services.Media; using Foxel.Services.Storage; using Foxel.Services.Storage.Providers; using Foxel.Services.VectorDB; +using Foxel.Services.Background.Processors; namespace Foxel.Extensions; @@ -38,6 +39,7 @@ public static class ServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); } diff --git a/Foxel.csproj b/Foxel.csproj index 5ea75da..14b7763 100644 --- a/Foxel.csproj +++ b/Foxel.csproj @@ -26,6 +26,7 @@ + diff --git a/Models/DataBase/BackgroundTask.cs b/Models/DataBase/BackgroundTask.cs new file mode 100644 index 0000000..9d1884a --- /dev/null +++ b/Models/DataBase/BackgroundTask.cs @@ -0,0 +1,62 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Foxel.Models.DataBase +{ + public enum TaskType + { + PictureProcessing = 0, + } + + public enum TaskExecutionStatus + { + Pending, // 等待处理 + Processing, // 处理中 + Completed, // 处理完成 + Failed // 处理失败 + } + + public class BackgroundTask + { + [Key] + public Guid Id { get; set; } // 任务的唯一标识符 + + public TaskType Type { get; set; } // 任务类型 + + public TaskExecutionStatus Status { get; set; } // 当前状态 + + public int Progress { get; set; } // 进度 (0-100) + + [Column(TypeName = "jsonb")] + public string? Payload { get; set; } // JSON 字符串,存储任务特定数据 + + public string? ErrorMessage { get; set; } // 错误信息(如果任务失败) + + public DateTime CreatedAt { get; set; } // 创建时间 + + public DateTime? StartedAt { get; set; } // 开始处理时间 + + public DateTime? CompletedAt { get; set; } // 完成时间 + + public int? UserId { get; set; } // 关联的用户ID + public User? User { get; set; } + + public int? RelatedEntityId { get; set; } + + public BackgroundTask() + { + Id = Guid.NewGuid(); + CreatedAt = DateTime.UtcNow; + Status = TaskExecutionStatus.Pending; + Progress = 0; + } + } + + public class PictureProcessingPayload + { + public int PictureId { get; set; } + public required string OriginalFilePath { get; set; } + public int? UserIdForPicture { get; set; } + } +} diff --git a/Models/DataBase/Picture.cs b/Models/DataBase/Picture.cs index 3f7eb18..727fbbe 100644 --- a/Models/DataBase/Picture.cs +++ b/Models/DataBase/Picture.cs @@ -41,10 +41,6 @@ public class Picture : BaseModel public bool ContentWarning { get; set; } = false; public PermissionType Permission { get; set; } = PermissionType.Public; - - public ProcessingStatus ProcessingStatus { get; set; } = ProcessingStatus.Pending; - public string? ProcessingError { get; set; } - public int ProcessingProgress { get; set; } = 0; } public enum PermissionType @@ -53,11 +49,3 @@ public enum PermissionType Friends = 1, Private = 2 } - -public enum ProcessingStatus -{ - Pending, // 等待处理 - Processing, // 处理中 - Completed, // 处理完成 - Failed // 处理失败 -} \ No newline at end of file diff --git a/Models/Response/Picture/PictureResponse.cs b/Models/Response/Picture/PictureResponse.cs index 792a90a..6e0a842 100644 --- a/Models/Response/Picture/PictureResponse.cs +++ b/Models/Response/Picture/PictureResponse.cs @@ -20,7 +20,4 @@ public record PictureResponse public int? AlbumId { get; set; } public string? AlbumName { get; set; } public PermissionType Permission { get; set; } = PermissionType.Public; - public ProcessingStatus ProcessingStatus { get; set; } - public string? ProcessingError { get; set; } - public int ProcessingProgress { get; set; } } diff --git a/MyDbContext.cs b/MyDbContext.cs index d292d13..99b5657 100644 --- a/MyDbContext.cs +++ b/MyDbContext.cs @@ -13,4 +13,5 @@ public class MyDbContext(DbContextOptions options) : DbContext(opti public DbSet Albums { get; set; } = null!; public DbSet Roles { get; set; } = null!; public DbSet Logs { get; set; } = null!; + public DbSet BackgroundTasks { get; set; } = null!; } \ No newline at end of file diff --git a/Services/AI/AiService.cs b/Services/AI/AiService.cs index 1dba755..5d9c90d 100644 --- a/Services/AI/AiService.cs +++ b/Services/AI/AiService.cs @@ -65,8 +65,7 @@ public class AiService(IHttpClientFactory httpClientFactory, IConfigService conf var textContent = new TextContent { Type = "text", - Text = configService["AI:ImageAnalysisPrompt"] ?? - "请详细分析这张图片,并提供全面的描述,以便用于向量嵌入和基于文本的图像搜索。描述需要包含:主体对象、场景环境、色彩特点、构图布局、风格特征、情绪氛围、细节特征等关键元素。请提供一个简短有力的标题,然后提供详细描述。\n\n请以JSON格式返回,格式如下:\n{\"title\": \"简短概括图片的核心内容\", \"description\": \"全面详细的描述,包含上述所有元素,使用丰富精确的词汇,避免笼统表达\"}\n\n请确保返回有效的JSON格式。" + Text = configService["AI:ImageAnalysisPrompt"] }; var message = new ChatMessage @@ -117,8 +116,7 @@ public class AiService(IHttpClientFactory httpClientFactory, IConfigService conf string model = configService["AI:Model"]; var tagsText = string.Join(", ", availableTags); - string promptTemplate = configService["AI:TagMatchingPrompt"] ?? - "以下是一组标签:[{tagsText}]。\n\n请从这些标签中严格选择与下面描述内容高度相关的标签(最多选择5个)。只选择确实匹配的标签,如果找不到完全匹配或高度相关的标签,宁可返回空数组也不要选择不太相关的标签。\n\n描述内容:{description}\n\n请以JSON格式返回,格式如下:\n{{\"tags\": [\"标签1\", \"标签2\", \"标签3\"]}}\n\n请确保返回有效的JSON格式前面不要加```,并且只包含确实匹配的标签名称。"; + string promptTemplate = configService["AI:TagMatchingPrompt"]; // 替换占位符 string promptText = promptTemplate @@ -246,8 +244,7 @@ public class AiService(IHttpClientFactory httpClientFactory, IConfigService conf if (allowNewTags) { // 获取配置的标签生成提示词,如果没有则使用默认提示词 - string defaultPrompt = configService["AI:TagGenerationPrompt"] ?? - "请为图片生成5个最相关的标签,每个标签应该是简短且描述性的词语或短语。\n\n请以JSON格式返回,格式如下:\n{\"tags\": [\"标签1\", \"标签2\", \"标签3\", \"标签4\", \"标签5\"]}\n\n请确保返回有效的JSON格式。"; + string defaultPrompt = configService["AI:TagGenerationPrompt"]; // 如果允许新标签,则提供现有标签作为参考,但允许生成新标签 promptText = availableTags.Count > 0 @@ -261,8 +258,7 @@ public class AiService(IHttpClientFactory httpClientFactory, IConfigService conf return new List(); var tagsText = string.Join(", ", availableTags); - string templatePrompt = configService["AI:TagMatchingPrompt"] ?? - "以下是一组标签:[{tagsText}]。\n\n请从这些标签中严格选择与图片内容高度相关的标签(最多选择5个)。只选择确实匹配的标签,如果找不到完全匹配或高度相关的标签,宁可返回空数组也不要选择不太相关的标签。\n\n请以JSON格式返回,格式如下:\n{{\"tags\": [\"标签1\", \"标签2\", \"标签3\"]}}\n\n请确保返回有效的JSON格式,并且只包含上述列表中的标签名称。"; + string templatePrompt = configService["AI:TagMatchingPrompt"]; promptText = templatePrompt.Replace("{tagsText}", tagsText); } diff --git a/Services/Background/BackgroundTaskQueue.cs b/Services/Background/BackgroundTaskQueue.cs index 32a13c3..631c7eb 100644 --- a/Services/Background/BackgroundTaskQueue.cs +++ b/Services/Background/BackgroundTaskQueue.cs @@ -1,21 +1,16 @@ using System.Collections.Concurrent; +using System.Text.Json; using System.Threading.Channels; using Foxel.Models.DataBase; -using Foxel.Services.AI; -using Foxel.Services.Attributes; using Foxel.Services.Configuration; -using Foxel.Services.Storage; -using Foxel.Services.VectorDB; -using Foxel.Utils; +using Foxel.Services.Background.Processors; using Microsoft.EntityFrameworkCore; namespace Foxel.Services.Background; public sealed class BackgroundTaskQueue : IBackgroundTaskQueue, IDisposable { - private readonly Channel _queue; - private readonly ConcurrentDictionary _activeTasks; - private readonly ConcurrentDictionary _pictureStatus; + private readonly Channel _queue; private readonly IServiceProvider _serviceProvider; private readonly IDbContextFactory _contextFactory; private readonly List _processingTasks; @@ -30,83 +25,129 @@ public sealed class BackgroundTaskQueue : IBackgroundTaskQueue, IDisposable IConfigService configuration, ILogger logger) { - _serviceProvider = serviceProvider; + _serviceProvider = serviceProvider; // Keep IServiceProvider to resolve processors _contextFactory = contextFactory; _logger = logger; - _activeTasks = new ConcurrentDictionary(); - _pictureStatus = new ConcurrentDictionary(); _processingTasks = new List(); - _maxConcurrentTasks = configuration.GetValueAsync("BackgroundTasks:MaxConcurrentTasks", 10).Result; + _maxConcurrentTasks = configuration.GetValueAsync("BackgroundTasks:MaxConcurrentTasks", 10).Result; // 保持原有逻辑 _signal = new SemaphoreSlim(_maxConcurrentTasks); - var options = new BoundedChannelOptions(10000) + var options = new BoundedChannelOptions(10000) // 保持原有逻辑 { FullMode = BoundedChannelFullMode.Wait }; - _queue = Channel.CreateBounded(options); + _queue = Channel.CreateBounded(options); + + // 启动处理器,确保在服务启动时就开始处理队列 + StartProcessor(); } public async Task QueuePictureProcessingTaskAsync(int pictureId, string originalFilePath) { - var task = new PictureProcessingTask - { - Id = Guid.NewGuid(), - PictureId = pictureId, - OriginalFilePath = originalFilePath, - CreatedAt = DateTime.UtcNow - }; - - // 更新状态字典 - var status = new PictureProcessingStatus - { - TaskId = task.Id, - PictureId = pictureId, - Status = ProcessingStatus.Pending, - Progress = 0, - CreatedAt = DateTime.UtcNow - }; - - // 将用户ID添加到任务状态中,这样可以按用户过滤任务 - using var scope = _serviceProvider.CreateScope(); await using var dbContext = await _contextFactory.CreateDbContextAsync(); - var picture = await dbContext.Pictures - .Include(p => p.User) - .FirstOrDefaultAsync(p => p.Id == pictureId); - - if (picture != null) + var picture = await dbContext.Pictures.FindAsync(pictureId); + if (picture == null) { - status.PictureName = picture.Name; - task.UserId = picture.UserId; + _logger.LogError("无法为不存在的图片 PictureId: {PictureId} 创建处理任务", pictureId); + throw new KeyNotFoundException($"找不到 PictureId: {pictureId} 的图片"); } - _pictureStatus[pictureId] = status; - _activeTasks[task.Id] = task; - await _queue.Writer.WriteAsync(task); + var payload = new PictureProcessingPayload + { + PictureId = pictureId, + OriginalFilePath = originalFilePath, + UserIdForPicture = picture.UserId + }; + + var backgroundTask = new BackgroundTask + { + Type = TaskType.PictureProcessing, + Payload = JsonSerializer.Serialize(payload), + UserId = picture.UserId, + RelatedEntityId = pictureId, + Status = TaskExecutionStatus.Pending, + CreatedAt = DateTime.UtcNow + }; + + dbContext.BackgroundTasks.Add(backgroundTask); + await dbContext.SaveChangesAsync(); + + await _queue.Writer.WriteAsync(backgroundTask.Id); + _logger.LogInformation("图片处理任务已加入队列: TaskId={TaskId}, PictureId={PictureId}", backgroundTask.Id, pictureId); - // 启动处理器,如果没有正在运行 StartProcessor(); - return task.Id; + return backgroundTask.Id; } - public async Task> GetUserTasksStatusAsync(int userId) + public async Task> GetUserTasksStatusAsync(int userId) { await using var dbContext = await _contextFactory.CreateDbContextAsync(); - var userPictureIds = await dbContext.Pictures - .Where(p => p.UserId == userId && - (p.ProcessingStatus == ProcessingStatus.Pending || - p.ProcessingStatus == ProcessingStatus.Processing)) - .Select(p => p.Id) + var tasks = await dbContext.BackgroundTasks + .Where(bt => bt.UserId == userId) + .OrderByDescending(bt => bt.CreatedAt) .ToListAsync(); - return _pictureStatus.Values - .Where(s => userPictureIds.Contains(s.PictureId)) - .OrderByDescending(s => s.CreatedAt) - .ToList(); + var statusList = new List(); + foreach (var task in tasks) + { + string taskName = $"任务: {task.Id}"; + if (task.Type == TaskType.PictureProcessing && task.RelatedEntityId.HasValue) + { + var picture = await dbContext.Pictures.FindAsync(task.RelatedEntityId.Value); + if (picture != null) + { + taskName = picture.Name; + } + else + { + taskName = "图片处理 (图片信息丢失)"; + } + } + + statusList.Add(new TaskDetailsDto + { + TaskId = task.Id, + TaskName = taskName, + TaskType = task.Type, + Status = task.Status, + Progress = task.Progress, + Error = task.ErrorMessage, + CreatedAt = task.CreatedAt, + CompletedAt = task.CompletedAt, + RelatedEntityId = task.RelatedEntityId + }); + } + return statusList; } - public Task GetPictureProcessingStatusAsync(int pictureId) + public async Task GetPictureProcessingStatusAsync(int pictureId) { - return Task.FromResult(_pictureStatus.GetValueOrDefault(pictureId)); + await using var dbContext = await _contextFactory.CreateDbContextAsync(); + var task = await dbContext.BackgroundTasks + .FirstOrDefaultAsync(bt => bt.RelatedEntityId == pictureId && bt.Type == TaskType.PictureProcessing); + + if (task == null) + return null; + + var pictureName = "未知图片"; + var picture = await dbContext.Pictures.FindAsync(pictureId); + if (picture != null) + { + pictureName = picture.Name; + } + + return new TaskDetailsDto + { + TaskId = task.Id, + TaskName = pictureName, // Picture name as task name + TaskType = task.Type, + Status = task.Status, + Progress = task.Progress, + Error = task.ErrorMessage, + CreatedAt = task.CreatedAt, + CompletedAt = task.CompletedAt, + RelatedEntityId = task.RelatedEntityId + }; } public async Task RestoreUnfinishedTasksAsync() @@ -114,38 +155,27 @@ public sealed class BackgroundTaskQueue : IBackgroundTaskQueue, IDisposable try { await using var dbContext = await _contextFactory.CreateDbContextAsync(); - - // 获取所有未完成的图片处理任务 - var unfinishedPictures = await dbContext.Pictures - .Where(p => p.ProcessingStatus == ProcessingStatus.Pending || - p.ProcessingStatus == ProcessingStatus.Processing) + var unfinishedTasks = await dbContext.BackgroundTasks + .Where(bt => bt.Type == TaskType.PictureProcessing && + (bt.Status == TaskExecutionStatus.Pending || bt.Status == TaskExecutionStatus.Processing)) .ToListAsync(); - if (unfinishedPictures.Any()) + if (unfinishedTasks.Any()) { - _logger.LogInformation("正在恢复 {Count} 个未完成的图片处理任务", unfinishedPictures.Count); - - foreach (var picture in unfinishedPictures) + _logger.LogInformation("正在恢复 {Count} 个未完成的图片处理任务", unfinishedTasks.Count); + foreach (var task in unfinishedTasks) { - // 构建原始文件路径 - string relativePath = picture.Path.TrimStart('/'); - string originalFilePath = Path.Combine(Directory.GetCurrentDirectory(), relativePath); - if (File.Exists(originalFilePath)) + // 确保任务状态在数据库中被重置为 Pending,以防上次运行时停在 Processing 状态 + if (task.Status == TaskExecutionStatus.Processing) { - // 重新加入队列 - await QueuePictureProcessingTaskAsync(picture.Id, originalFilePath); - _logger.LogInformation("已恢复图片处理任务: ID={PictureId}, 路径={FilePath}", picture.Id, originalFilePath); - } - else - { - // 如果文件不存在,则标记为失败 - picture.ProcessingStatus = ProcessingStatus.Failed; - picture.ProcessingError = "系统重启后找不到原始图片文件"; - _logger.LogWarning("无法恢复图片处理任务: ID={PictureId}, 找不到文件: {FilePath}", picture.Id, originalFilePath); + task.Status = TaskExecutionStatus.Pending; + task.StartedAt = null; // 重置开始时间 + // 保留 Progress 和 ErrorMessage 以供参考 } + await _queue.Writer.WriteAsync(task.Id); + _logger.LogInformation("已恢复图片处理任务到队列: TaskId={TaskId}, RelatedEntityId={RelatedEntityId}", task.Id, task.RelatedEntityId); } - - await dbContext.SaveChangesAsync(); + await dbContext.SaveChangesAsync(); // 保存状态更改 } else { @@ -160,342 +190,140 @@ public sealed class BackgroundTaskQueue : IBackgroundTaskQueue, IDisposable private void StartProcessor() { - // 添加新的处理任务,如果当前任务数量小于最大并发数 - while (_processingTasks.Count(t => !t.IsCompleted) < _maxConcurrentTasks) + lock (_processingTasks) // 确保线程安全地访问 _processingTasks { - _processingTasks.Add(Task.Run(ProcessTasksAsync)); - } + // 清理已完成的任务 + _processingTasks.RemoveAll(t => t.IsCompleted); - // 清理已完成的任务 - _processingTasks.RemoveAll(t => t.IsCompleted); + // 添加新的处理任务,如果当前任务数量小于最大并发数 + while (_processingTasks.Count < _maxConcurrentTasks && _queue.Reader.Count > 0) + { + _processingTasks.Add(Task.Run(ProcessTasksAsync)); + } + } } private async Task ProcessTasksAsync() { while (await _queue.Reader.WaitToReadAsync()) { - await _signal.WaitAsync(); - - try + if (_queue.Reader.TryRead(out var taskId)) { - if (_queue.Reader.TryRead(out var task)) + await _signal.WaitAsync(); + try { - await ProcessPictureAsync(task); - } - } - finally - { - _signal.Release(); - } - } - } + await using var checkDbContext = await _contextFactory.CreateDbContextAsync(); + var taskToCheck = await checkDbContext.BackgroundTasks.FindAsync(taskId); - private async Task ProcessPictureAsync(PictureProcessingTask task) - { - if (!_activeTasks.TryGetValue(task.Id, out _) || !_pictureStatus.TryGetValue(task.PictureId, out var status)) - { - return; - } - - // 更新状态为处理中 - await UpdatePictureStatus(task.PictureId, ProcessingStatus.Processing, 0); - - string localFilePath = ""; - bool isTempFile = false; - var dbContext = await _contextFactory.CreateDbContextAsync(); - var picture = await dbContext.Pictures.FindAsync(task.PictureId); - - try - { - using var scope = _serviceProvider.CreateScope(); - var aiService = scope.ServiceProvider.GetRequiredService(); - var storageService = scope.ServiceProvider.GetRequiredService(); - - // 1. 获取图片信息 - await UpdatePictureStatus(task.PictureId, ProcessingStatus.Processing, 10); - - if (picture == null) - { - throw new Exception($"找不到ID为{task.PictureId}的图片"); - } - - // 处理文件获取逻辑 - if (picture.StorageType == StorageType.Local) - { - // 本地存储,直接使用文件路径 - localFilePath = Path.Combine(Directory.GetCurrentDirectory(), picture.Path.TrimStart('/')); - } - else - { - // 非本地存储需要先下载文件 - await UpdatePictureStatus(task.PictureId, ProcessingStatus.Processing, 15); - localFilePath = await storageService.ExecuteAsync(picture.StorageType, - provider => provider.DownloadFileAsync(picture.Path)); - isTempFile = true; - } - - if (string.IsNullOrEmpty(localFilePath) || !File.Exists(localFilePath)) - { - throw new Exception($"找不到图片文件: {localFilePath}"); - } - - // 检查并生成缩略图(如果不存在) - await UpdatePictureStatus(task.PictureId, ProcessingStatus.Processing, 20); - - string thumbnailForAI = localFilePath; // 用于AI分析的缩略图路径 - - if (string.IsNullOrEmpty(picture.ThumbnailPath)) - { - // 如果缩略图不存在,生成缩略图 - var thumbnailPath = Path.Combine( - Path.GetDirectoryName(localFilePath)!, - Path.GetFileNameWithoutExtension(Path.GetFileName(localFilePath)) + "_thumb.webp"); - - await ImageHelper.CreateThumbnailAsync(localFilePath, thumbnailPath, 500); - thumbnailForAI = thumbnailPath; - - // 更新缩略图路径到数据库 - await UpdatePictureStatus(task.PictureId, ProcessingStatus.Processing, 25); - - if (picture.StorageType == StorageType.Local) - { - // 本地存储缩略图 - var relativeThumbnailPath = - $"/Uploads/{Path.GetRelativePath("Uploads", Path.GetDirectoryName(thumbnailPath)!)}/{Path.GetFileName(thumbnailPath)}"; - picture.ThumbnailPath = relativeThumbnailPath.Replace('\\', '/'); - } - else - { - // 上传缩略图并获取存储路径或元数据 - await using var thumbnailFileStream = new FileStream(thumbnailPath, FileMode.Open, FileAccess.Read); - var thumbnailFileName = Path.GetFileName(thumbnailPath); - var thumbnailContentType = "image/webp"; - - string thumbnailStoragePath = await storageService.ExecuteAsync( - picture.StorageType, - provider => provider.SaveAsync(thumbnailFileStream, thumbnailFileName, thumbnailContentType)); - - // 将路径或元数据存储到ThumbnailPath - picture.ThumbnailPath = thumbnailStoragePath; - } - } - else - { - // 如果缩略图已存在,下载用于AI分析 - if (picture.StorageType != StorageType.Local) - { - thumbnailForAI = await storageService.ExecuteAsync(picture.StorageType, - provider => provider.DownloadFileAsync(picture.ThumbnailPath)); - } - else - { - thumbnailForAI = Path.Combine(Directory.GetCurrentDirectory(), picture.ThumbnailPath.TrimStart('/')); - } - } - - // 3. 提取EXIF信息 - await UpdatePictureStatus(task.PictureId, ProcessingStatus.Processing, 30); - var exifInfo = await ImageHelper.ExtractExifInfoAsync(localFilePath); - picture.ExifInfo = exifInfo; - - // 4. 从EXIF中提取拍摄时间并确保是UTC格式 - picture.TakenAt = ImageHelper.ParseExifDateTime(exifInfo.DateTimeOriginal); - - // 保存缩略图和EXIF信息的更改,确保这些基本信息即使在后续步骤失败时也能保存 - await dbContext.SaveChangesAsync(); - - // 5. 将缩略图转换为Base64并调用AI分析 - await UpdatePictureStatus(task.PictureId, ProcessingStatus.Processing, 50); - string base64Image = await ImageHelper.ConvertImageToBase64(thumbnailForAI); - var (title, description) = await aiService.AnalyzeImageAsync(base64Image); - - // 6. 确定最终标题和描述 - string finalTitle = !string.IsNullOrWhiteSpace(title) && title != "AI生成的标题" - ? title - : Path.GetFileNameWithoutExtension(localFilePath); - - string finalDescription = !string.IsNullOrWhiteSpace(description) && description != "AI生成的描述" - ? description - : picture.Description; - - picture.Name = finalTitle; - picture.Description = finalDescription; - - // 7. 生成嵌入向量 - await UpdatePictureStatus(task.PictureId, ProcessingStatus.Processing, 60); - var combinedText = $"{finalTitle}. {finalDescription}"; - var embedding = await aiService.GetEmbeddingAsync(combinedText); - picture.Embedding = embedding; - if (picture.UserId.HasValue && embedding.Length > 0) - { - var vectorDbService = scope.ServiceProvider.GetRequiredService(); - var pictureVector = new Models.Vector.PictureVector - { - Id = (ulong)picture.Id, - Name = picture.Name, - Embedding = embedding - }; - await vectorDbService.AddPictureToUserCollectionAsync(picture.UserId.Value, pictureVector); - } - - // 8. 获取所有可用标签名称 - await UpdatePictureStatus(task.PictureId, ProcessingStatus.Processing, 70); - var availableTagNames = await dbContext.Tags.Select(t => t.Name).ToListAsync(); - - // 9. 获取匹配的标签名称 - 从图片生成标签 - await UpdatePictureStatus(task.PictureId, ProcessingStatus.Processing, 80); - var matchedTagNames = await aiService.GenerateTagsFromImageAsync(base64Image, availableTagNames, true); - - // 10. 处理标签 - await UpdatePictureStatus(task.PictureId, ProcessingStatus.Processing, 90); - var user = await dbContext.Users - .Include(u => u.Tags) - .FirstOrDefaultAsync(u => u.Id == picture.UserId); - - if (user != null && matchedTagNames.Any()) - { - var tagEntities = new List(); - foreach (var tagName in matchedTagNames) - { - var existingTag = await dbContext.Tags - .Include(t => t.Users) - .FirstOrDefaultAsync(t => t.Name.ToLower() == tagName.ToLower()); - - if (existingTag != null) + if (taskToCheck == null) { - tagEntities.Add(existingTag); - user.Tags ??= new List(); - if (user.Tags.All(t => t.Id != existingTag.Id)) + _logger.LogWarning("任务 TaskId={TaskId} 在开始处理前未找到,可能已被删除。", taskId); + continue; // Skip this task + } + + if (taskToCheck.Status != TaskExecutionStatus.Pending && taskToCheck.Status != TaskExecutionStatus.Processing) + { + _logger.LogInformation("任务 TaskId={TaskId} 状态为 {Status},跳过处理。", taskId, taskToCheck.Status); + continue; // Skip this task, already completed or failed by another process + } + + taskToCheck.Status = TaskExecutionStatus.Processing; + taskToCheck.StartedAt = DateTime.UtcNow; + await checkDbContext.SaveChangesAsync(); + + _logger.LogInformation("开始处理任务: TaskId={TaskId}, Type={TaskType}", taskToCheck.Id, taskToCheck.Type); + + try + { + ITaskProcessor processor; + // Processors are typically scoped, so we create a scope here. + using var scope = _serviceProvider.CreateScope(); + switch (taskToCheck.Type) { - user.Tags.Add(existingTag); + case TaskType.PictureProcessing: + processor = scope.ServiceProvider.GetRequiredService(); + break; + // Future task types can be added here + default: + _logger.LogError("未找到任务类型 {TaskType} 的处理器: TaskId={TaskId}", taskToCheck.Type, taskToCheck.Id); + await MarkTaskAsFailedByQueue(taskToCheck.Id, $"未找到任务类型 {taskToCheck.Type} 的处理器。"); + continue; // Continue to next task in queue } + await processor.ProcessAsync(taskToCheck); // Processor handles its own final status update } - else + catch (Exception procEx) { - var newTag = new Tag { Name = tagName.Trim(), Description = tagName.Trim() }; - dbContext.Tags.Add(newTag); - await dbContext.SaveChangesAsync(); - user.Tags ??= new List(); - user.Tags.Add(newTag); - tagEntities.Add(newTag); + _logger.LogError(procEx, "处理器执行任务 TaskId={TaskId} 时发生错误。", taskToCheck.Id); + await MarkTaskAsFailedByQueue(taskToCheck.Id, $"处理器执行时发生错误: {procEx.Message}"); } } - - picture.Tags = tagEntities; - } - - // 11. 更新图片处理状态为完成 - picture.ProcessingStatus = ProcessingStatus.Completed; - picture.ProcessingProgress = 100; - await dbContext.SaveChangesAsync(); - - // 更新任务状态 - await UpdatePictureStatus(task.PictureId, ProcessingStatus.Completed, 100); - status.CompletedAt = DateTime.UtcNow; - } - catch (Exception ex) - { - // 更新状态为失败 - await UpdatePictureStatus(task.PictureId, ProcessingStatus.Failed, 0, ex.Message); - - // 确保图片对象存在且已有的处理结果被保存 - if (picture != null) - { - picture.ProcessingStatus = ProcessingStatus.Failed; - picture.ProcessingError = ex.Message; - - try - { - await dbContext.SaveChangesAsync(); - } - catch (Exception saveEx) - { - _logger.LogError(saveEx, "保存失败状态时出错"); - } - } - - // 记录错误日志 - _logger.LogError(ex, "图片处理失败: 图片ID={PictureId}", task.PictureId); - } - finally - { - // 如果是临时文件,处理完后删除 - if (isTempFile && File.Exists(localFilePath)) - { - try - { - File.Delete(localFilePath); - } catch (Exception ex) { - _logger.LogWarning(ex, "删除临时文件失败: {FilePath}", localFilePath); + _logger.LogError(ex, "处理任务 TaskId={TaskId} 时发生未捕获的异常。", taskId); + await MarkTaskAsFailedByQueue(taskId, $"处理过程中发生未捕获的异常: {ex.Message}"); + } + finally + { + _signal.Release(); + StartProcessor(); } } - - // 清理活动任务 - _activeTasks.TryRemove(task.Id, out _); - - // 继续处理队列中的下一个任务 - StartProcessor(); } } - private async Task UpdatePictureStatus(int pictureId, ProcessingStatus status, int progress, string? error = null) + private async Task MarkTaskAsFailedByQueue(Guid taskId, string errorMessage) { - if (_pictureStatus.TryGetValue(pictureId, out var currentStatus)) - { - currentStatus.Status = status; - currentStatus.Progress = progress; - currentStatus.Error = error; - } - - // 更新数据库中的状态 await using var dbContext = await _contextFactory.CreateDbContextAsync(); - var picture = await dbContext.Pictures.FindAsync(pictureId); - if (picture != null) + var task = await dbContext.BackgroundTasks.FindAsync(taskId); + if (task != null) { - picture.ProcessingStatus = status; - picture.ProcessingProgress = progress; - picture.ProcessingError = error; + task.Status = TaskExecutionStatus.Failed; + task.ErrorMessage = errorMessage; + task.Progress = task.Progress; // Keep existing progress or reset to 0 + task.CompletedAt = DateTime.UtcNow; + if (!task.StartedAt.HasValue) // Ensure StartedAt is set if not already + { + task.StartedAt = task.CreatedAt; + } await dbContext.SaveChangesAsync(); + _logger.LogWarning("任务由队列标记为失败: TaskId={TaskId}, Error='{Error}'", taskId, errorMessage); + } + else + { + _logger.LogWarning("尝试由队列标记为失败,但未找到任务: TaskId={TaskId}", taskId); } } public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - private void Dispose(bool disposing) { if (_isDisposed) return; - if (disposing) + _queue.Writer.TryComplete(); // 尝试完成队列写入 + + // 等待所有处理任务完成,设置超时 + var allProcessingTasksDone = Task.WhenAll(_processingTasks); + try { - _signal.Dispose(); - try + if (!allProcessingTasksDone.Wait(TimeSpan.FromSeconds(10))) // 例如,等待10秒 { - Task.WhenAll(_processingTasks).Wait(5000); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "等待处理任务完成时超时"); + _logger.LogWarning("并非所有后台任务都在 Dispose 超时内完成。"); } } + catch (AggregateException ae) + { + ae.Handle(ex => + { + _logger.LogError(ex, "后台任务在 Dispose 期间抛出异常。"); + return true; // 标记为已处理 + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "等待处理任务完成时发生错误。"); + } + _signal.Dispose(); _isDisposed = true; + GC.SuppressFinalize(this); } -} - -/// -/// 图片处理任务 -/// -public class PictureProcessingTask -{ - public Guid Id { get; set; } - public int PictureId { get; set; } - public string OriginalFilePath { get; set; } = string.Empty; - public int? UserId { get; set; } - public DateTime CreatedAt { get; set; } } \ No newline at end of file diff --git a/Services/Background/IBackgroundTaskQueue.cs b/Services/Background/IBackgroundTaskQueue.cs index 34ad7b4..18ed447 100644 --- a/Services/Background/IBackgroundTaskQueue.cs +++ b/Services/Background/IBackgroundTaskQueue.cs @@ -14,20 +14,20 @@ public interface IBackgroundTaskQueue /// 原始图片路径 /// 任务ID Task QueuePictureProcessingTaskAsync(int pictureId, string originalFilePath); - + /// - /// 获取用户的所有任务状态 + /// 获取用户的所有任务状态 (目前主要指图片处理任务) /// /// 用户ID /// 该用户的任务状态列表 - Task> GetUserTasksStatusAsync(int userId); - + Task> GetUserTasksStatusAsync(int userId); + /// - /// 获取特定图片的处理状态 + /// 获取特定图片的处理状态 (实际获取的是与该图片关联的任务状态) /// - /// 图片ID - /// 处理状态 - Task GetPictureProcessingStatusAsync(int pictureId); + /// 图片ID, 将作为 RelatedEntityId 查询 + /// 处理状态 DTO + Task GetPictureProcessingStatusAsync(int pictureId); /// /// 恢复未完成的任务 @@ -36,16 +36,17 @@ public interface IBackgroundTaskQueue } /// -/// 图片处理状态 +/// 通用任务状态 DTO (用于API响应) /// -public class PictureProcessingStatus +public class TaskDetailsDto { - public int PictureId { get; set; } public Guid TaskId { get; set; } - public string PictureName { get; set; } = string.Empty; - public ProcessingStatus Status { get; set; } + public string TaskName { get; set; } = string.Empty; // 任务的描述性名称 + public TaskType TaskType { get; set; } // 任务类型 + public TaskExecutionStatus Status { get; set; } public int Progress { get; set; } // 0-100 public string? Error { get; set; } public DateTime CreatedAt { get; set; } public DateTime? CompletedAt { get; set; } + public int? RelatedEntityId { get; set; } // 关联实体的ID,例如 PictureId } diff --git a/Services/Background/Processors/ITaskProcessor.cs b/Services/Background/Processors/ITaskProcessor.cs new file mode 100644 index 0000000..c3eeb30 --- /dev/null +++ b/Services/Background/Processors/ITaskProcessor.cs @@ -0,0 +1,10 @@ +using Foxel.Models.DataBase; +using System.Threading.Tasks; + +namespace Foxel.Services.Background.Processors +{ + public interface ITaskProcessor + { + Task ProcessAsync(BackgroundTask task); + } +} diff --git a/Services/Background/Processors/PictureTaskProcessor.cs b/Services/Background/Processors/PictureTaskProcessor.cs new file mode 100644 index 0000000..4b095fc --- /dev/null +++ b/Services/Background/Processors/PictureTaskProcessor.cs @@ -0,0 +1,282 @@ +using Foxel.Models.DataBase; +using Foxel.Services.AI; +using Foxel.Services.Storage; +using Foxel.Services.VectorDB; +using Foxel.Utils; +using Microsoft.EntityFrameworkCore; +using System.Text.Json; +using Foxel.Services.Attributes; + +namespace Foxel.Services.Background.Processors +{ + public class PictureTaskProcessor : ITaskProcessor + { + private readonly IDbContextFactory _contextFactory; + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + private readonly IWebHostEnvironment _environment; + + public PictureTaskProcessor( + IDbContextFactory contextFactory, + IServiceProvider serviceProvider, + ILogger logger, + IWebHostEnvironment environment) + { + _contextFactory = contextFactory; + _serviceProvider = serviceProvider; + _logger = logger; + _environment = environment; + } + + public async Task ProcessAsync(BackgroundTask backgroundTask) + { + if (backgroundTask.Payload == null) + { + await UpdateTaskStatusInDb(backgroundTask.Id, TaskExecutionStatus.Failed, 0, "任务 Payload 为空。"); + _logger.LogError("任务 Payload 为空: TaskId={TaskId}", backgroundTask.Id); + return; + } + + PictureProcessingPayload? payload; + try + { + payload = JsonSerializer.Deserialize(backgroundTask.Payload); + } + catch (JsonException ex) + { + _logger.LogError(ex, "无法解析图片处理任务的 Payload: TaskId={TaskId}", backgroundTask.Id); + await UpdateTaskStatusInDb(backgroundTask.Id, TaskExecutionStatus.Failed, 0, "Payload 解析失败。"); + return; + } + + if (payload == null || payload.PictureId == 0) + { + _logger.LogError("图片处理任务的 Payload 无效或缺少 PictureId: TaskId={TaskId}", backgroundTask.Id); + await UpdateTaskStatusInDb(backgroundTask.Id, TaskExecutionStatus.Failed, 0, "Payload 无效或缺少 PictureId。"); + return; + } + + var pictureId = payload.PictureId; + var originalFilePathFromPayload = payload.OriginalFilePath; + string localFilePath = ""; + bool isTempFile = false; + string thumbnailForAI = string.Empty; + + await using var dbContext = await _contextFactory.CreateDbContextAsync(); + var currentBackgroundTaskState = await dbContext.BackgroundTasks.FindAsync(backgroundTask.Id); + if (currentBackgroundTaskState == null) + { + _logger.LogError("在 PictureTaskProcessor 中找不到后台任务: TaskId={TaskId}", backgroundTask.Id); + return; + } + + + var picture = await dbContext.Pictures.Include(p => p.User).ThenInclude(u => u.Tags).FirstOrDefaultAsync(p => p.Id == pictureId); + + try + { + await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Processing, 10, currentBackgroundTaskState: currentBackgroundTaskState); + + if (picture == null) + { + throw new Exception($"找不到ID为{pictureId}的图片。"); + } + + using var scope = _serviceProvider.CreateScope(); + var aiService = scope.ServiceProvider.GetRequiredService(); + var storageService = scope.ServiceProvider.GetRequiredService(); + + string contentRootPath = _environment.ContentRootPath; + + if (picture.StorageType == StorageType.Local) + { + localFilePath = Path.Combine(contentRootPath, originalFilePathFromPayload.TrimStart('/')); + } + else + { + await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Processing, 15, currentBackgroundTaskState: currentBackgroundTaskState); + localFilePath = await storageService.ExecuteAsync(picture.StorageType, + provider => provider.DownloadFileAsync(originalFilePathFromPayload)); + isTempFile = true; + } + + if (string.IsNullOrEmpty(localFilePath) || !File.Exists(localFilePath)) + { + throw new Exception($"找不到图片文件: {localFilePath} (源路径: {originalFilePathFromPayload})"); + } + + await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Processing, 20, currentBackgroundTaskState: currentBackgroundTaskState); + thumbnailForAI = localFilePath; + + if (string.IsNullOrEmpty(picture.ThumbnailPath)) + { + var tempThumbContainer = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(tempThumbContainer); + var thumbnailDiskPath = Path.Combine(tempThumbContainer, Path.GetFileNameWithoutExtension(Path.GetFileName(localFilePath)) + "_thumb.webp"); + + await ImageHelper.CreateThumbnailAsync(localFilePath, thumbnailDiskPath, 500); + thumbnailForAI = thumbnailDiskPath; + + await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Processing, 25, currentBackgroundTaskState: currentBackgroundTaskState); + + await using var thumbnailFileStream = new FileStream(thumbnailDiskPath, FileMode.Open, FileAccess.Read); + var thumbnailStorageFileName = Path.GetFileNameWithoutExtension(picture.Path.Split('/').LastOrDefault() ?? picture.Name) + "_thumb.webp"; + + string storedThumbnailPath = await storageService.ExecuteAsync( + picture.StorageType, + provider => provider.SaveAsync(thumbnailFileStream, thumbnailStorageFileName, "image/webp")); + picture.ThumbnailPath = storedThumbnailPath; + + if (Directory.Exists(tempThumbContainer)) Directory.Delete(tempThumbContainer, true); + } + else + { + if (picture.StorageType != StorageType.Local && !string.IsNullOrEmpty(picture.ThumbnailPath)) + { + thumbnailForAI = await storageService.ExecuteAsync(picture.StorageType, + provider => provider.DownloadFileAsync(picture.ThumbnailPath)); + } + else if (!string.IsNullOrEmpty(picture.ThumbnailPath)) + { + // 对于本地存储的缩略图,也基于 ContentRootPath 构建路径 + thumbnailForAI = Path.Combine(contentRootPath, picture.ThumbnailPath.TrimStart('/')); + } + } + + if (!File.Exists(thumbnailForAI) && isTempFile) // If thumbnailForAI was meant to be a temp file but doesn't exist, re-download or handle + { + _logger.LogWarning("AI分析所需的缩略图文件不存在: {ThumbnailPath}", thumbnailForAI); + // Fallback or error handling if thumbnailForAI is critical + if (string.IsNullOrEmpty(picture.ThumbnailPath) || picture.StorageType == StorageType.Local) + { + thumbnailForAI = localFilePath; // Fallback to original if thumbnail is missing and was supposed to be local or not generated + } + else + { + // Attempt to re-download if it was from remote storage + thumbnailForAI = await storageService.ExecuteAsync(picture.StorageType, provider => provider.DownloadFileAsync(picture.ThumbnailPath!)); + if (!File.Exists(thumbnailForAI)) throw new Exception($"无法获取用于AI分析的缩略图: {picture.ThumbnailPath}"); + } + } + + + await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Processing, 30, currentBackgroundTaskState: currentBackgroundTaskState); + var exifInfo = await ImageHelper.ExtractExifInfoAsync(localFilePath); + picture.ExifInfo = exifInfo; + picture.TakenAt = ImageHelper.ParseExifDateTime(exifInfo.DateTimeOriginal); + await dbContext.SaveChangesAsync(); + + await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Processing, 50, currentBackgroundTaskState: currentBackgroundTaskState); + string base64Image = await ImageHelper.ConvertImageToBase64(thumbnailForAI); + var (title, description) = await aiService.AnalyzeImageAsync(base64Image); + + string finalTitle = !string.IsNullOrWhiteSpace(title) && title != "AI生成的标题" ? title : Path.GetFileNameWithoutExtension(picture.Name); + string finalDescription = !string.IsNullOrWhiteSpace(description) && description != "AI生成的描述" ? description : picture.Description; + picture.Name = finalTitle; + picture.Description = finalDescription; + + await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Processing, 60, currentBackgroundTaskState: currentBackgroundTaskState); + var combinedText = $"{finalTitle}. {finalDescription}"; + var embedding = await aiService.GetEmbeddingAsync(combinedText); + picture.Embedding = embedding; + + if (picture.UserId.HasValue && embedding != null && embedding.Length > 0) + { + var vectorDbService = scope.ServiceProvider.GetRequiredService(); + await vectorDbService.AddPictureToUserCollectionAsync(picture.UserId.Value, new Models.Vector.PictureVector + { + Id = (ulong)picture.Id, + Name = picture.Name, + Embedding = embedding + }); + } + + await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Processing, 70, currentBackgroundTaskState: currentBackgroundTaskState); + var availableTagNames = await dbContext.Tags.Select(t => t.Name).ToListAsync(); + await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Processing, 80, currentBackgroundTaskState: currentBackgroundTaskState); + var matchedTagNames = await aiService.GenerateTagsFromImageAsync(base64Image, availableTagNames, true); + + await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Processing, 90, currentBackgroundTaskState: currentBackgroundTaskState); + if (picture.User != null && matchedTagNames.Any()) + { + picture.Tags ??= new List(); + foreach (var tagName in matchedTagNames) + { + var existingTag = await dbContext.Tags.FirstOrDefaultAsync(t => t.Name.ToLower() == tagName.ToLower()); + if (existingTag == null) + { + existingTag = new Tag { Name = tagName.Trim(), Description = tagName.Trim() }; + dbContext.Tags.Add(existingTag); + } + if (!picture.Tags.Any(t => t.Id == existingTag.Id)) picture.Tags.Add(existingTag); + + picture.User.Tags ??= new List(); + if (!picture.User.Tags.Any(t => t.Id == existingTag.Id)) picture.User.Tags.Add(existingTag); + } + } + + await dbContext.SaveChangesAsync(); + await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Completed, 100, completedAt: DateTime.UtcNow, currentBackgroundTaskState: currentBackgroundTaskState); + } + catch (Exception ex) + { + _logger.LogError(ex, "图片处理任务失败: TaskId={TaskId}, PictureId={PictureId}", currentBackgroundTaskState.Id, pictureId); + await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Failed, currentBackgroundTaskState.Progress, ex.Message, currentBackgroundTaskState: currentBackgroundTaskState); + if (picture != null) + { + // Potentially update picture entity itself if needed, though task failure is primary + await dbContext.SaveChangesAsync(); + } + } + finally + { + if (isTempFile && File.Exists(localFilePath)) + { + try { File.Delete(localFilePath); } catch (Exception ex) { _logger.LogWarning(ex, "删除临时主图片文件失败: {FilePath}", localFilePath); } + } + bool thumbnailIsTemp = thumbnailForAI.StartsWith(Path.GetTempPath()); + if (thumbnailIsTemp && thumbnailForAI != localFilePath && File.Exists(thumbnailForAI)) + { + try { File.Delete(thumbnailForAI); } catch (Exception ex) { _logger.LogWarning(ex, "删除临时缩略图文件失败: {FilePath}", thumbnailForAI); } + } + } + } + + 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(); + var taskToUpdate = currentBackgroundTaskState ?? await dbContext.BackgroundTasks.FindAsync(taskId); + + if (taskToUpdate != null) + { + if (currentBackgroundTaskState != null && dbContext.Entry(currentBackgroundTaskState).State == EntityState.Detached) + { + dbContext.BackgroundTasks.Attach(currentBackgroundTaskState); + } + + taskToUpdate.Status = status; + taskToUpdate.Progress = progress; + taskToUpdate.ErrorMessage = string.IsNullOrEmpty(error) ? taskToUpdate.ErrorMessage : error; + if (startedAt.HasValue) taskToUpdate.StartedAt = startedAt; + if (completedAt.HasValue) taskToUpdate.CompletedAt = completedAt; + + if ((status == TaskExecutionStatus.Completed || status == TaskExecutionStatus.Failed) && !taskToUpdate.StartedAt.HasValue) + { + taskToUpdate.StartedAt = taskToUpdate.CreatedAt; + } + if (status == TaskExecutionStatus.Completed || status == TaskExecutionStatus.Failed) + { + taskToUpdate.CompletedAt ??= DateTime.UtcNow; + } + + + await dbContext.SaveChangesAsync(); + _logger.LogInformation("任务状态更新 (Processor): TaskId={TaskId}, Status={Status}, Progress={Progress}%", taskId, status, progress); + } + else + { + _logger.LogWarning("尝试在 Processor 中更新不存在的任务状态: TaskId={TaskId}", taskId); + } + } + } +} diff --git a/Services/Management/PictureManagementService.cs b/Services/Management/PictureManagementService.cs index 6ac83af..70a1c16 100644 --- a/Services/Management/PictureManagementService.cs +++ b/Services/Management/PictureManagementService.cs @@ -1,9 +1,7 @@ using Foxel.Models; using Foxel.Models.Response.Picture; -using Foxel.Services.Configuration; using Foxel.Services.Storage; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; namespace Foxel.Services.Management; @@ -28,7 +26,7 @@ public class PictureManagementService( if (!string.IsNullOrWhiteSpace(searchQuery)) { query = query.Where(p => p.Name.Contains(searchQuery) || - (p.Description != null && p.Description.Contains(searchQuery))); + (p.Description.Contains(searchQuery))); } if (userId.HasValue) @@ -51,7 +49,7 @@ public class PictureManagementService( Id = picture.Id, Name = picture.Name, Path = storageService.ExecuteAsync(picture.StorageType, provider => - Task.FromResult(provider.GetUrl(picture.Path ?? string.Empty))).Result, + Task.FromResult(provider.GetUrl(picture.Path))).Result, ThumbnailPath = storageService.ExecuteAsync(picture.StorageType, provider => Task.FromResult(provider.GetUrl(picture.ThumbnailPath ?? string.Empty))).Result, Description = picture.Description, @@ -81,7 +79,8 @@ public class PictureManagementService( await using var dbContext = await contextFactory.CreateDbContextAsync(); var picture = await dbContext.Pictures - .Include(p => p.User) + .Include(p => p.User).Include(picture => picture.Tags).Include(picture => picture.Album) + .Include(picture => picture.Favorites) .FirstOrDefaultAsync(p => p.Id == id); if (picture == null) @@ -103,9 +102,6 @@ public class PictureManagementService( AlbumId = picture.AlbumId, AlbumName = picture.Album?.Name, Permission = picture.Permission, - ProcessingStatus = picture.ProcessingStatus, - ProcessingError = picture.ProcessingError, - ProcessingProgress = picture.ProcessingProgress, FavoriteCount = picture.Favorites?.Count ?? 0, IsFavorited = false }; @@ -216,9 +212,6 @@ public class PictureManagementService( AlbumId = picture.AlbumId, AlbumName = picture.Album?.Name, Permission = picture.Permission, - ProcessingStatus = picture.ProcessingStatus, - ProcessingError = picture.ProcessingError, - ProcessingProgress = picture.ProcessingProgress, FavoriteCount = picture.Favorites?.Count ?? 0, IsFavorited = false }).ToList(); diff --git a/Services/Media/PictureService.cs b/Services/Media/PictureService.cs index cd933a7..ac35e1e 100644 --- a/Services/Media/PictureService.cs +++ b/Services/Media/PictureService.cs @@ -343,8 +343,10 @@ public class PictureService( Name = picture.Name, Path = storageService.ExecuteAsync(picture.StorageType, provider => Task.FromResult(provider.GetUrl(picture.Path ?? string.Empty))).Result, - ThumbnailPath = storageService.ExecuteAsync(picture.StorageType, provider => - Task.FromResult(provider.GetUrl(picture.ThumbnailPath ?? 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(), @@ -354,6 +356,7 @@ public class PictureService( Username = picture.User?.UserName, AlbumId = picture.AlbumId, Permission = picture.Permission + // ProcessingStatus 字段已移除,客户端应通过 BackgroundTaskController 获取状态 }; } @@ -620,13 +623,13 @@ public class PictureService( { Name = initialTitle, Description = initialDescription, - Path = relativePath, + Path = relativePath, // 这是存储服务返回的路径/键 User = user, Permission = permission, AlbumId = albumId, StorageType = storageType.Value, - ProcessingStatus = isAnonymous ? ProcessingStatus.Completed : ProcessingStatus.Pending, - ThumbnailPath = thumbnailPath ?? (isAnonymous ? relativePath : null) // 匿名用户使用原图作为缩略图 + // ProcessingStatus 等字段已移除 + ThumbnailPath = thumbnailPath // 如果生成了缩略图,则保存其存储路径/键 }; dbContext.Pictures.Add(picture); @@ -634,7 +637,8 @@ public class PictureService( if (!isAnonymous) { - await backgroundTaskQueue.QueuePictureProcessingTaskAsync(picture.Id, relativePath); + // 使用 relativePath (即存储服务中的对象键/路径) 来排队任务 + await backgroundTaskQueue.QueuePictureProcessingTaskAsync(picture.Id, picture.Path); } // 返回图片基本信息 @@ -643,21 +647,18 @@ public class PictureService( Id = picture.Id, Name = picture.Name, Path = await storageService.ExecuteAsync(picture.StorageType, provider => - Task.FromResult(provider.GetUrl(relativePath))), - ThumbnailPath = !string.IsNullOrEmpty(thumbnailPath) + Task.FromResult(provider.GetUrl(picture.Path))), // 使用 picture.Path + ThumbnailPath = !string.IsNullOrEmpty(picture.ThumbnailPath) ? await storageService.ExecuteAsync(picture.StorageType, provider => - Task.FromResult(provider.GetUrl(thumbnailPath))) - : (isAnonymous - ? await storageService.ExecuteAsync(picture.StorageType, provider => - Task.FromResult(provider.GetUrl(relativePath))) - : null), + Task.FromResult(provider.GetUrl(picture.ThumbnailPath))) + : null, Description = picture.Description, CreatedAt = picture.CreatedAt, Tags = new List(), Permission = permission, AlbumId = albumId, AlbumName = album?.Name, - ProcessingStatus = picture.ProcessingStatus + // ProcessingStatus 字段已移除 }; return (pictureResponse, picture.Id); diff --git a/Web/src/api/backgroundTaskApi.ts b/Web/src/api/backgroundTaskApi.ts index df94eef..3dbc503 100644 --- a/Web/src/api/backgroundTaskApi.ts +++ b/Web/src/api/backgroundTaskApi.ts @@ -1,29 +1,36 @@ import { fetchApi, type BaseResult } from './fetchClient'; -import type { ProcessingStatus } from './pictureApi'; -// 图片处理任务 -export interface PictureProcessingTask { - pictureId: number; +// 通用任务视图模型 +export interface TaskDetailsViewModel { taskId: string; - pictureName: string; - status: ProcessingStatus; + taskName: string; // 任务的描述性名称 + taskType: number; // 任务类型 (例如 0 for "PictureProcessing") + status: TaskExecutionStatus; // 修改: 类型将是数字枚举 progress: number; // 0-100 error?: string; createdAt: Date; completedAt?: Date; + relatedEntityId?: number; +} +// 修改: TaskExecutionStatus 定义为数字枚举 +export enum TaskExecutionStatus { + Pending = 0, // 等待处理 + Processing = 1, // 处理中 + Completed = 2, // 处理完成 + Failed = 3 // 处理失败 } /** * 获取当前用户的所有处理任务 */ -export const getUserTasks = async (): Promise> => { - return fetchApi('/background-tasks/user-tasks'); +export const getUserTasks = async (): Promise> => { + return fetchApi('/background-tasks/user-tasks'); }; /** - * 获取特定图片的处理状态 + * 获取特定图片的处理状态 (实际获取的是与该图片关联的任务状态) * @param pictureId 图片ID */ -export const getPictureProcessingStatus = async (pictureId: number): Promise> => { - return fetchApi(`/background-tasks/picture-status/${pictureId}`); +export const getPictureTaskExecutionStatus = async (pictureId: number): Promise> => { + return fetchApi(`/background-tasks/picture-status/${pictureId}`); }; diff --git a/Web/src/api/pictureApi.ts b/Web/src/api/pictureApi.ts index 71e70cf..61ef0c5 100644 --- a/Web/src/api/pictureApi.ts +++ b/Web/src/api/pictureApi.ts @@ -20,16 +20,7 @@ export interface FilteredPicturesRequest { includeAllPublic?: boolean; } -// 将类型定义改为枚举,这样既可以作为类型也可以作为值使用 -export type ProcessingStatus = 'Pending' | 'Processing' | 'Completed' | 'Failed'; -// 添加常量对象提供运行时值 -export const ProcessingStatus = { - Pending: 'Pending' as ProcessingStatus, - Processing: 'Processing' as ProcessingStatus, - Completed: 'Completed' as ProcessingStatus, - Failed: 'Failed' as ProcessingStatus -}; // 图片响应数据 export interface PictureResponse { @@ -49,9 +40,6 @@ export interface PictureResponse { permission: number; albumId?: number; albumName?: string; - processingStatus: ProcessingStatus; - processingError?: string; - processingProgress: number; } // 收藏请求 diff --git a/Web/src/components/TaskProgressBar.tsx b/Web/src/components/TaskProgressBar.tsx index 9fa7202..4415d97 100644 --- a/Web/src/components/TaskProgressBar.tsx +++ b/Web/src/components/TaskProgressBar.tsx @@ -6,10 +6,10 @@ import { CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons'; -import { ProcessingStatus } from '../api'; +import { TaskExecutionStatus } from '../api'; interface TaskProgressBarProps { - status: ProcessingStatus; + status: TaskExecutionStatus; // status 现在是数字 progress: number; error?: string; showLabel?: boolean; @@ -32,26 +32,26 @@ const TaskProgressBar: React.FC = ({ let statusText = ''; let progressStatus: "success" | "exception" | "active" | "normal" | undefined; - switch (status) { - case ProcessingStatus.Pending: + switch (status) { // status 现在是数字 + case TaskExecutionStatus.Pending: // 使用数字枚举成员 statusColor = 'orange'; progressStatus = 'normal'; icon = ; statusText = '等待中'; break; - case ProcessingStatus.Processing: + case TaskExecutionStatus.Processing: // 使用数字枚举成员 statusColor = 'processing'; progressStatus = 'active'; icon = ; statusText = '处理中'; break; - case ProcessingStatus.Completed: + case TaskExecutionStatus.Completed: // 使用数字枚举成员 statusColor = 'success'; progressStatus = 'success'; icon = ; statusText = '已完成'; break; - case ProcessingStatus.Failed: + case TaskExecutionStatus.Failed: // 使用数字枚举成员 statusColor = 'error'; progressStatus = 'exception'; icon = ; @@ -66,7 +66,7 @@ const TaskProgressBar: React.FC = ({ {statusText} - {status === ProcessingStatus.Failed && error && ( + {status === TaskExecutionStatus.Failed && error && ( // 使用数字枚举成员 查看错误 @@ -81,7 +81,7 @@ const TaskProgressBar: React.FC = ({ size={size} status={progressStatus} showInfo={size !== 'small'} - strokeColor={status === ProcessingStatus.Failed ? '#ff4d4f' : undefined} + strokeColor={status === TaskExecutionStatus.Failed ? '#ff4d4f' : undefined} // 使用数字枚举成员 /> diff --git a/Web/src/components/image/ImageInfo.css b/Web/src/components/image/ImageInfo.css new file mode 100644 index 0000000..db7abef --- /dev/null +++ b/Web/src/components/image/ImageInfo.css @@ -0,0 +1,205 @@ +/* 抽屉基础样式 */ +.imageinfo-drawer { + position: fixed; + top: 0; + right: 0; + width: 350px; + height: 100vh; + z-index: 1050; + background-color: rgba(28, 30, 34, 0.5); + backdrop-filter: blur(24px) saturate(180%); + -webkit-backdrop-filter: blur(24px) saturate(180%); + border: none; + box-shadow: -10px 0 30px rgba(0, 0, 0, 0.2); + transition: transform 0.3s ease; + transform: translateX(100%); + overflow-y: auto; +} +.imageinfo-drawer-visible { + transform: translateX(0); +} + +.imageinfo-header { + background-color: rgba(28, 30, 34, 0.6); + backdrop-filter: blur(24px) saturate(180%); + -webkit-backdrop-filter: blur(24px) saturate(180%); + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + color: rgba(255, 255, 255, 0.95); + padding: 16px 20px; + display: flex; + justify-content: space-between; + align-items: center; +} +.imageinfo-header-title { + color: rgba(255, 255, 255, 0.95); + margin: 0; + font-size: 16px; + font-weight: 500; +} +.imageinfo-close-btn { + background: transparent; + border: none; + color: rgba(255, 255, 255, 0.8); + cursor: pointer; + padding: 4px; + display: flex; + align-items: center; + justify-content: center; +} + +.imageinfo-body { + padding: 24px 20px; + height: calc(100% - 57px); + scrollbar-width: none; + overflow-y: auto; + color: white; +} + +/* 标题样式 */ +.imageinfo-title-container { + padding: 0 0 16px 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + margin-bottom: 20px; +} +.imageinfo-title { + color: rgba(255, 255, 255, 0.95); + margin: 0 0 4px 0; + font-size: 18px; + font-weight: 500; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); +} +.imageinfo-date { + color: rgba(255, 255, 255, 0.6); + font-size: 13px; +} + +/* 描述区域 */ +.imageinfo-desc-section { + background-color: rgba(255, 255, 255, 0.08); + backdrop-filter: blur(10px) saturate(180%); + -webkit-backdrop-filter: blur(10px) saturate(180%); + padding: 16px; + border-radius: 8px; + margin-bottom: 20px; + border: 1px solid rgba(255, 255, 255, 0.1); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} +.imageinfo-desc-text { + color: rgba(255, 255, 255, 0.9); + line-height: 1.6; + display: -webkit-box; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + -webkit-line-clamp: 8; +} +.imageinfo-desc-text-expand { + -webkit-line-clamp: unset; +} +.imageinfo-expand-btn { + background: transparent; + border: none; + color: rgba(255, 255, 255, 0.7); + cursor: pointer; + padding: 8px 0 0 0; + font-size: 13px; + display: flex; + align-items: center; + width: 100%; + justify-content: center; +} + +/* 标签区域 */ +.imageinfo-tags-section { + margin-bottom: 20px; + padding: 0 4px; +} +.imageinfo-tag-title { + color: rgba(255, 255, 255, 0.8); + font-size: 14px; + font-weight: 500; + margin-bottom: 12px; +} +.imageinfo-tag-item { + background-color: rgba(255, 255, 255, 0.15); + border-radius: 16px; + color: rgba(255, 255, 255, 0.9); + border: none; + padding: 4px 12px; + margin: 0 8px 8px 0; + display: inline-block; + font-size: 12px; +} + +/* 规格信息区 */ +.imageinfo-specs-section { + background-color: rgba(255, 255, 255, 0.08); + backdrop-filter: blur(10px) saturate(180%); + -webkit-backdrop-filter: blur(10px) saturate(180%); + padding: 16px; + border-radius: 8px; + margin: 16px 0 20px; + border: 1px solid rgba(255, 255, 255, 0.1); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} +.imageinfo-specs-container { + display: flex; + justify-content: space-around; + text-align: center; +} +.imageinfo-spec-item { + padding: 0 8px; + flex: 1; +} +.imageinfo-spec-value { + font-size: 15px; + font-weight: 500; + color: rgba(255, 255, 255, 0.95); + margin-bottom: 4px; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); +} +.imageinfo-spec-label { + font-size: 12px; + color: rgba(255, 255, 255, 0.6); +} + +/* EXIF信息区 */ +.imageinfo-exif-container { + margin-top: 10px; +} +.imageinfo-exif-category { + margin-bottom: 20px; +} +.imageinfo-exif-divider { + border-color: rgba(255, 255, 255, 0.08) !important; + margin: 10px 0 16px !important; + font-size: 14px !important; + color: rgba(255, 255, 255, 0.8) !important; + font-weight: 500 !important; +} +.imageinfo-exif-table { + background-color: rgba(255, 255, 255, 0.08); + border-radius: 8px; + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.1); +} +.imageinfo-exif-row { + display: flex; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); +} +.imageinfo-exif-label { + color: rgba(255, 255, 255, 0.7); + background-color: rgba(0, 0, 0, 0.2); + padding: 8px 12px; + width: 100px; + font-size: 13px; +} +.imageinfo-exif-value { + color: rgba(255, 255, 255, 0.9); + background-color: rgba(255, 255, 255, 0.05); + padding: 8px 12px; + font-size: 13px; +} +.imageinfo-exif-empty { + color: rgba(255, 255, 255, 0.6); +} diff --git a/Web/src/components/image/ImageInfo.tsx b/Web/src/components/image/ImageInfo.tsx index a9ecd8a..9dd1a73 100644 --- a/Web/src/components/image/ImageInfo.tsx +++ b/Web/src/components/image/ImageInfo.tsx @@ -3,6 +3,7 @@ import { Divider } from 'antd'; import { CloseOutlined, DownOutlined, UpOutlined } from '@ant-design/icons'; import type { PictureResponse } from '../../api'; import './ImageViewer.css'; +import './ImageInfo.css'; interface ImageInfoProps { image: PictureResponse; @@ -25,7 +26,7 @@ const ImageInfo: React.FC = ({ // 格式化EXIF数据 const formatExifInfo = (exifInfo: any) => { if (!exifInfo) return []; - + // 定义EXIF信息分类 const categories = { basic: { title: "基本信息", items: [] as any[] }, @@ -34,18 +35,18 @@ const ImageInfo: React.FC = ({ time: { title: "时间信息", items: [] as any[] }, location: { title: "位置信息", items: [] as any[] } }; - + // 将EXIF信息映射到对应字段 const exifMapping: Record string }> = { // 基本信息 width: { key: "width", category: "basic", formatter: (v) => `${v}px` }, height: { key: "height", category: "basic", formatter: (v) => `${v}px` }, - + // 相机信息 cameraMaker: { key: "make", category: "camera" }, cameraModel: { key: "model", category: "camera" }, software: { key: "software", category: "camera" }, - + // 拍摄参数 exposureTime: { key: "exposureTime", category: "settings" }, aperture: { key: "fNumber", category: "settings", formatter: (v) => `f/${v}` }, @@ -54,9 +55,9 @@ const ImageInfo: React.FC = ({ flash: { key: "flash", category: "settings" }, meteringMode: { key: "meteringMode", category: "settings" }, whiteBalance: { key: "whiteBalance", category: "settings" }, - dateTimeOriginal: { - key: "dateTime", - category: "time", + dateTimeOriginal: { + key: "dateTime", + category: "time", formatter: (v) => { if (typeof v === 'string' && v.match(/^\d{4}:\d{2}:\d{2} \d{2}:\d{2}:\d{2}$/)) { const normalized = v.replace(/^(\d{4}):(\d{2}):(\d{2})/, '$1-$2-$3'); @@ -65,24 +66,24 @@ const ImageInfo: React.FC = ({ return date.toLocaleString(); } } - return v.toString(); + return v.toString(); } }, - + // 位置信息 gpsLatitude: { key: "latitude", category: "location" }, gpsLongitude: { key: "longitude", category: "location" } }; - + // 处理每个EXIF字段 Object.entries(exifInfo).forEach(([key, value]) => { if (value === null || value === undefined || value === '') return; - + const mapping = exifMapping[key]; if (mapping) { const formattedValue = mapping.formatter ? mapping.formatter(value) : value.toString(); const label = formatExifLabel(mapping.key); - + categories[mapping.category].items.push({ key: mapping.key, label, @@ -90,7 +91,7 @@ const ImageInfo: React.FC = ({ }); } }); - + // 返回包含数据的分类 return Object.values(categories).filter(category => category.items.length > 0); }; @@ -101,12 +102,12 @@ const ImageInfo: React.FC = ({ // 基本信息 width: "宽度", height: "高度", - + // 相机信息 make: "相机品牌", model: "相机型号", software: "软件", - + // 拍摄参数 exposureTime: "曝光时间", fNumber: "光圈值", @@ -115,43 +116,43 @@ const ImageInfo: React.FC = ({ flash: "闪光灯", meteringMode: "测光模式", whiteBalance: "白平衡", - + // 时间信息 dateTime: "拍摄时间", - + // 位置信息 latitude: "纬度", longitude: "经度" }; - + return labels[key] || key.charAt(0).toUpperCase() + key.slice(1).replace(/([A-Z])/g, ' $1'); }; // 渲染EXIF信息 - const renderExifInfo = (styles: any) => { - if (!image?.exifInfo) return
无EXIF信息
; - + const renderExifInfo = () => { + if (!image?.exifInfo) return
无EXIF信息
; + const formattedCategories = formatExifInfo(image.exifInfo); - + if (formattedCategories.length === 0) { - return
无EXIF信息
; + return
无EXIF信息
; } - + return ( -
+
{formattedCategories.map(category => ( -
+
{category.title} -
+
{category.items.map(item => ( -
-
{item.label}
-
{item.value}
+
+
{item.label}
+
{item.value}
))}
@@ -161,211 +162,31 @@ const ImageInfo: React.FC = ({ ); }; - // 定义内联样式对象 - const styles = { - // 抽屉基础样式 - drawer: { - position: 'fixed' as const, - top: 0, - right: 0, - width: '350px', - height: '100%', - zIndex: 1050, - backgroundColor: 'rgba(28, 30, 34, 0.5)', - backdropFilter: 'blur(24px) saturate(180%)', - WebkitBackdropFilter: 'blur(24px) saturate(180%)', - border: 'none', - boxShadow: '-10px 0 30px rgba(0, 0, 0, 0.2)', - transition: 'transform 0.3s ease', - transform: visible ? 'translateX(0)' : 'translateX(100%)', - overflowY: 'auto' as const - }, - header: { - backgroundColor: 'rgba(28, 30, 34, 0.6)', - backdropFilter: 'blur(24px) saturate(180%)', - WebkitBackdropFilter: 'blur(24px) saturate(180%)', - borderBottom: '1px solid rgba(255, 255, 255, 0.08)', - color: 'rgba(255, 255, 255, 0.95)', - padding: '16px 20px', - display: 'flex', - justifyContent: 'space-between', - alignItems: 'center' - }, - headerTitle: { - color: 'rgba(255, 255, 255, 0.95)', - margin: 0, - fontSize: '16px', - fontWeight: 500 - }, - closeButton: { - background: 'transparent', - border: 'none', - color: 'rgba(255, 255, 255, 0.8)', - cursor: 'pointer', - padding: '4px', - display: 'flex', - alignItems: 'center', - justifyContent: 'center' - }, - body: { - padding: '24px 20px', - color: 'white' - }, - // 标题样式 - titleContainer: { - padding: '0 0 16px 0', - borderBottom: '1px solid rgba(255, 255, 255, 0.08)', - marginBottom: '20px' - }, - title: { - color: 'rgba(255, 255, 255, 0.95)', - margin: '0 0 4px 0', - fontSize: '18px', - fontWeight: 500, - textShadow: '0 1px 2px rgba(0, 0, 0, 0.1)' - }, - date: { - color: 'rgba(255, 255, 255, 0.6)', - fontSize: '13px' - }, - // 描述区域 - descSection: { - backgroundColor: 'rgba(255, 255, 255, 0.08)', - backdropFilter: 'blur(10px) saturate(180%)', - WebkitBackdropFilter: 'blur(10px) saturate(180%)', - padding: '16px', - borderRadius: '8px', - marginBottom: '20px', - border: '1px solid rgba(255, 255, 255, 0.1)', - boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)' - }, - descText: { - color: 'rgba(255, 255, 255, 0.9)', - lineHeight: '1.6', - display: '-webkit-box', - WebkitBoxOrient: 'vertical' as const, - overflow: 'hidden', - textOverflow: 'ellipsis', - WebkitLineClamp: expandDescription ? 'unset' : 8 - }, - expandButton: { - background: 'transparent', - border: 'none', - color: 'rgba(255, 255, 255, 0.7)', - cursor: 'pointer', - padding: '8px 0 0 0', - fontSize: '13px', - display: 'flex', - alignItems: 'center', - width: '100%', - justifyContent: 'center' - }, - // 标签区域 - tagsSection: { - marginBottom: '20px', - padding: '0 4px' - }, - tagTitle: { - color: 'rgba(255, 255, 255, 0.8)', - fontSize: '14px', - fontWeight: 500, - marginBottom: '12px' - }, - tagItem: { - backgroundColor: 'rgba(255, 255, 255, 0.15)', - borderRadius: '16px', - color: 'rgba(255, 255, 255, 0.9)', - border: 'none', - padding: '4px 12px', - margin: '0 8px 8px 0', - display: 'inline-block', - fontSize: '12px' - }, - // 规格信息区 - specsSection: { - backgroundColor: 'rgba(255, 255, 255, 0.08)', - backdropFilter: 'blur(10px) saturate(180%)', - WebkitBackdropFilter: 'blur(10px) saturate(180%)', - padding: '16px', - borderRadius: '8px', - margin: '16px 0 20px', - border: '1px solid rgba(255, 255, 255, 0.1)', - boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)' - }, - specsContainer: { - display: 'flex', - justifyContent: 'space-around', - textAlign: 'center' as const - }, - specItem: { - padding: '0 8px', - flex: 1 - }, - specValue: { - fontSize: '15px', - fontWeight: 500, - color: 'rgba(255, 255, 255, 0.95)', - marginBottom: '4px', - textShadow: '0 1px 2px rgba(0, 0, 0, 0.1)' - }, - specLabel: { - fontSize: '12px', - color: 'rgba(255, 255, 255, 0.6)' - }, - // EXIF信息区 - exifContainer: { - marginTop: '10px' - }, - exifCategory: { - marginBottom: '20px' - }, - divider: { - borderColor: 'rgba(255, 255, 255, 0.08)', - margin: '10px 0 16px', - fontSize: '14px', - color: 'rgba(255, 255, 255, 0.8)', - fontWeight: 500 - }, - exifTable: { - backgroundColor: 'rgba(255, 255, 255, 0.08)', - borderRadius: '8px', - overflow: 'hidden', - border: '1px solid rgba(255, 255, 255, 0.1)' - }, - exifLabel: { - color: 'rgba(255, 255, 255, 0.7)', - backgroundColor: 'rgba(0, 0, 0, 0.2)', - padding: '8px 12px', - width: '100px', - fontSize: '13px' - }, - exifValue: { - color: 'rgba(255, 255, 255, 0.9)', - backgroundColor: 'rgba(255, 255, 255, 0.05)', - padding: '8px 12px', - fontSize: '13px' - } - }; - return ( -
-
-

图片信息

-
-
-
-

{image?.name}

-
上传于{new Date(image?.createdAt).toLocaleString()}
+
+
+

{image?.name}

+
上传于{new Date(image?.createdAt).toLocaleString()}
- + {image?.description && ( -
-
{image.description}
+
+
+ {image.description} +
{image.description.split('\n').length > 8 || image.description.length > 200 ? ( -
)} - + {image?.tags && image.tags.length > 0 && ( -
-
标签
+
+
标签
{image.tags.map(tag => ( - #{tag} + #{tag} ))}
)} - + {image?.exifInfo && ( -
-
-
-
{image.exifInfo.width}×{image.exifInfo.height}
-
分辨率
+
+
+
+
{image.exifInfo.width}×{image.exifInfo.height}
+
分辨率
{image.exifInfo.focalLength && ( -
-
{image.exifInfo.focalLength}
-
焦距
+
+
{image.exifInfo.focalLength}
+
焦距
)}
)} - + {/* 渲染EXIF信息 */} - {renderExifInfo(styles)} + {renderExifInfo()}
); diff --git a/Web/src/pages/backgroundTasks/Index.tsx b/Web/src/pages/backgroundTasks/Index.tsx index 4a1b7cd..ac06c02 100644 --- a/Web/src/pages/backgroundTasks/Index.tsx +++ b/Web/src/pages/backgroundTasks/Index.tsx @@ -1,8 +1,8 @@ import React, { useState, useEffect, useCallback } from 'react'; import { Typography, Table, Card, Tag, Space, Button, Empty, message, Modal } from 'antd'; import { SyncOutlined, EyeOutlined } from '@ant-design/icons'; -import { getUserTasks } from '../../api'; -import { type PictureProcessingTask, ProcessingStatus } from '../../api'; +import { getUserTasks, TaskExecutionStatus } from '../../api'; +import { type TaskDetailsViewModel } from '../../api'; import TaskProgressBar from '../../components/TaskProgressBar'; import dayjs from 'dayjs'; import { Link } from 'react-router'; @@ -10,8 +10,16 @@ import type { ColumnType } from 'antd/es/table'; const { Title, Text } = Typography; +// 定义任务类型映射 +const taskTypeDisplayMapping: { [key: number]: string } = { + 0: '图片处理', // 对应后端的 PictureProcessing = 0 + // 如果有其他任务类型,在此处添加 + // 1: '视频处理', + // 2: '数据导出', +}; + const BackgroundTasks: React.FC = () => { - const [tasks, setTasks] = useState([]); + const [tasks, setTasks] = useState([]); // Updated type const [loading, setLoading] = useState(true); const [pollingActive, setPollingActive] = useState(true); const [pollingInterval, setPollingIntervalState] = useState(null); @@ -40,61 +48,63 @@ const BackgroundTasks: React.FC = () => { // 设置轮询 if (pollingActive) { - const interval = setInterval(fetchTasks, 3000); - setPollingIntervalState(interval as unknown as number); + const interval = setInterval(fetchTasks, 3000); // 轮询间隔调整为3秒 + setPollingIntervalState(interval as unknown as number); // 保存 interval ID } return () => { if (pollingInterval) { - clearInterval(pollingInterval); + clearInterval(pollingInterval); // 清除 interval } }; - }, [fetchTasks, pollingActive]); + }, [fetchTasks, pollingActive]); // 依赖项中移除 pollingInterval // 检查是否有活跃的任务,如果没有则停止轮询 useEffect(() => { const hasActiveTasks = tasks.some( - task => task.status === ProcessingStatus.Pending || task.status === ProcessingStatus.Processing + task => task.status === TaskExecutionStatus.Pending || task.status === TaskExecutionStatus.Processing // 使用数字枚举成员 ); - + if (!hasActiveTasks && pollingActive) { setPollingActive(false); if (pollingInterval) { clearInterval(pollingInterval); setPollingIntervalState(null); } - } else if (hasActiveTasks && !pollingActive) { + } else if (hasActiveTasks && !pollingActive && tasks.length > 0) { // 确保有任务才重新激活轮询 setPollingActive(true); - const interval = setInterval(fetchTasks, 3000); - setPollingIntervalState(interval as unknown as number); + // 不需要在这里重新创建 interval,上面的 useEffect 会处理 } - }, [tasks, pollingActive, pollingInterval, fetchTasks]); + }, [tasks, pollingActive, pollingInterval, fetchTasks]); // 保持依赖项 // 渲染状态标签 - const renderStatus = (status: ProcessingStatus) => { + const renderStatus = (status: TaskExecutionStatus) => { // status 现在是数字 let color = ''; let text = ''; let icon = null; switch (status) { - case ProcessingStatus.Pending: + case TaskExecutionStatus.Pending: // 使用数字枚举成员 color = 'orange'; text = '等待中'; icon = ; break; - case ProcessingStatus.Processing: + case TaskExecutionStatus.Processing: // 使用数字枚举成员 color = 'processing'; text = '处理中'; icon = ; break; - case ProcessingStatus.Completed: + case TaskExecutionStatus.Completed: // 使用数字枚举成员 color = 'success'; text = '已完成'; break; - case ProcessingStatus.Failed: + case TaskExecutionStatus.Failed: // 使用数字枚举成员 color = 'error'; text = '失败'; break; + default: + text = `未知状态 (${status})`; + break; } return {text}; @@ -115,40 +125,49 @@ const BackgroundTasks: React.FC = () => { }; // 表格列定义 - const columns: ColumnType[] = [ + const columns: ColumnType[] = [ // Updated type { - title: '图片名称', - dataIndex: 'pictureName', - key: 'pictureName', - render: (text: string, record: PictureProcessingTask) => ( - {text} + title: '任务名称', // Changed title + dataIndex: 'taskName', // Changed dataIndex + key: 'taskName', + render: (text: string, record: TaskDetailsViewModel) => ( // Updated type and logic + record.taskType === 0 && record.relatedEntityId // 修正: 使用数字 0 比较 + ? {text} + : text ), }, { title: '状态', dataIndex: 'status', key: 'status', - render: (status: ProcessingStatus) => renderStatus(status), + render: (status: TaskExecutionStatus) => renderStatus(status), // status 现在是数字 filters: [ - { text: '等待中', value: ProcessingStatus.Pending }, - { text: '处理中', value: ProcessingStatus.Processing }, - { text: '已完成', value: ProcessingStatus.Completed }, - { text: '失败', value: ProcessingStatus.Failed }, + { text: '等待中', value: TaskExecutionStatus.Pending }, // 使用数字枚举成员 + { text: '处理中', value: TaskExecutionStatus.Processing }, // 使用数字枚举成员 + { text: '已完成', value: TaskExecutionStatus.Completed }, // 使用数字枚举成员 + { text: '失败', value: TaskExecutionStatus.Failed }, // 使用数字枚举成员 ], - onFilter: (value, record: PictureProcessingTask) => - record.status === value.toString(), + onFilter: (value, record: TaskDetailsViewModel) => + record.status === (value as TaskExecutionStatus), // value 已经是数字 + }, + { + title: '任务类型', + dataIndex: 'taskType', + key: 'taskType', + render: (taskType: number | undefined) => // 接收数字类型的 taskType + taskType !== undefined ? taskTypeDisplayMapping[taskType] || `未知类型 (${taskType})` : '-', }, { title: '进度', dataIndex: 'progress', key: 'progress', - render: (progress: number, record: PictureProcessingTask) => ( - ( // Updated type + ), @@ -158,29 +177,31 @@ const BackgroundTasks: React.FC = () => { dataIndex: 'createdAt', key: 'createdAt', render: (date: Date) => formatDate(date), - sorter: (a: PictureProcessingTask, b: PictureProcessingTask) => + sorter: (a: TaskDetailsViewModel, b: TaskDetailsViewModel) => // Updated type new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), }, { title: '完成时间', dataIndex: 'completedAt', key: 'completedAt', - render: (date: Date) => formatDate(date), + render: (date: Date | undefined) => formatDate(date), // Ensure date can be undefined }, { title: '操作', key: 'action', - render: (_: any, record: PictureProcessingTask) => ( + render: (_: any, record: TaskDetailsViewModel) => ( // Updated type - - - - {record.status === ProcessingStatus.Failed && record.error && ( - + + )} + {record.status === TaskExecutionStatus.Failed && record.error && ( // 使用数字枚举成员 +
- + {tasks.length > 0 ? ( - ) : ( -