mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-09-05 07:36:53 +08:00
feat(task-processor): implement PictureTaskProcessor for background image processing tasks
This commit is contained in:
@@ -17,37 +17,20 @@ public class BackgroundTaskController : BaseApiController
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("user-tasks")]
|
[HttpGet("user-tasks")]
|
||||||
public async Task<ActionResult<BaseResult<List<PictureProcessingStatus>>>> GetUserTasks()
|
public async Task<ActionResult<BaseResult<List<TaskDetailsDto>>>> GetUserTasks()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var userId = GetCurrentUserId();
|
var userId = GetCurrentUserId();
|
||||||
if (userId == null)
|
if (userId == null)
|
||||||
return Error<List<PictureProcessingStatus>>("无法识别用户信息", 401);
|
return Error<List<TaskDetailsDto>>("无法识别用户信息", 401);
|
||||||
|
|
||||||
var tasks = await _backgroundTaskQueue.GetUserTasksStatusAsync(userId.Value);
|
var tasks = await _backgroundTaskQueue.GetUserTasksStatusAsync(userId.Value);
|
||||||
return Success(tasks, "成功获取任务列表");
|
return Success(tasks, "成功获取任务列表");
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
return Error<List<PictureProcessingStatus>>($"获取任务状态失败: {ex.Message}", 500);
|
return Error<List<TaskDetailsDto>>($"获取任务状态失败: {ex.Message}", 500);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpGet("picture-status/{pictureId}")]
|
|
||||||
public async Task<ActionResult<BaseResult<PictureProcessingStatus>>> GetPictureStatus(int pictureId)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var status = await _backgroundTaskQueue.GetPictureProcessingStatusAsync(pictureId);
|
|
||||||
if (status == null)
|
|
||||||
return Error<PictureProcessingStatus>("找不到该图片的处理状态", 404);
|
|
||||||
|
|
||||||
return Success(status, "成功获取图片处理状态");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return Error<PictureProcessingStatus>($"获取图片处理状态失败: {ex.Message}", 500);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ using Foxel.Services.Media;
|
|||||||
using Foxel.Services.Storage;
|
using Foxel.Services.Storage;
|
||||||
using Foxel.Services.Storage.Providers;
|
using Foxel.Services.Storage.Providers;
|
||||||
using Foxel.Services.VectorDB;
|
using Foxel.Services.VectorDB;
|
||||||
|
using Foxel.Services.Background.Processors;
|
||||||
|
|
||||||
namespace Foxel.Extensions;
|
namespace Foxel.Extensions;
|
||||||
|
|
||||||
@@ -38,6 +39,7 @@ public static class ServiceCollectionExtensions
|
|||||||
services.AddSingleton<CosStorageProvider>();
|
services.AddSingleton<CosStorageProvider>();
|
||||||
services.AddSingleton<WebDavStorageProvider>();
|
services.AddSingleton<WebDavStorageProvider>();
|
||||||
services.AddSingleton<IStorageService, StorageService>();
|
services.AddSingleton<IStorageService, StorageService>();
|
||||||
|
services.AddSingleton<PictureTaskProcessor>();
|
||||||
services.AddSingleton<IDatabaseInitializer, DatabaseInitializer>();
|
services.AddSingleton<IDatabaseInitializer, DatabaseInitializer>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Folder Include="Uploads\" />
|
<Folder Include="Uploads\" />
|
||||||
|
<Folder Include="Uploads\2025\" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,10 +41,6 @@ public class Picture : BaseModel
|
|||||||
|
|
||||||
public bool ContentWarning { get; set; } = false;
|
public bool ContentWarning { get; set; } = false;
|
||||||
public PermissionType Permission { get; set; } = PermissionType.Public;
|
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
|
public enum PermissionType
|
||||||
@@ -53,11 +49,3 @@ public enum PermissionType
|
|||||||
Friends = 1,
|
Friends = 1,
|
||||||
Private = 2
|
Private = 2
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum ProcessingStatus
|
|
||||||
{
|
|
||||||
Pending, // 等待处理
|
|
||||||
Processing, // 处理中
|
|
||||||
Completed, // 处理完成
|
|
||||||
Failed // 处理失败
|
|
||||||
}
|
|
||||||
@@ -20,7 +20,4 @@ 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 ProcessingStatus ProcessingStatus { get; set; }
|
|
||||||
public string? ProcessingError { get; set; }
|
|
||||||
public int ProcessingProgress { get; set; }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,4 +13,5 @@ public class MyDbContext(DbContextOptions<MyDbContext> options) : DbContext(opti
|
|||||||
public DbSet<Album> Albums { get; set; } = null!;
|
public DbSet<Album> Albums { get; set; } = null!;
|
||||||
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!;
|
||||||
}
|
}
|
||||||
@@ -65,8 +65,7 @@ public class AiService(IHttpClientFactory httpClientFactory, IConfigService conf
|
|||||||
var textContent = new TextContent
|
var textContent = new TextContent
|
||||||
{
|
{
|
||||||
Type = "text",
|
Type = "text",
|
||||||
Text = configService["AI:ImageAnalysisPrompt"] ??
|
Text = configService["AI:ImageAnalysisPrompt"]
|
||||||
"请详细分析这张图片,并提供全面的描述,以便用于向量嵌入和基于文本的图像搜索。描述需要包含:主体对象、场景环境、色彩特点、构图布局、风格特征、情绪氛围、细节特征等关键元素。请提供一个简短有力的标题,然后提供详细描述。\n\n请以JSON格式返回,格式如下:\n{\"title\": \"简短概括图片的核心内容\", \"description\": \"全面详细的描述,包含上述所有元素,使用丰富精确的词汇,避免笼统表达\"}\n\n请确保返回有效的JSON格式。"
|
|
||||||
};
|
};
|
||||||
|
|
||||||
var message = new ChatMessage
|
var message = new ChatMessage
|
||||||
@@ -117,8 +116,7 @@ public class AiService(IHttpClientFactory httpClientFactory, IConfigService conf
|
|||||||
string model = configService["AI:Model"];
|
string model = configService["AI:Model"];
|
||||||
var tagsText = string.Join(", ", availableTags);
|
var tagsText = string.Join(", ", availableTags);
|
||||||
|
|
||||||
string promptTemplate = configService["AI:TagMatchingPrompt"] ??
|
string promptTemplate = configService["AI:TagMatchingPrompt"];
|
||||||
"以下是一组标签:[{tagsText}]。\n\n请从这些标签中严格选择与下面描述内容高度相关的标签(最多选择5个)。只选择确实匹配的标签,如果找不到完全匹配或高度相关的标签,宁可返回空数组也不要选择不太相关的标签。\n\n描述内容:{description}\n\n请以JSON格式返回,格式如下:\n{{\"tags\": [\"标签1\", \"标签2\", \"标签3\"]}}\n\n请确保返回有效的JSON格式前面不要加```,并且只包含确实匹配的标签名称。";
|
|
||||||
|
|
||||||
// 替换占位符
|
// 替换占位符
|
||||||
string promptText = promptTemplate
|
string promptText = promptTemplate
|
||||||
@@ -246,8 +244,7 @@ public class AiService(IHttpClientFactory httpClientFactory, IConfigService conf
|
|||||||
if (allowNewTags)
|
if (allowNewTags)
|
||||||
{
|
{
|
||||||
// 获取配置的标签生成提示词,如果没有则使用默认提示词
|
// 获取配置的标签生成提示词,如果没有则使用默认提示词
|
||||||
string defaultPrompt = configService["AI:TagGenerationPrompt"] ??
|
string defaultPrompt = configService["AI:TagGenerationPrompt"];
|
||||||
"请为图片生成5个最相关的标签,每个标签应该是简短且描述性的词语或短语。\n\n请以JSON格式返回,格式如下:\n{\"tags\": [\"标签1\", \"标签2\", \"标签3\", \"标签4\", \"标签5\"]}\n\n请确保返回有效的JSON格式。";
|
|
||||||
|
|
||||||
// 如果允许新标签,则提供现有标签作为参考,但允许生成新标签
|
// 如果允许新标签,则提供现有标签作为参考,但允许生成新标签
|
||||||
promptText = availableTags.Count > 0
|
promptText = availableTags.Count > 0
|
||||||
@@ -261,8 +258,7 @@ public class AiService(IHttpClientFactory httpClientFactory, IConfigService conf
|
|||||||
return new List<string>();
|
return new List<string>();
|
||||||
|
|
||||||
var tagsText = string.Join(", ", availableTags);
|
var tagsText = string.Join(", ", availableTags);
|
||||||
string templatePrompt = configService["AI:TagMatchingPrompt"] ??
|
string templatePrompt = configService["AI:TagMatchingPrompt"];
|
||||||
"以下是一组标签:[{tagsText}]。\n\n请从这些标签中严格选择与图片内容高度相关的标签(最多选择5个)。只选择确实匹配的标签,如果找不到完全匹配或高度相关的标签,宁可返回空数组也不要选择不太相关的标签。\n\n请以JSON格式返回,格式如下:\n{{\"tags\": [\"标签1\", \"标签2\", \"标签3\"]}}\n\n请确保返回有效的JSON格式,并且只包含上述列表中的标签名称。";
|
|
||||||
|
|
||||||
promptText = templatePrompt.Replace("{tagsText}", tagsText);
|
promptText = templatePrompt.Replace("{tagsText}", tagsText);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,16 @@
|
|||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
|
using System.Text.Json;
|
||||||
using System.Threading.Channels;
|
using System.Threading.Channels;
|
||||||
using Foxel.Models.DataBase;
|
using Foxel.Models.DataBase;
|
||||||
using Foxel.Services.AI;
|
|
||||||
using Foxel.Services.Attributes;
|
|
||||||
using Foxel.Services.Configuration;
|
using Foxel.Services.Configuration;
|
||||||
using Foxel.Services.Storage;
|
using Foxel.Services.Background.Processors;
|
||||||
using Foxel.Services.VectorDB;
|
|
||||||
using Foxel.Utils;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace Foxel.Services.Background;
|
namespace Foxel.Services.Background;
|
||||||
|
|
||||||
public sealed class BackgroundTaskQueue : IBackgroundTaskQueue, IDisposable
|
public sealed class BackgroundTaskQueue : IBackgroundTaskQueue, IDisposable
|
||||||
{
|
{
|
||||||
private readonly Channel<PictureProcessingTask> _queue;
|
private readonly Channel<Guid> _queue;
|
||||||
private readonly ConcurrentDictionary<Guid, PictureProcessingTask> _activeTasks;
|
|
||||||
private readonly ConcurrentDictionary<int, PictureProcessingStatus> _pictureStatus;
|
|
||||||
private readonly IServiceProvider _serviceProvider;
|
private readonly IServiceProvider _serviceProvider;
|
||||||
private readonly IDbContextFactory<MyDbContext> _contextFactory;
|
private readonly IDbContextFactory<MyDbContext> _contextFactory;
|
||||||
private readonly List<Task> _processingTasks;
|
private readonly List<Task> _processingTasks;
|
||||||
@@ -30,83 +25,129 @@ public sealed class BackgroundTaskQueue : IBackgroundTaskQueue, IDisposable
|
|||||||
IConfigService configuration,
|
IConfigService configuration,
|
||||||
ILogger<BackgroundTaskQueue> logger)
|
ILogger<BackgroundTaskQueue> logger)
|
||||||
{
|
{
|
||||||
_serviceProvider = serviceProvider;
|
_serviceProvider = serviceProvider; // Keep IServiceProvider to resolve processors
|
||||||
_contextFactory = contextFactory;
|
_contextFactory = contextFactory;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_activeTasks = new ConcurrentDictionary<Guid, PictureProcessingTask>();
|
|
||||||
_pictureStatus = new ConcurrentDictionary<int, PictureProcessingStatus>();
|
|
||||||
_processingTasks = new List<Task>();
|
_processingTasks = new List<Task>();
|
||||||
_maxConcurrentTasks = configuration.GetValueAsync("BackgroundTasks:MaxConcurrentTasks", 10).Result;
|
_maxConcurrentTasks = configuration.GetValueAsync("BackgroundTasks:MaxConcurrentTasks", 10).Result; // 保持原有逻辑
|
||||||
_signal = new SemaphoreSlim(_maxConcurrentTasks);
|
_signal = new SemaphoreSlim(_maxConcurrentTasks);
|
||||||
var options = new BoundedChannelOptions(10000)
|
var options = new BoundedChannelOptions(10000) // 保持原有逻辑
|
||||||
{
|
{
|
||||||
FullMode = BoundedChannelFullMode.Wait
|
FullMode = BoundedChannelFullMode.Wait
|
||||||
};
|
};
|
||||||
_queue = Channel.CreateBounded<PictureProcessingTask>(options);
|
_queue = Channel.CreateBounded<Guid>(options);
|
||||||
|
|
||||||
|
// 启动处理器,确保在服务启动时就开始处理队列
|
||||||
|
StartProcessor();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Guid> QueuePictureProcessingTaskAsync(int pictureId, string originalFilePath)
|
public async Task<Guid> 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();
|
await using var dbContext = await _contextFactory.CreateDbContextAsync();
|
||||||
var picture = await dbContext.Pictures
|
var picture = await dbContext.Pictures.FindAsync(pictureId);
|
||||||
.Include(p => p.User)
|
if (picture == null)
|
||||||
.FirstOrDefaultAsync(p => p.Id == pictureId);
|
|
||||||
|
|
||||||
if (picture != null)
|
|
||||||
{
|
{
|
||||||
status.PictureName = picture.Name;
|
_logger.LogError("无法为不存在的图片 PictureId: {PictureId} 创建处理任务", pictureId);
|
||||||
task.UserId = picture.UserId;
|
throw new KeyNotFoundException($"找不到 PictureId: {pictureId} 的图片");
|
||||||
}
|
}
|
||||||
|
|
||||||
_pictureStatus[pictureId] = status;
|
var payload = new PictureProcessingPayload
|
||||||
_activeTasks[task.Id] = task;
|
{
|
||||||
await _queue.Writer.WriteAsync(task);
|
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();
|
StartProcessor();
|
||||||
|
|
||||||
return task.Id;
|
return backgroundTask.Id;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<PictureProcessingStatus>> GetUserTasksStatusAsync(int userId)
|
public async Task<List<TaskDetailsDto>> GetUserTasksStatusAsync(int userId)
|
||||||
{
|
{
|
||||||
await using var dbContext = await _contextFactory.CreateDbContextAsync();
|
await using var dbContext = await _contextFactory.CreateDbContextAsync();
|
||||||
var userPictureIds = await dbContext.Pictures
|
var tasks = await dbContext.BackgroundTasks
|
||||||
.Where(p => p.UserId == userId &&
|
.Where(bt => bt.UserId == userId)
|
||||||
(p.ProcessingStatus == ProcessingStatus.Pending ||
|
.OrderByDescending(bt => bt.CreatedAt)
|
||||||
p.ProcessingStatus == ProcessingStatus.Processing))
|
|
||||||
.Select(p => p.Id)
|
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
return _pictureStatus.Values
|
var statusList = new List<TaskDetailsDto>();
|
||||||
.Where(s => userPictureIds.Contains(s.PictureId))
|
foreach (var task in tasks)
|
||||||
.OrderByDescending(s => s.CreatedAt)
|
{
|
||||||
.ToList();
|
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<PictureProcessingStatus?> GetPictureProcessingStatusAsync(int pictureId)
|
public async Task<TaskDetailsDto?> 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()
|
public async Task RestoreUnfinishedTasksAsync()
|
||||||
@@ -114,38 +155,27 @@ public sealed class BackgroundTaskQueue : IBackgroundTaskQueue, IDisposable
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
await using var dbContext = await _contextFactory.CreateDbContextAsync();
|
await using var dbContext = await _contextFactory.CreateDbContextAsync();
|
||||||
|
var unfinishedTasks = await dbContext.BackgroundTasks
|
||||||
// 获取所有未完成的图片处理任务
|
.Where(bt => bt.Type == TaskType.PictureProcessing &&
|
||||||
var unfinishedPictures = await dbContext.Pictures
|
(bt.Status == TaskExecutionStatus.Pending || bt.Status == TaskExecutionStatus.Processing))
|
||||||
.Where(p => p.ProcessingStatus == ProcessingStatus.Pending ||
|
|
||||||
p.ProcessingStatus == ProcessingStatus.Processing)
|
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
if (unfinishedPictures.Any())
|
if (unfinishedTasks.Any())
|
||||||
{
|
{
|
||||||
_logger.LogInformation("正在恢复 {Count} 个未完成的图片处理任务", unfinishedPictures.Count);
|
_logger.LogInformation("正在恢复 {Count} 个未完成的图片处理任务", unfinishedTasks.Count);
|
||||||
|
foreach (var task in unfinishedTasks)
|
||||||
foreach (var picture in unfinishedPictures)
|
|
||||||
{
|
{
|
||||||
// 构建原始文件路径
|
// 确保任务状态在数据库中被重置为 Pending,以防上次运行时停在 Processing 状态
|
||||||
string relativePath = picture.Path.TrimStart('/');
|
if (task.Status == TaskExecutionStatus.Processing)
|
||||||
string originalFilePath = Path.Combine(Directory.GetCurrentDirectory(), relativePath);
|
|
||||||
if (File.Exists(originalFilePath))
|
|
||||||
{
|
{
|
||||||
// 重新加入队列
|
task.Status = TaskExecutionStatus.Pending;
|
||||||
await QueuePictureProcessingTaskAsync(picture.Id, originalFilePath);
|
task.StartedAt = null; // 重置开始时间
|
||||||
_logger.LogInformation("已恢复图片处理任务: ID={PictureId}, 路径={FilePath}", picture.Id, originalFilePath);
|
// 保留 Progress 和 ErrorMessage 以供参考
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// 如果文件不存在,则标记为失败
|
|
||||||
picture.ProcessingStatus = ProcessingStatus.Failed;
|
|
||||||
picture.ProcessingError = "系统重启后找不到原始图片文件";
|
|
||||||
_logger.LogWarning("无法恢复图片处理任务: ID={PictureId}, 找不到文件: {FilePath}", picture.Id, originalFilePath);
|
|
||||||
}
|
}
|
||||||
|
await _queue.Writer.WriteAsync(task.Id);
|
||||||
|
_logger.LogInformation("已恢复图片处理任务到队列: TaskId={TaskId}, RelatedEntityId={RelatedEntityId}", task.Id, task.RelatedEntityId);
|
||||||
}
|
}
|
||||||
|
await dbContext.SaveChangesAsync(); // 保存状态更改
|
||||||
await dbContext.SaveChangesAsync();
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -160,342 +190,140 @@ public sealed class BackgroundTaskQueue : IBackgroundTaskQueue, IDisposable
|
|||||||
|
|
||||||
private void StartProcessor()
|
private void StartProcessor()
|
||||||
{
|
{
|
||||||
// 添加新的处理任务,如果当前任务数量小于最大并发数
|
lock (_processingTasks) // 确保线程安全地访问 _processingTasks
|
||||||
while (_processingTasks.Count(t => !t.IsCompleted) < _maxConcurrentTasks)
|
|
||||||
{
|
{
|
||||||
_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()
|
private async Task ProcessTasksAsync()
|
||||||
{
|
{
|
||||||
while (await _queue.Reader.WaitToReadAsync())
|
while (await _queue.Reader.WaitToReadAsync())
|
||||||
{
|
{
|
||||||
await _signal.WaitAsync();
|
if (_queue.Reader.TryRead(out var taskId))
|
||||||
|
|
||||||
try
|
|
||||||
{
|
{
|
||||||
if (_queue.Reader.TryRead(out var task))
|
await _signal.WaitAsync();
|
||||||
|
try
|
||||||
{
|
{
|
||||||
await ProcessPictureAsync(task);
|
await using var checkDbContext = await _contextFactory.CreateDbContextAsync();
|
||||||
}
|
var taskToCheck = await checkDbContext.BackgroundTasks.FindAsync(taskId);
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_signal.Release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task ProcessPictureAsync(PictureProcessingTask task)
|
if (taskToCheck == null)
|
||||||
{
|
|
||||||
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<IAiService>();
|
|
||||||
var storageService = scope.ServiceProvider.GetRequiredService<IStorageService>();
|
|
||||||
|
|
||||||
// 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<IVectorDbService>();
|
|
||||||
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<Tag>();
|
|
||||||
foreach (var tagName in matchedTagNames)
|
|
||||||
{
|
|
||||||
var existingTag = await dbContext.Tags
|
|
||||||
.Include(t => t.Users)
|
|
||||||
.FirstOrDefaultAsync(t => t.Name.ToLower() == tagName.ToLower());
|
|
||||||
|
|
||||||
if (existingTag != null)
|
|
||||||
{
|
{
|
||||||
tagEntities.Add(existingTag);
|
_logger.LogWarning("任务 TaskId={TaskId} 在开始处理前未找到,可能已被删除。", taskId);
|
||||||
user.Tags ??= new List<Tag>();
|
continue; // Skip this task
|
||||||
if (user.Tags.All(t => t.Id != existingTag.Id))
|
}
|
||||||
|
|
||||||
|
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<PictureTaskProcessor>();
|
||||||
|
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() };
|
_logger.LogError(procEx, "处理器执行任务 TaskId={TaskId} 时发生错误。", taskToCheck.Id);
|
||||||
dbContext.Tags.Add(newTag);
|
await MarkTaskAsFailedByQueue(taskToCheck.Id, $"处理器执行时发生错误: {procEx.Message}");
|
||||||
await dbContext.SaveChangesAsync();
|
|
||||||
user.Tags ??= new List<Tag>();
|
|
||||||
user.Tags.Add(newTag);
|
|
||||||
tagEntities.Add(newTag);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
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();
|
await using var dbContext = await _contextFactory.CreateDbContextAsync();
|
||||||
var picture = await dbContext.Pictures.FindAsync(pictureId);
|
var task = await dbContext.BackgroundTasks.FindAsync(taskId);
|
||||||
if (picture != null)
|
if (task != null)
|
||||||
{
|
{
|
||||||
picture.ProcessingStatus = status;
|
task.Status = TaskExecutionStatus.Failed;
|
||||||
picture.ProcessingProgress = progress;
|
task.ErrorMessage = errorMessage;
|
||||||
picture.ProcessingError = error;
|
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();
|
await dbContext.SaveChangesAsync();
|
||||||
|
_logger.LogWarning("任务由队列标记为失败: TaskId={TaskId}, Error='{Error}'", taskId, errorMessage);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogWarning("尝试由队列标记为失败,但未找到任务: TaskId={TaskId}", taskId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
|
||||||
Dispose(true);
|
|
||||||
GC.SuppressFinalize(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void Dispose(bool disposing)
|
|
||||||
{
|
{
|
||||||
if (_isDisposed) return;
|
if (_isDisposed) return;
|
||||||
|
|
||||||
if (disposing)
|
_queue.Writer.TryComplete(); // 尝试完成队列写入
|
||||||
|
|
||||||
|
// 等待所有处理任务完成,设置超时
|
||||||
|
var allProcessingTasksDone = Task.WhenAll(_processingTasks);
|
||||||
|
try
|
||||||
{
|
{
|
||||||
_signal.Dispose();
|
if (!allProcessingTasksDone.Wait(TimeSpan.FromSeconds(10))) // 例如,等待10秒
|
||||||
try
|
|
||||||
{
|
{
|
||||||
Task.WhenAll(_processingTasks).Wait(5000);
|
_logger.LogWarning("并非所有后台任务都在 Dispose 超时内完成。");
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogWarning(ex, "等待处理任务完成时超时");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
catch (AggregateException ae)
|
||||||
|
{
|
||||||
|
ae.Handle(ex =>
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "后台任务在 Dispose 期间抛出异常。");
|
||||||
|
return true; // 标记为已处理
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "等待处理任务完成时发生错误。");
|
||||||
|
}
|
||||||
|
|
||||||
|
_signal.Dispose();
|
||||||
_isDisposed = true;
|
_isDisposed = true;
|
||||||
|
GC.SuppressFinalize(this);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 图片处理任务
|
|
||||||
/// </summary>
|
|
||||||
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; }
|
|
||||||
}
|
}
|
||||||
@@ -14,20 +14,20 @@ public interface IBackgroundTaskQueue
|
|||||||
/// <param name="originalFilePath">原始图片路径</param>
|
/// <param name="originalFilePath">原始图片路径</param>
|
||||||
/// <returns>任务ID</returns>
|
/// <returns>任务ID</returns>
|
||||||
Task<Guid> QueuePictureProcessingTaskAsync(int pictureId, string originalFilePath);
|
Task<Guid> QueuePictureProcessingTaskAsync(int pictureId, string originalFilePath);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 获取用户的所有任务状态
|
/// 获取用户的所有任务状态 (目前主要指图片处理任务)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="userId">用户ID</param>
|
/// <param name="userId">用户ID</param>
|
||||||
/// <returns>该用户的任务状态列表</returns>
|
/// <returns>该用户的任务状态列表</returns>
|
||||||
Task<List<PictureProcessingStatus>> GetUserTasksStatusAsync(int userId);
|
Task<List<TaskDetailsDto>> GetUserTasksStatusAsync(int userId);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 获取特定图片的处理状态
|
/// 获取特定图片的处理状态 (实际获取的是与该图片关联的任务状态)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="pictureId">图片ID</param>
|
/// <param name="pictureId">图片ID, 将作为 RelatedEntityId 查询</param>
|
||||||
/// <returns>处理状态</returns>
|
/// <returns>处理状态 DTO</returns>
|
||||||
Task<PictureProcessingStatus?> GetPictureProcessingStatusAsync(int pictureId);
|
Task<TaskDetailsDto?> GetPictureProcessingStatusAsync(int pictureId);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 恢复未完成的任务
|
/// 恢复未完成的任务
|
||||||
@@ -36,16 +36,17 @@ public interface IBackgroundTaskQueue
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 图片处理状态
|
/// 通用任务状态 DTO (用于API响应)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class PictureProcessingStatus
|
public class TaskDetailsDto
|
||||||
{
|
{
|
||||||
public int PictureId { get; set; }
|
|
||||||
public Guid TaskId { get; set; }
|
public Guid TaskId { get; set; }
|
||||||
public string PictureName { get; set; } = string.Empty;
|
public string TaskName { get; set; } = string.Empty; // 任务的描述性名称
|
||||||
public ProcessingStatus Status { get; set; }
|
public TaskType TaskType { get; set; } // 任务类型
|
||||||
|
public TaskExecutionStatus Status { get; set; }
|
||||||
public int Progress { get; set; } // 0-100
|
public int Progress { get; set; } // 0-100
|
||||||
public string? Error { get; set; }
|
public string? Error { get; set; }
|
||||||
public DateTime CreatedAt { get; set; }
|
public DateTime CreatedAt { get; set; }
|
||||||
public DateTime? CompletedAt { get; set; }
|
public DateTime? CompletedAt { get; set; }
|
||||||
|
public int? RelatedEntityId { get; set; } // 关联实体的ID,例如 PictureId
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
using Foxel.Models.DataBase;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace Foxel.Services.Background.Processors
|
||||||
|
{
|
||||||
|
public interface ITaskProcessor
|
||||||
|
{
|
||||||
|
Task ProcessAsync(BackgroundTask task);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<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)
|
||||||
|
{
|
||||||
|
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<PictureProcessingPayload>(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<IAiService>();
|
||||||
|
var storageService = scope.ServiceProvider.GetRequiredService<IStorageService>();
|
||||||
|
|
||||||
|
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<IVectorDbService>();
|
||||||
|
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<Tag>();
|
||||||
|
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<Tag>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
using Foxel.Models;
|
using Foxel.Models;
|
||||||
using Foxel.Models.Response.Picture;
|
using Foxel.Models.Response.Picture;
|
||||||
using Foxel.Services.Configuration;
|
|
||||||
using Foxel.Services.Storage;
|
using Foxel.Services.Storage;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
|
|
||||||
namespace Foxel.Services.Management;
|
namespace Foxel.Services.Management;
|
||||||
|
|
||||||
@@ -28,7 +26,7 @@ public class PictureManagementService(
|
|||||||
if (!string.IsNullOrWhiteSpace(searchQuery))
|
if (!string.IsNullOrWhiteSpace(searchQuery))
|
||||||
{
|
{
|
||||||
query = query.Where(p => p.Name.Contains(searchQuery) ||
|
query = query.Where(p => p.Name.Contains(searchQuery) ||
|
||||||
(p.Description != null && p.Description.Contains(searchQuery)));
|
(p.Description.Contains(searchQuery)));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (userId.HasValue)
|
if (userId.HasValue)
|
||||||
@@ -51,7 +49,7 @@ 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.StorageType, provider =>
|
||||||
Task.FromResult(provider.GetUrl(picture.Path ?? string.Empty))).Result,
|
Task.FromResult(provider.GetUrl(picture.Path))).Result,
|
||||||
ThumbnailPath = storageService.ExecuteAsync(picture.StorageType, provider =>
|
ThumbnailPath = storageService.ExecuteAsync(picture.StorageType, provider =>
|
||||||
Task.FromResult(provider.GetUrl(picture.ThumbnailPath ?? string.Empty))).Result,
|
Task.FromResult(provider.GetUrl(picture.ThumbnailPath ?? string.Empty))).Result,
|
||||||
Description = picture.Description,
|
Description = picture.Description,
|
||||||
@@ -81,7 +79,8 @@ public class PictureManagementService(
|
|||||||
await using var dbContext = await contextFactory.CreateDbContextAsync();
|
await using var dbContext = await contextFactory.CreateDbContextAsync();
|
||||||
|
|
||||||
var picture = await dbContext.Pictures
|
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);
|
.FirstOrDefaultAsync(p => p.Id == id);
|
||||||
|
|
||||||
if (picture == null)
|
if (picture == null)
|
||||||
@@ -103,9 +102,6 @@ public class PictureManagementService(
|
|||||||
AlbumId = picture.AlbumId,
|
AlbumId = picture.AlbumId,
|
||||||
AlbumName = picture.Album?.Name,
|
AlbumName = picture.Album?.Name,
|
||||||
Permission = picture.Permission,
|
Permission = picture.Permission,
|
||||||
ProcessingStatus = picture.ProcessingStatus,
|
|
||||||
ProcessingError = picture.ProcessingError,
|
|
||||||
ProcessingProgress = picture.ProcessingProgress,
|
|
||||||
FavoriteCount = picture.Favorites?.Count ?? 0,
|
FavoriteCount = picture.Favorites?.Count ?? 0,
|
||||||
IsFavorited = false
|
IsFavorited = false
|
||||||
};
|
};
|
||||||
@@ -216,9 +212,6 @@ public class PictureManagementService(
|
|||||||
AlbumId = picture.AlbumId,
|
AlbumId = picture.AlbumId,
|
||||||
AlbumName = picture.Album?.Name,
|
AlbumName = picture.Album?.Name,
|
||||||
Permission = picture.Permission,
|
Permission = picture.Permission,
|
||||||
ProcessingStatus = picture.ProcessingStatus,
|
|
||||||
ProcessingError = picture.ProcessingError,
|
|
||||||
ProcessingProgress = picture.ProcessingProgress,
|
|
||||||
FavoriteCount = picture.Favorites?.Count ?? 0,
|
FavoriteCount = picture.Favorites?.Count ?? 0,
|
||||||
IsFavorited = false
|
IsFavorited = false
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
|||||||
@@ -343,8 +343,10 @@ public class PictureService(
|
|||||||
Name = picture.Name,
|
Name = picture.Name,
|
||||||
Path = storageService.ExecuteAsync(picture.StorageType, provider =>
|
Path = storageService.ExecuteAsync(picture.StorageType, provider =>
|
||||||
Task.FromResult(provider.GetUrl(picture.Path ?? string.Empty))).Result,
|
Task.FromResult(provider.GetUrl(picture.Path ?? string.Empty))).Result,
|
||||||
ThumbnailPath = storageService.ExecuteAsync(picture.StorageType, provider =>
|
ThumbnailPath = !string.IsNullOrEmpty(picture.ThumbnailPath) ?
|
||||||
Task.FromResult(provider.GetUrl(picture.ThumbnailPath ?? string.Empty))).Result,
|
storageService.ExecuteAsync(picture.StorageType, provider =>
|
||||||
|
Task.FromResult(provider.GetUrl(picture.ThumbnailPath))).Result
|
||||||
|
: null, // 如果没有缩略图路径,则为null
|
||||||
Description = picture.Description,
|
Description = picture.Description,
|
||||||
CreatedAt = picture.CreatedAt,
|
CreatedAt = picture.CreatedAt,
|
||||||
Tags = picture.Tags != null ? picture.Tags.Select(t => t.Name).ToList() : new List<string>(),
|
Tags = picture.Tags != null ? picture.Tags.Select(t => t.Name).ToList() : new List<string>(),
|
||||||
@@ -354,6 +356,7 @@ public class PictureService(
|
|||||||
Username = picture.User?.UserName,
|
Username = picture.User?.UserName,
|
||||||
AlbumId = picture.AlbumId,
|
AlbumId = picture.AlbumId,
|
||||||
Permission = picture.Permission
|
Permission = picture.Permission
|
||||||
|
// ProcessingStatus 字段已移除,客户端应通过 BackgroundTaskController 获取状态
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -620,13 +623,13 @@ public class PictureService(
|
|||||||
{
|
{
|
||||||
Name = initialTitle,
|
Name = initialTitle,
|
||||||
Description = initialDescription,
|
Description = initialDescription,
|
||||||
Path = relativePath,
|
Path = relativePath, // 这是存储服务返回的路径/键
|
||||||
User = user,
|
User = user,
|
||||||
Permission = permission,
|
Permission = permission,
|
||||||
AlbumId = albumId,
|
AlbumId = albumId,
|
||||||
StorageType = storageType.Value,
|
StorageType = storageType.Value,
|
||||||
ProcessingStatus = isAnonymous ? ProcessingStatus.Completed : ProcessingStatus.Pending,
|
// ProcessingStatus 等字段已移除
|
||||||
ThumbnailPath = thumbnailPath ?? (isAnonymous ? relativePath : null) // 匿名用户使用原图作为缩略图
|
ThumbnailPath = thumbnailPath // 如果生成了缩略图,则保存其存储路径/键
|
||||||
};
|
};
|
||||||
|
|
||||||
dbContext.Pictures.Add(picture);
|
dbContext.Pictures.Add(picture);
|
||||||
@@ -634,7 +637,8 @@ public class PictureService(
|
|||||||
|
|
||||||
if (!isAnonymous)
|
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,
|
Id = picture.Id,
|
||||||
Name = picture.Name,
|
Name = picture.Name,
|
||||||
Path = await storageService.ExecuteAsync(picture.StorageType, provider =>
|
Path = await storageService.ExecuteAsync(picture.StorageType, provider =>
|
||||||
Task.FromResult(provider.GetUrl(relativePath))),
|
Task.FromResult(provider.GetUrl(picture.Path))), // 使用 picture.Path
|
||||||
ThumbnailPath = !string.IsNullOrEmpty(thumbnailPath)
|
ThumbnailPath = !string.IsNullOrEmpty(picture.ThumbnailPath)
|
||||||
? await storageService.ExecuteAsync(picture.StorageType, provider =>
|
? await storageService.ExecuteAsync(picture.StorageType, provider =>
|
||||||
Task.FromResult(provider.GetUrl(thumbnailPath)))
|
Task.FromResult(provider.GetUrl(picture.ThumbnailPath)))
|
||||||
: (isAnonymous
|
: null,
|
||||||
? await storageService.ExecuteAsync(picture.StorageType, provider =>
|
|
||||||
Task.FromResult(provider.GetUrl(relativePath)))
|
|
||||||
: null),
|
|
||||||
Description = picture.Description,
|
Description = picture.Description,
|
||||||
CreatedAt = picture.CreatedAt,
|
CreatedAt = picture.CreatedAt,
|
||||||
Tags = new List<string>(),
|
Tags = new List<string>(),
|
||||||
Permission = permission,
|
Permission = permission,
|
||||||
AlbumId = albumId,
|
AlbumId = albumId,
|
||||||
AlbumName = album?.Name,
|
AlbumName = album?.Name,
|
||||||
ProcessingStatus = picture.ProcessingStatus
|
// ProcessingStatus 字段已移除
|
||||||
};
|
};
|
||||||
|
|
||||||
return (pictureResponse, picture.Id);
|
return (pictureResponse, picture.Id);
|
||||||
|
|||||||
@@ -1,29 +1,36 @@
|
|||||||
import { fetchApi, type BaseResult } from './fetchClient';
|
import { fetchApi, type BaseResult } from './fetchClient';
|
||||||
import type { ProcessingStatus } from './pictureApi';
|
|
||||||
|
|
||||||
// 图片处理任务
|
// 通用任务视图模型
|
||||||
export interface PictureProcessingTask {
|
export interface TaskDetailsViewModel {
|
||||||
pictureId: number;
|
|
||||||
taskId: string;
|
taskId: string;
|
||||||
pictureName: string;
|
taskName: string; // 任务的描述性名称
|
||||||
status: ProcessingStatus;
|
taskType: number; // 任务类型 (例如 0 for "PictureProcessing")
|
||||||
|
status: TaskExecutionStatus; // 修改: 类型将是数字枚举
|
||||||
progress: number; // 0-100
|
progress: number; // 0-100
|
||||||
error?: string;
|
error?: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
completedAt?: Date;
|
completedAt?: Date;
|
||||||
|
relatedEntityId?: number;
|
||||||
|
}
|
||||||
|
// 修改: TaskExecutionStatus 定义为数字枚举
|
||||||
|
export enum TaskExecutionStatus {
|
||||||
|
Pending = 0, // 等待处理
|
||||||
|
Processing = 1, // 处理中
|
||||||
|
Completed = 2, // 处理完成
|
||||||
|
Failed = 3 // 处理失败
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取当前用户的所有处理任务
|
* 获取当前用户的所有处理任务
|
||||||
*/
|
*/
|
||||||
export const getUserTasks = async (): Promise<BaseResult<PictureProcessingTask[]>> => {
|
export const getUserTasks = async (): Promise<BaseResult<TaskDetailsViewModel[]>> => {
|
||||||
return fetchApi<PictureProcessingTask[]>('/background-tasks/user-tasks');
|
return fetchApi<TaskDetailsViewModel[]>('/background-tasks/user-tasks');
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取特定图片的处理状态
|
* 获取特定图片的处理状态 (实际获取的是与该图片关联的任务状态)
|
||||||
* @param pictureId 图片ID
|
* @param pictureId 图片ID
|
||||||
*/
|
*/
|
||||||
export const getPictureProcessingStatus = async (pictureId: number): Promise<BaseResult<PictureProcessingTask>> => {
|
export const getPictureTaskExecutionStatus = async (pictureId: number): Promise<BaseResult<TaskDetailsViewModel>> => {
|
||||||
return fetchApi<PictureProcessingTask>(`/background-tasks/picture-status/${pictureId}`);
|
return fetchApi<TaskDetailsViewModel>(`/background-tasks/picture-status/${pictureId}`);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,16 +20,7 @@ export interface FilteredPicturesRequest {
|
|||||||
includeAllPublic?: boolean;
|
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 {
|
export interface PictureResponse {
|
||||||
@@ -49,9 +40,6 @@ export interface PictureResponse {
|
|||||||
permission: number;
|
permission: number;
|
||||||
albumId?: number;
|
albumId?: number;
|
||||||
albumName?: string;
|
albumName?: string;
|
||||||
processingStatus: ProcessingStatus;
|
|
||||||
processingError?: string;
|
|
||||||
processingProgress: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 收藏请求
|
// 收藏请求
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ import {
|
|||||||
CheckCircleOutlined,
|
CheckCircleOutlined,
|
||||||
CloseCircleOutlined
|
CloseCircleOutlined
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { ProcessingStatus } from '../api';
|
import { TaskExecutionStatus } from '../api';
|
||||||
|
|
||||||
interface TaskProgressBarProps {
|
interface TaskProgressBarProps {
|
||||||
status: ProcessingStatus;
|
status: TaskExecutionStatus; // status 现在是数字
|
||||||
progress: number;
|
progress: number;
|
||||||
error?: string;
|
error?: string;
|
||||||
showLabel?: boolean;
|
showLabel?: boolean;
|
||||||
@@ -32,26 +32,26 @@ const TaskProgressBar: React.FC<TaskProgressBarProps> = ({
|
|||||||
let statusText = '';
|
let statusText = '';
|
||||||
let progressStatus: "success" | "exception" | "active" | "normal" | undefined;
|
let progressStatus: "success" | "exception" | "active" | "normal" | undefined;
|
||||||
|
|
||||||
switch (status) {
|
switch (status) { // status 现在是数字
|
||||||
case ProcessingStatus.Pending:
|
case TaskExecutionStatus.Pending: // 使用数字枚举成员
|
||||||
statusColor = 'orange';
|
statusColor = 'orange';
|
||||||
progressStatus = 'normal';
|
progressStatus = 'normal';
|
||||||
icon = <ClockCircleOutlined />;
|
icon = <ClockCircleOutlined />;
|
||||||
statusText = '等待中';
|
statusText = '等待中';
|
||||||
break;
|
break;
|
||||||
case ProcessingStatus.Processing:
|
case TaskExecutionStatus.Processing: // 使用数字枚举成员
|
||||||
statusColor = 'processing';
|
statusColor = 'processing';
|
||||||
progressStatus = 'active';
|
progressStatus = 'active';
|
||||||
icon = <SyncOutlined spin />;
|
icon = <SyncOutlined spin />;
|
||||||
statusText = '处理中';
|
statusText = '处理中';
|
||||||
break;
|
break;
|
||||||
case ProcessingStatus.Completed:
|
case TaskExecutionStatus.Completed: // 使用数字枚举成员
|
||||||
statusColor = 'success';
|
statusColor = 'success';
|
||||||
progressStatus = 'success';
|
progressStatus = 'success';
|
||||||
icon = <CheckCircleOutlined />;
|
icon = <CheckCircleOutlined />;
|
||||||
statusText = '已完成';
|
statusText = '已完成';
|
||||||
break;
|
break;
|
||||||
case ProcessingStatus.Failed:
|
case TaskExecutionStatus.Failed: // 使用数字枚举成员
|
||||||
statusColor = 'error';
|
statusColor = 'error';
|
||||||
progressStatus = 'exception';
|
progressStatus = 'exception';
|
||||||
icon = <CloseCircleOutlined />;
|
icon = <CloseCircleOutlined />;
|
||||||
@@ -66,7 +66,7 @@ const TaskProgressBar: React.FC<TaskProgressBarProps> = ({
|
|||||||
<Tag color={statusColor} icon={icon} style={{ marginRight: 8 }}>
|
<Tag color={statusColor} icon={icon} style={{ marginRight: 8 }}>
|
||||||
{statusText}
|
{statusText}
|
||||||
</Tag>
|
</Tag>
|
||||||
{status === ProcessingStatus.Failed && error && (
|
{status === TaskExecutionStatus.Failed && error && ( // 使用数字枚举成员
|
||||||
<Tooltip title={error}>
|
<Tooltip title={error}>
|
||||||
<span style={{ color: '#ff4d4f', cursor: 'pointer', fontSize: 13 }}>
|
<span style={{ color: '#ff4d4f', cursor: 'pointer', fontSize: 13 }}>
|
||||||
查看错误
|
查看错误
|
||||||
@@ -81,7 +81,7 @@ const TaskProgressBar: React.FC<TaskProgressBarProps> = ({
|
|||||||
size={size}
|
size={size}
|
||||||
status={progressStatus}
|
status={progressStatus}
|
||||||
showInfo={size !== 'small'}
|
showInfo={size !== 'small'}
|
||||||
strokeColor={status === ProcessingStatus.Failed ? '#ff4d4f' : undefined}
|
strokeColor={status === TaskExecutionStatus.Failed ? '#ff4d4f' : undefined} // 使用数字枚举成员
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { Divider } from 'antd';
|
|||||||
import { CloseOutlined, DownOutlined, UpOutlined } from '@ant-design/icons';
|
import { CloseOutlined, DownOutlined, UpOutlined } from '@ant-design/icons';
|
||||||
import type { PictureResponse } from '../../api';
|
import type { PictureResponse } from '../../api';
|
||||||
import './ImageViewer.css';
|
import './ImageViewer.css';
|
||||||
|
import './ImageInfo.css';
|
||||||
|
|
||||||
interface ImageInfoProps {
|
interface ImageInfoProps {
|
||||||
image: PictureResponse;
|
image: PictureResponse;
|
||||||
@@ -25,7 +26,7 @@ const ImageInfo: React.FC<ImageInfoProps> = ({
|
|||||||
// 格式化EXIF数据
|
// 格式化EXIF数据
|
||||||
const formatExifInfo = (exifInfo: any) => {
|
const formatExifInfo = (exifInfo: any) => {
|
||||||
if (!exifInfo) return [];
|
if (!exifInfo) return [];
|
||||||
|
|
||||||
// 定义EXIF信息分类
|
// 定义EXIF信息分类
|
||||||
const categories = {
|
const categories = {
|
||||||
basic: { title: "基本信息", items: [] as any[] },
|
basic: { title: "基本信息", items: [] as any[] },
|
||||||
@@ -34,18 +35,18 @@ const ImageInfo: React.FC<ImageInfoProps> = ({
|
|||||||
time: { title: "时间信息", items: [] as any[] },
|
time: { title: "时间信息", items: [] as any[] },
|
||||||
location: { title: "位置信息", items: [] as any[] }
|
location: { title: "位置信息", items: [] as any[] }
|
||||||
};
|
};
|
||||||
|
|
||||||
// 将EXIF信息映射到对应字段
|
// 将EXIF信息映射到对应字段
|
||||||
const exifMapping: Record<string, { key: string; category: keyof typeof categories; formatter?: (value: any) => string }> = {
|
const exifMapping: Record<string, { key: string; category: keyof typeof categories; formatter?: (value: any) => string }> = {
|
||||||
// 基本信息
|
// 基本信息
|
||||||
width: { key: "width", category: "basic", formatter: (v) => `${v}px` },
|
width: { key: "width", category: "basic", formatter: (v) => `${v}px` },
|
||||||
height: { key: "height", category: "basic", formatter: (v) => `${v}px` },
|
height: { key: "height", category: "basic", formatter: (v) => `${v}px` },
|
||||||
|
|
||||||
// 相机信息
|
// 相机信息
|
||||||
cameraMaker: { key: "make", category: "camera" },
|
cameraMaker: { key: "make", category: "camera" },
|
||||||
cameraModel: { key: "model", category: "camera" },
|
cameraModel: { key: "model", category: "camera" },
|
||||||
software: { key: "software", category: "camera" },
|
software: { key: "software", category: "camera" },
|
||||||
|
|
||||||
// 拍摄参数
|
// 拍摄参数
|
||||||
exposureTime: { key: "exposureTime", category: "settings" },
|
exposureTime: { key: "exposureTime", category: "settings" },
|
||||||
aperture: { key: "fNumber", category: "settings", formatter: (v) => `f/${v}` },
|
aperture: { key: "fNumber", category: "settings", formatter: (v) => `f/${v}` },
|
||||||
@@ -54,9 +55,9 @@ const ImageInfo: React.FC<ImageInfoProps> = ({
|
|||||||
flash: { key: "flash", category: "settings" },
|
flash: { key: "flash", category: "settings" },
|
||||||
meteringMode: { key: "meteringMode", category: "settings" },
|
meteringMode: { key: "meteringMode", category: "settings" },
|
||||||
whiteBalance: { key: "whiteBalance", category: "settings" },
|
whiteBalance: { key: "whiteBalance", category: "settings" },
|
||||||
dateTimeOriginal: {
|
dateTimeOriginal: {
|
||||||
key: "dateTime",
|
key: "dateTime",
|
||||||
category: "time",
|
category: "time",
|
||||||
formatter: (v) => {
|
formatter: (v) => {
|
||||||
if (typeof v === 'string' && v.match(/^\d{4}:\d{2}:\d{2} \d{2}:\d{2}:\d{2}$/)) {
|
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');
|
const normalized = v.replace(/^(\d{4}):(\d{2}):(\d{2})/, '$1-$2-$3');
|
||||||
@@ -65,24 +66,24 @@ const ImageInfo: React.FC<ImageInfoProps> = ({
|
|||||||
return date.toLocaleString();
|
return date.toLocaleString();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return v.toString();
|
return v.toString();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// 位置信息
|
// 位置信息
|
||||||
gpsLatitude: { key: "latitude", category: "location" },
|
gpsLatitude: { key: "latitude", category: "location" },
|
||||||
gpsLongitude: { key: "longitude", category: "location" }
|
gpsLongitude: { key: "longitude", category: "location" }
|
||||||
};
|
};
|
||||||
|
|
||||||
// 处理每个EXIF字段
|
// 处理每个EXIF字段
|
||||||
Object.entries(exifInfo).forEach(([key, value]) => {
|
Object.entries(exifInfo).forEach(([key, value]) => {
|
||||||
if (value === null || value === undefined || value === '') return;
|
if (value === null || value === undefined || value === '') return;
|
||||||
|
|
||||||
const mapping = exifMapping[key];
|
const mapping = exifMapping[key];
|
||||||
if (mapping) {
|
if (mapping) {
|
||||||
const formattedValue = mapping.formatter ? mapping.formatter(value) : value.toString();
|
const formattedValue = mapping.formatter ? mapping.formatter(value) : value.toString();
|
||||||
const label = formatExifLabel(mapping.key);
|
const label = formatExifLabel(mapping.key);
|
||||||
|
|
||||||
categories[mapping.category].items.push({
|
categories[mapping.category].items.push({
|
||||||
key: mapping.key,
|
key: mapping.key,
|
||||||
label,
|
label,
|
||||||
@@ -90,7 +91,7 @@ const ImageInfo: React.FC<ImageInfoProps> = ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 返回包含数据的分类
|
// 返回包含数据的分类
|
||||||
return Object.values(categories).filter(category => category.items.length > 0);
|
return Object.values(categories).filter(category => category.items.length > 0);
|
||||||
};
|
};
|
||||||
@@ -101,12 +102,12 @@ const ImageInfo: React.FC<ImageInfoProps> = ({
|
|||||||
// 基本信息
|
// 基本信息
|
||||||
width: "宽度",
|
width: "宽度",
|
||||||
height: "高度",
|
height: "高度",
|
||||||
|
|
||||||
// 相机信息
|
// 相机信息
|
||||||
make: "相机品牌",
|
make: "相机品牌",
|
||||||
model: "相机型号",
|
model: "相机型号",
|
||||||
software: "软件",
|
software: "软件",
|
||||||
|
|
||||||
// 拍摄参数
|
// 拍摄参数
|
||||||
exposureTime: "曝光时间",
|
exposureTime: "曝光时间",
|
||||||
fNumber: "光圈值",
|
fNumber: "光圈值",
|
||||||
@@ -115,43 +116,43 @@ const ImageInfo: React.FC<ImageInfoProps> = ({
|
|||||||
flash: "闪光灯",
|
flash: "闪光灯",
|
||||||
meteringMode: "测光模式",
|
meteringMode: "测光模式",
|
||||||
whiteBalance: "白平衡",
|
whiteBalance: "白平衡",
|
||||||
|
|
||||||
// 时间信息
|
// 时间信息
|
||||||
dateTime: "拍摄时间",
|
dateTime: "拍摄时间",
|
||||||
|
|
||||||
// 位置信息
|
// 位置信息
|
||||||
latitude: "纬度",
|
latitude: "纬度",
|
||||||
longitude: "经度"
|
longitude: "经度"
|
||||||
};
|
};
|
||||||
|
|
||||||
return labels[key] || key.charAt(0).toUpperCase() + key.slice(1).replace(/([A-Z])/g, ' $1');
|
return labels[key] || key.charAt(0).toUpperCase() + key.slice(1).replace(/([A-Z])/g, ' $1');
|
||||||
};
|
};
|
||||||
|
|
||||||
// 渲染EXIF信息
|
// 渲染EXIF信息
|
||||||
const renderExifInfo = (styles: any) => {
|
const renderExifInfo = () => {
|
||||||
if (!image?.exifInfo) return <div style={{ color: 'rgba(255, 255, 255, 0.6)' }}>无EXIF信息</div>;
|
if (!image?.exifInfo) return <div className="imageinfo-exif-empty">无EXIF信息</div>;
|
||||||
|
|
||||||
const formattedCategories = formatExifInfo(image.exifInfo);
|
const formattedCategories = formatExifInfo(image.exifInfo);
|
||||||
|
|
||||||
if (formattedCategories.length === 0) {
|
if (formattedCategories.length === 0) {
|
||||||
return <div style={{ color: 'rgba(255, 255, 255, 0.6)' }}>无EXIF信息</div>;
|
return <div className="imageinfo-exif-empty">无EXIF信息</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={styles.exifContainer}>
|
<div className="imageinfo-exif-container">
|
||||||
{formattedCategories.map(category => (
|
{formattedCategories.map(category => (
|
||||||
<div key={category.title} style={styles.exifCategory}>
|
<div key={category.title} className="imageinfo-exif-category">
|
||||||
<Divider
|
<Divider
|
||||||
orientation="left"
|
orientation="left"
|
||||||
style={styles.divider}
|
className="imageinfo-exif-divider"
|
||||||
>
|
>
|
||||||
{category.title}
|
{category.title}
|
||||||
</Divider>
|
</Divider>
|
||||||
<div style={styles.exifTable}>
|
<div className="imageinfo-exif-table">
|
||||||
{category.items.map(item => (
|
{category.items.map(item => (
|
||||||
<div key={item.key} style={{ display: 'flex', borderBottom: '1px solid rgba(255, 255, 255, 0.05)' }}>
|
<div key={item.key} className="imageinfo-exif-row">
|
||||||
<div style={styles.exifLabel}>{item.label}</div>
|
<div className="imageinfo-exif-label">{item.label}</div>
|
||||||
<div style={styles.exifValue}>{item.value}</div>
|
<div className="imageinfo-exif-value">{item.value}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -161,211 +162,31 @@ const ImageInfo: React.FC<ImageInfoProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 定义内联样式对象
|
|
||||||
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 (
|
return (
|
||||||
<div style={styles.drawer}>
|
<div
|
||||||
<div style={styles.header}>
|
className={`imageinfo-drawer${visible ? ' imageinfo-drawer-visible' : ''}`}
|
||||||
<h3 style={styles.headerTitle}>图片信息</h3>
|
>
|
||||||
<button style={styles.closeButton} onClick={onClose}>
|
<div className="imageinfo-header">
|
||||||
|
<h3 className="imageinfo-header-title">图片信息</h3>
|
||||||
|
<button className="imageinfo-close-btn" onClick={onClose}>
|
||||||
<CloseOutlined />
|
<CloseOutlined />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div style={styles.body}>
|
<div className="imageinfo-body">
|
||||||
<div style={styles.titleContainer}>
|
<div className="imageinfo-title-container">
|
||||||
<h4 style={styles.title}>{image?.name}</h4>
|
<h4 className="imageinfo-title">{image?.name}</h4>
|
||||||
<div style={styles.date}>上传于{new Date(image?.createdAt).toLocaleString()}</div>
|
<div className="imageinfo-date">上传于{new Date(image?.createdAt).toLocaleString()}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{image?.description && (
|
{image?.description && (
|
||||||
<div style={styles.descSection}>
|
<div className="imageinfo-desc-section">
|
||||||
<div style={styles.descText}>{image.description}</div>
|
<div
|
||||||
|
className={`imageinfo-desc-text${expandDescription ? ' imageinfo-desc-text-expand' : ''}`}
|
||||||
|
>
|
||||||
|
{image.description}
|
||||||
|
</div>
|
||||||
{image.description.split('\n').length > 8 || image.description.length > 200 ? (
|
{image.description.split('\n').length > 8 || image.description.length > 200 ? (
|
||||||
<button style={styles.expandButton} onClick={toggleDescription}>
|
<button className="imageinfo-expand-btn" onClick={toggleDescription}>
|
||||||
{expandDescription ? (
|
{expandDescription ? (
|
||||||
<>收起 <UpOutlined style={{ fontSize: '12px', marginLeft: '4px' }} /></>
|
<>收起 <UpOutlined style={{ fontSize: '12px', marginLeft: '4px' }} /></>
|
||||||
) : (
|
) : (
|
||||||
@@ -375,37 +196,37 @@ const ImageInfo: React.FC<ImageInfoProps> = ({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{image?.tags && image.tags.length > 0 && (
|
{image?.tags && image.tags.length > 0 && (
|
||||||
<div style={styles.tagsSection}>
|
<div className="imageinfo-tags-section">
|
||||||
<div style={styles.tagTitle}>标签</div>
|
<div className="imageinfo-tag-title">标签</div>
|
||||||
<div>
|
<div>
|
||||||
{image.tags.map(tag => (
|
{image.tags.map(tag => (
|
||||||
<span key={tag} style={styles.tagItem}>#{tag}</span>
|
<span key={tag} className="imageinfo-tag-item">#{tag}</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{image?.exifInfo && (
|
{image?.exifInfo && (
|
||||||
<div style={styles.specsSection}>
|
<div className="imageinfo-specs-section">
|
||||||
<div style={styles.specsContainer}>
|
<div className="imageinfo-specs-container">
|
||||||
<div style={styles.specItem}>
|
<div className="imageinfo-spec-item">
|
||||||
<div style={styles.specValue}>{image.exifInfo.width}×{image.exifInfo.height}</div>
|
<div className="imageinfo-spec-value">{image.exifInfo.width}×{image.exifInfo.height}</div>
|
||||||
<div style={styles.specLabel}>分辨率</div>
|
<div className="imageinfo-spec-label">分辨率</div>
|
||||||
</div>
|
</div>
|
||||||
{image.exifInfo.focalLength && (
|
{image.exifInfo.focalLength && (
|
||||||
<div style={styles.specItem}>
|
<div className="imageinfo-spec-item">
|
||||||
<div style={styles.specValue}>{image.exifInfo.focalLength}</div>
|
<div className="imageinfo-spec-value">{image.exifInfo.focalLength}</div>
|
||||||
<div style={styles.specLabel}>焦距</div>
|
<div className="imageinfo-spec-label">焦距</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 渲染EXIF信息 */}
|
{/* 渲染EXIF信息 */}
|
||||||
{renderExifInfo(styles)}
|
{renderExifInfo()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { Typography, Table, Card, Tag, Space, Button, Empty, message, Modal } from 'antd';
|
import { Typography, Table, Card, Tag, Space, Button, Empty, message, Modal } from 'antd';
|
||||||
import { SyncOutlined, EyeOutlined } from '@ant-design/icons';
|
import { SyncOutlined, EyeOutlined } from '@ant-design/icons';
|
||||||
import { getUserTasks } from '../../api';
|
import { getUserTasks, TaskExecutionStatus } from '../../api';
|
||||||
import { type PictureProcessingTask, ProcessingStatus } from '../../api';
|
import { type TaskDetailsViewModel } from '../../api';
|
||||||
import TaskProgressBar from '../../components/TaskProgressBar';
|
import TaskProgressBar from '../../components/TaskProgressBar';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
@@ -10,8 +10,16 @@ import type { ColumnType } from 'antd/es/table';
|
|||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
// 定义任务类型映射
|
||||||
|
const taskTypeDisplayMapping: { [key: number]: string } = {
|
||||||
|
0: '图片处理', // 对应后端的 PictureProcessing = 0
|
||||||
|
// 如果有其他任务类型,在此处添加
|
||||||
|
// 1: '视频处理',
|
||||||
|
// 2: '数据导出',
|
||||||
|
};
|
||||||
|
|
||||||
const BackgroundTasks: React.FC = () => {
|
const BackgroundTasks: React.FC = () => {
|
||||||
const [tasks, setTasks] = useState<PictureProcessingTask[]>([]);
|
const [tasks, setTasks] = useState<TaskDetailsViewModel[]>([]); // Updated type
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [pollingActive, setPollingActive] = useState(true);
|
const [pollingActive, setPollingActive] = useState(true);
|
||||||
const [pollingInterval, setPollingIntervalState] = useState<number | null>(null);
|
const [pollingInterval, setPollingIntervalState] = useState<number | null>(null);
|
||||||
@@ -40,61 +48,63 @@ const BackgroundTasks: React.FC = () => {
|
|||||||
|
|
||||||
// 设置轮询
|
// 设置轮询
|
||||||
if (pollingActive) {
|
if (pollingActive) {
|
||||||
const interval = setInterval(fetchTasks, 3000);
|
const interval = setInterval(fetchTasks, 3000); // 轮询间隔调整为3秒
|
||||||
setPollingIntervalState(interval as unknown as number);
|
setPollingIntervalState(interval as unknown as number); // 保存 interval ID
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (pollingInterval) {
|
if (pollingInterval) {
|
||||||
clearInterval(pollingInterval);
|
clearInterval(pollingInterval); // 清除 interval
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [fetchTasks, pollingActive]);
|
}, [fetchTasks, pollingActive]); // 依赖项中移除 pollingInterval
|
||||||
|
|
||||||
// 检查是否有活跃的任务,如果没有则停止轮询
|
// 检查是否有活跃的任务,如果没有则停止轮询
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const hasActiveTasks = tasks.some(
|
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) {
|
if (!hasActiveTasks && pollingActive) {
|
||||||
setPollingActive(false);
|
setPollingActive(false);
|
||||||
if (pollingInterval) {
|
if (pollingInterval) {
|
||||||
clearInterval(pollingInterval);
|
clearInterval(pollingInterval);
|
||||||
setPollingIntervalState(null);
|
setPollingIntervalState(null);
|
||||||
}
|
}
|
||||||
} else if (hasActiveTasks && !pollingActive) {
|
} else if (hasActiveTasks && !pollingActive && tasks.length > 0) { // 确保有任务才重新激活轮询
|
||||||
setPollingActive(true);
|
setPollingActive(true);
|
||||||
const interval = setInterval(fetchTasks, 3000);
|
// 不需要在这里重新创建 interval,上面的 useEffect 会处理
|
||||||
setPollingIntervalState(interval as unknown as number);
|
|
||||||
}
|
}
|
||||||
}, [tasks, pollingActive, pollingInterval, fetchTasks]);
|
}, [tasks, pollingActive, pollingInterval, fetchTasks]); // 保持依赖项
|
||||||
|
|
||||||
// 渲染状态标签
|
// 渲染状态标签
|
||||||
const renderStatus = (status: ProcessingStatus) => {
|
const renderStatus = (status: TaskExecutionStatus) => { // status 现在是数字
|
||||||
let color = '';
|
let color = '';
|
||||||
let text = '';
|
let text = '';
|
||||||
let icon = null;
|
let icon = null;
|
||||||
|
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case ProcessingStatus.Pending:
|
case TaskExecutionStatus.Pending: // 使用数字枚举成员
|
||||||
color = 'orange';
|
color = 'orange';
|
||||||
text = '等待中';
|
text = '等待中';
|
||||||
icon = <SyncOutlined spin />;
|
icon = <SyncOutlined spin />;
|
||||||
break;
|
break;
|
||||||
case ProcessingStatus.Processing:
|
case TaskExecutionStatus.Processing: // 使用数字枚举成员
|
||||||
color = 'processing';
|
color = 'processing';
|
||||||
text = '处理中';
|
text = '处理中';
|
||||||
icon = <SyncOutlined spin />;
|
icon = <SyncOutlined spin />;
|
||||||
break;
|
break;
|
||||||
case ProcessingStatus.Completed:
|
case TaskExecutionStatus.Completed: // 使用数字枚举成员
|
||||||
color = 'success';
|
color = 'success';
|
||||||
text = '已完成';
|
text = '已完成';
|
||||||
break;
|
break;
|
||||||
case ProcessingStatus.Failed:
|
case TaskExecutionStatus.Failed: // 使用数字枚举成员
|
||||||
color = 'error';
|
color = 'error';
|
||||||
text = '失败';
|
text = '失败';
|
||||||
break;
|
break;
|
||||||
|
default:
|
||||||
|
text = `未知状态 (${status})`;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Tag color={color} icon={icon}>{text}</Tag>;
|
return <Tag color={color} icon={icon}>{text}</Tag>;
|
||||||
@@ -115,40 +125,49 @@ const BackgroundTasks: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 表格列定义
|
// 表格列定义
|
||||||
const columns: ColumnType<PictureProcessingTask>[] = [
|
const columns: ColumnType<TaskDetailsViewModel>[] = [ // Updated type
|
||||||
{
|
{
|
||||||
title: '图片名称',
|
title: '任务名称', // Changed title
|
||||||
dataIndex: 'pictureName',
|
dataIndex: 'taskName', // Changed dataIndex
|
||||||
key: 'pictureName',
|
key: 'taskName',
|
||||||
render: (text: string, record: PictureProcessingTask) => (
|
render: (text: string, record: TaskDetailsViewModel) => ( // Updated type and logic
|
||||||
<Link to={`/pictures/${record.pictureId}`}>{text}</Link>
|
record.taskType === 0 && record.relatedEntityId // 修正: 使用数字 0 比较
|
||||||
|
? <Link to={`/pictures/${record.relatedEntityId}`}>{text}</Link>
|
||||||
|
: text
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
key: 'status',
|
key: 'status',
|
||||||
render: (status: ProcessingStatus) => renderStatus(status),
|
render: (status: TaskExecutionStatus) => renderStatus(status), // status 现在是数字
|
||||||
filters: [
|
filters: [
|
||||||
{ text: '等待中', value: ProcessingStatus.Pending },
|
{ text: '等待中', value: TaskExecutionStatus.Pending }, // 使用数字枚举成员
|
||||||
{ text: '处理中', value: ProcessingStatus.Processing },
|
{ text: '处理中', value: TaskExecutionStatus.Processing }, // 使用数字枚举成员
|
||||||
{ text: '已完成', value: ProcessingStatus.Completed },
|
{ text: '已完成', value: TaskExecutionStatus.Completed }, // 使用数字枚举成员
|
||||||
{ text: '失败', value: ProcessingStatus.Failed },
|
{ text: '失败', value: TaskExecutionStatus.Failed }, // 使用数字枚举成员
|
||||||
],
|
],
|
||||||
onFilter: (value, record: PictureProcessingTask) =>
|
onFilter: (value, record: TaskDetailsViewModel) =>
|
||||||
record.status === value.toString(),
|
record.status === (value as TaskExecutionStatus), // value 已经是数字
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '任务类型',
|
||||||
|
dataIndex: 'taskType',
|
||||||
|
key: 'taskType',
|
||||||
|
render: (taskType: number | undefined) => // 接收数字类型的 taskType
|
||||||
|
taskType !== undefined ? taskTypeDisplayMapping[taskType] || `未知类型 (${taskType})` : '-',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '进度',
|
title: '进度',
|
||||||
dataIndex: 'progress',
|
dataIndex: 'progress',
|
||||||
key: 'progress',
|
key: 'progress',
|
||||||
render: (progress: number, record: PictureProcessingTask) => (
|
render: (progress: number, record: TaskDetailsViewModel) => ( // Updated type
|
||||||
<TaskProgressBar
|
<TaskProgressBar
|
||||||
status={record.status}
|
status={record.status}
|
||||||
progress={progress}
|
progress={progress}
|
||||||
error={record.error}
|
error={record.error}
|
||||||
showLabel={false}
|
showLabel={false}
|
||||||
size="small"
|
size="small"
|
||||||
style={{ width: '150px' }}
|
style={{ width: '150px' }}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
@@ -158,29 +177,31 @@ const BackgroundTasks: React.FC = () => {
|
|||||||
dataIndex: 'createdAt',
|
dataIndex: 'createdAt',
|
||||||
key: 'createdAt',
|
key: 'createdAt',
|
||||||
render: (date: Date) => formatDate(date),
|
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(),
|
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '完成时间',
|
title: '完成时间',
|
||||||
dataIndex: 'completedAt',
|
dataIndex: 'completedAt',
|
||||||
key: 'completedAt',
|
key: 'completedAt',
|
||||||
render: (date: Date) => formatDate(date),
|
render: (date: Date | undefined) => formatDate(date), // Ensure date can be undefined
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
key: 'action',
|
key: 'action',
|
||||||
render: (_: any, record: PictureProcessingTask) => (
|
render: (_: any, record: TaskDetailsViewModel) => ( // Updated type
|
||||||
<Space size="middle">
|
<Space size="middle">
|
||||||
<Link to={`/pictures/${record.pictureId}`}>
|
{record.taskType === 0 && record.relatedEntityId && (
|
||||||
<Button type="link" icon={<EyeOutlined />} size="small">
|
<Link to={`/pictures/${record.relatedEntityId}`}>
|
||||||
查看
|
<Button type="link" icon={<EyeOutlined />} size="small">
|
||||||
</Button>
|
查看关联图片
|
||||||
</Link>
|
</Button>
|
||||||
{record.status === ProcessingStatus.Failed && record.error && (
|
</Link>
|
||||||
<Button
|
)}
|
||||||
type="link"
|
{record.status === TaskExecutionStatus.Failed && record.error && ( // 使用数字枚举成员
|
||||||
danger
|
<Button
|
||||||
|
type="link"
|
||||||
|
danger
|
||||||
size="small"
|
size="small"
|
||||||
onClick={() => showErrorMessage(record.error!)}
|
onClick={() => showErrorMessage(record.error!)}
|
||||||
>
|
>
|
||||||
@@ -194,50 +215,50 @@ const BackgroundTasks: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="background-tasks-container">
|
<div className="background-tasks-container">
|
||||||
<div style={{
|
<div style={{
|
||||||
marginBottom: 30,
|
marginBottom: 30,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
}}>
|
}}>
|
||||||
<div>
|
<div>
|
||||||
<Title level={2} style={{
|
<Title level={2} style={{
|
||||||
margin: 0,
|
margin: 0,
|
||||||
marginBottom: 10,
|
marginBottom: 10,
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
letterSpacing: '0.5px',
|
letterSpacing: '0.5px',
|
||||||
fontSize: 32,
|
fontSize: 32,
|
||||||
background: 'linear-gradient(120deg, #000000, #444444)',
|
background: 'linear-gradient(120deg, #000000, #444444)',
|
||||||
WebkitBackgroundClip: 'text',
|
WebkitBackgroundClip: 'text',
|
||||||
WebkitTextFillColor: 'transparent',
|
WebkitTextFillColor: 'transparent',
|
||||||
}}>图片处理队列</Title>
|
}}>图片处理队列</Title>
|
||||||
<Text type="secondary" style={{
|
<Text type="secondary" style={{
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
color: '#888',
|
color: '#888',
|
||||||
letterSpacing: '0.3px'
|
letterSpacing: '0.3px'
|
||||||
}}>查看和管理图片后台处理任务</Text>
|
}}>查看和管理图片后台处理任务</Text>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
icon={<SyncOutlined />}
|
icon={<SyncOutlined />}
|
||||||
onClick={fetchTasks}
|
onClick={fetchTasks}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
>
|
>
|
||||||
刷新
|
刷新
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
{tasks.length > 0 ? (
|
{tasks.length > 0 ? (
|
||||||
<Table
|
<Table
|
||||||
dataSource={tasks}
|
dataSource={tasks}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
rowKey="taskId"
|
rowKey="taskId"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
pagination={{ pageSize: 10 }}
|
pagination={{ pageSize: 10 }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Empty
|
<Empty
|
||||||
description={
|
description={
|
||||||
loading ? "正在加载..." : "暂无处理任务"
|
loading ? "正在加载..." : "暂无处理任务"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
"strict": true,
|
"strict": true,
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"erasableSyntaxOnly": true,
|
"erasableSyntaxOnly": false,
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"noUncheckedSideEffectImports": true
|
"noUncheckedSideEffectImports": true
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user