add:
更新
This commit is contained in:
parent
d429560511
commit
123fa6a7aa
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -22,5 +22,9 @@
|
||||
"Port": 5672,
|
||||
"Username": "test",
|
||||
"Password": "123456"
|
||||
},
|
||||
"FileUploadOptions": {
|
||||
"DefaultStorage": "Local",
|
||||
"ChunkSize": 10,
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,7 +14,7 @@ using System.Reflection;
|
||||
[assembly: System.Reflection.AssemblyCompanyAttribute("IMTest")]
|
||||
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
|
||||
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
|
||||
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+dc6ecf224df4e8714171e8b5d23afaa90b3a1f81")]
|
||||
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+d42956051108811cdd7be6a07af6b11e1d7f0d15")]
|
||||
[assembly: System.Reflection.AssemblyProductAttribute("IMTest")]
|
||||
[assembly: System.Reflection.AssemblyTitleAttribute("IMTest")]
|
||||
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
||||
|
||||
@ -1 +1 @@
|
||||
1b9e709aa84e0b4f6260cd10cf25bfc3a30c60e75a3966fc7d4cdf489eae898b
|
||||
fa24b386648cc4dba48ae5e3f91e5303b0dfd0971bba62bc27ca1580a5064337
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@ -1 +1 @@
|
||||
6e6df2b3d9fe8d3830882bef146134864f65ca58bc5ea4bac684eaec55cfd628
|
||||
b2b545acb4173028f4d41b8d8c0aea03ecbcecb4eeabe997ca466c2e265beff6
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -96,7 +96,7 @@
|
||||
"privateAssets": "all"
|
||||
}
|
||||
},
|
||||
"runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\9.0.304/PortableRuntimeIdentifierGraph.json"
|
||||
"runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\9.0.311/PortableRuntimeIdentifierGraph.json"
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -199,6 +199,10 @@
|
||||
"target": "Package",
|
||||
"version": "[2.3.2, )"
|
||||
},
|
||||
"SixLabors.ImageSharp": {
|
||||
"target": "Package",
|
||||
"version": "[3.1.12, )"
|
||||
},
|
||||
"StackExchange.Redis": {
|
||||
"target": "Package",
|
||||
"version": "[2.9.32, )"
|
||||
@ -231,7 +235,7 @@
|
||||
"privateAssets": "all"
|
||||
}
|
||||
},
|
||||
"runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\9.0.304/PortableRuntimeIdentifierGraph.json"
|
||||
"runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\9.0.311/PortableRuntimeIdentifierGraph.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
<NuGetPackageRoot Condition=" '$(NuGetPackageRoot)' == '' ">$(UserProfile)\.nuget\packages\</NuGetPackageRoot>
|
||||
<NuGetPackageFolders Condition=" '$(NuGetPackageFolders)' == '' ">C:\Users\nanxun\.nuget\packages\;C:\Program Files (x86)\Microsoft Visual Studio\Shared\NuGetPackages</NuGetPackageFolders>
|
||||
<NuGetProjectStyle Condition=" '$(NuGetProjectStyle)' == '' ">PackageReference</NuGetProjectStyle>
|
||||
<NuGetToolVersion Condition=" '$(NuGetToolVersion)' == '' ">6.14.1</NuGetToolVersion>
|
||||
<NuGetToolVersion Condition=" '$(NuGetToolVersion)' == '' ">6.14.2</NuGetToolVersion>
|
||||
</PropertyGroup>
|
||||
<ItemGroup Condition=" '$(ExcludeRestorePackageImports)' != 'true' ">
|
||||
<SourceRoot Include="C:\Users\nanxun\.nuget\packages\" />
|
||||
|
||||
@ -1679,6 +1679,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"SixLabors.ImageSharp/3.1.12": {
|
||||
"type": "package",
|
||||
"compile": {
|
||||
"lib/net6.0/SixLabors.ImageSharp.dll": {
|
||||
"related": ".xml"
|
||||
}
|
||||
},
|
||||
"runtime": {
|
||||
"lib/net6.0/SixLabors.ImageSharp.dll": {
|
||||
"related": ".xml"
|
||||
}
|
||||
},
|
||||
"build": {
|
||||
"build/_._": {}
|
||||
}
|
||||
},
|
||||
"StackExchange.Redis/2.9.32": {
|
||||
"type": "package",
|
||||
"dependencies": {
|
||||
@ -3027,6 +3043,7 @@
|
||||
"Newtonsoft.Json": "13.0.4",
|
||||
"Pomelo.EntityFrameworkCore.MySql": "8.0.3",
|
||||
"RedLock.net": "2.3.2",
|
||||
"SixLabors.ImageSharp": "3.1.12",
|
||||
"StackExchange.Redis": "2.9.32",
|
||||
"Swashbuckle.AspNetCore": "6.6.2",
|
||||
"System.IdentityModel.Tokens.Jwt": "8.14.0"
|
||||
@ -5363,6 +5380,22 @@
|
||||
"runtimes/ubuntu.16.10-x64/native/System.Security.Cryptography.Native.OpenSsl.so"
|
||||
]
|
||||
},
|
||||
"SixLabors.ImageSharp/3.1.12": {
|
||||
"sha512": "iAg6zifihXEFS/t7fiHhZBGAdCp3FavsF4i2ZIDp0JfeYeDVzvmlbY1CNhhIKimaIzrzSi5M/NBFcWvZT2rB/A==",
|
||||
"type": "package",
|
||||
"path": "sixlabors.imagesharp/3.1.12",
|
||||
"files": [
|
||||
".nupkg.metadata",
|
||||
".signature.p7s",
|
||||
"LICENSE",
|
||||
"build/SixLabors.ImageSharp.props",
|
||||
"lib/net6.0/SixLabors.ImageSharp.dll",
|
||||
"lib/net6.0/SixLabors.ImageSharp.xml",
|
||||
"sixlabors.imagesharp.128.png",
|
||||
"sixlabors.imagesharp.3.1.12.nupkg.sha512",
|
||||
"sixlabors.imagesharp.nuspec"
|
||||
]
|
||||
},
|
||||
"StackExchange.Redis/2.9.32": {
|
||||
"sha512": "j5Rjbf7gWz5izrn0UWQy9RlQY4cQDPkwJfVqATnVsOa/+zzJrps12LOgacMsDl/Vit2f01cDiDkG/Rst8v2iGw==",
|
||||
"type": "package",
|
||||
@ -8962,7 +8995,7 @@
|
||||
"privateAssets": "all"
|
||||
}
|
||||
},
|
||||
"runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\9.0.304/PortableRuntimeIdentifierGraph.json"
|
||||
"runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\9.0.311/PortableRuntimeIdentifierGraph.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": 2,
|
||||
"dgSpecHash": "ueA0djhC8vQ=",
|
||||
"dgSpecHash": "E2DnflEnEuk=",
|
||||
"success": true,
|
||||
"projectFilePath": "C:\\Users\\nanxun\\Documents\\IM\\backend\\IMTest\\IMTest.csproj",
|
||||
"expectedPackageFiles": [
|
||||
@ -97,6 +97,7 @@
|
||||
"C:\\Users\\nanxun\\.nuget\\packages\\runtime.ubuntu.14.04-x64.runtime.native.system.security.cryptography.openssl\\4.3.0\\runtime.ubuntu.14.04-x64.runtime.native.system.security.cryptography.openssl.4.3.0.nupkg.sha512",
|
||||
"C:\\Users\\nanxun\\.nuget\\packages\\runtime.ubuntu.16.04-x64.runtime.native.system.security.cryptography.openssl\\4.3.0\\runtime.ubuntu.16.04-x64.runtime.native.system.security.cryptography.openssl.4.3.0.nupkg.sha512",
|
||||
"C:\\Users\\nanxun\\.nuget\\packages\\runtime.ubuntu.16.10-x64.runtime.native.system.security.cryptography.openssl\\4.3.0\\runtime.ubuntu.16.10-x64.runtime.native.system.security.cryptography.openssl.4.3.0.nupkg.sha512",
|
||||
"C:\\Users\\nanxun\\.nuget\\packages\\sixlabors.imagesharp\\3.1.12\\sixlabors.imagesharp.3.1.12.nupkg.sha512",
|
||||
"C:\\Users\\nanxun\\.nuget\\packages\\stackexchange.redis\\2.9.32\\stackexchange.redis.2.9.32.nupkg.sha512",
|
||||
"C:\\Users\\nanxun\\.nuget\\packages\\swashbuckle.aspnetcore\\6.6.2\\swashbuckle.aspnetcore.6.6.2.nupkg.sha512",
|
||||
"C:\\Users\\nanxun\\.nuget\\packages\\swashbuckle.aspnetcore.swagger\\6.6.2\\swashbuckle.aspnetcore.swagger.6.6.2.nupkg.sha512",
|
||||
|
||||
3
backend/IM_API/.gitignore
vendored
3
backend/IM_API/.gitignore
vendored
@ -1,3 +1,4 @@
|
||||
bin/
|
||||
obj/
|
||||
.vs/
|
||||
.vs/
|
||||
uploads/
|
||||
@ -17,11 +17,13 @@ namespace IM_API.Application.EventHandlers.MessageCreatedHandler
|
||||
private readonly IHubContext<ChatHub> _hub;
|
||||
private readonly IMapper _mapper;
|
||||
private readonly IUserService _userService;
|
||||
public SignalREventHandler(IHubContext<ChatHub> hub, IMapper mapper,IUserService userService)
|
||||
public SignalREventHandler(IHubContext<ChatHub> hub, IMapper mapper,
|
||||
IUserService userService)
|
||||
{
|
||||
_hub = hub;
|
||||
_mapper = mapper;
|
||||
_userService = userService;
|
||||
|
||||
}
|
||||
|
||||
public async Task Consume(ConsumeContext<MessageCreatedEvent> context)
|
||||
@ -35,6 +37,10 @@ namespace IM_API.Application.EventHandlers.MessageCreatedHandler
|
||||
var senderinfo = await _userService.GetUserInfoAsync(@event.MsgSenderId);
|
||||
messageBaseVo.SenderName = senderinfo.NickName;
|
||||
messageBaseVo.SenderAvatar = senderinfo.Avatar ?? "";
|
||||
if (messageBaseVo.Type != MessageMsgType.Text)
|
||||
{
|
||||
messageBaseVo.Content = UrlTools.ProcessMessageUrl(messageBaseVo.Content, @event.BaseUrl);
|
||||
}
|
||||
await _hub.Clients.Group(@event.StreamKey).SendAsync("ReceiveMessage", new HubResponse<MessageBaseVo>("Event", messageBaseVo));
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@ -0,0 +1,21 @@
|
||||
using IM_API.Domain.Events;
|
||||
using IM_API.Interface.Services;
|
||||
using MassTransit;
|
||||
|
||||
namespace IM_API.Application.EventHandlers.UploadEventHandler
|
||||
{
|
||||
public class MergeEventHandler : IConsumer<UploadMergeEvent>
|
||||
{
|
||||
private readonly IStorageService _storage;
|
||||
public MergeEventHandler(IStorageService storage)
|
||||
{
|
||||
_storage = storage;
|
||||
}
|
||||
|
||||
public async Task Consume(ConsumeContext<UploadMergeEvent> context)
|
||||
{
|
||||
var @event = context.Message;
|
||||
await _storage.MergeAsync(@event.TaskId, @event.ObjectName, @event.ChunckCount, @event.Parts);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -7,6 +7,7 @@ using IM_API.Application.EventHandlers.GroupRequestHandler;
|
||||
using IM_API.Application.EventHandlers.GroupRequestUpdateHandler;
|
||||
using IM_API.Application.EventHandlers.MessageCreatedHandler;
|
||||
using IM_API.Application.EventHandlers.RequestFriendHandler;
|
||||
using IM_API.Application.EventHandlers.UploadEventHandler;
|
||||
using IM_API.Configs.Options;
|
||||
using IM_API.Domain.Events;
|
||||
using MassTransit;
|
||||
@ -37,6 +38,7 @@ namespace IM_API.Configs
|
||||
x.AddConsumer<RequestDbHandler>();
|
||||
x.AddConsumer<SignalRHandler>();
|
||||
x.AddConsumer<RequestUpdateSignalrHandler>();
|
||||
x.AddConsumer<MergeEventHandler>();
|
||||
x.UsingRabbitMq((ctx,cfg) =>
|
||||
{
|
||||
cfg.Host(options.Host, "/", h =>
|
||||
|
||||
@ -4,9 +4,12 @@ using IM_API.Dtos;
|
||||
using IM_API.Dtos.Auth;
|
||||
using IM_API.Dtos.Friend;
|
||||
using IM_API.Dtos.Group;
|
||||
using IM_API.Dtos.Message;
|
||||
using IM_API.Dtos.User;
|
||||
using IM_API.Models;
|
||||
using IM_API.Models.Upload;
|
||||
using IM_API.Tools;
|
||||
using IM_API.VOs;
|
||||
using IM_API.VOs.Conversation;
|
||||
using IM_API.VOs.Message;
|
||||
|
||||
@ -171,6 +174,35 @@ namespace IM_API.Configs
|
||||
.ForMember(dest => dest.AuhorityEnum, opt => opt.MapFrom(src => GroupAuhority.REQUIRE_CONSENT))
|
||||
.ForMember(dest => dest.StatusEnum, opt => opt.MapFrom(src => GroupStatus.Normal))
|
||||
;
|
||||
|
||||
//上传任务模型转换
|
||||
CreateMap<CreateUploadTaskDto, UploadTask>()
|
||||
.ForMember(dest => dest.FileName, opt => opt.MapFrom(src => src.FileName))
|
||||
.ForMember(dest => dest.Status, opt => opt.MapFrom(src => UploadStatus.Created))
|
||||
.ForMember(dest => dest.Id, opt => opt.MapFrom(src => Guid.NewGuid()))
|
||||
.ForMember(dest => dest.FileSize, opt => opt.MapFrom(src => src.FileSize))
|
||||
.ForMember(dest => dest.FileHash, opt => opt.MapFrom(src => src.FileHash))
|
||||
.ForMember(dest => dest.ContentType, opt => opt.MapFrom(src => src.ContentType))
|
||||
.ForMember(dest => dest.CreatedAt, opt => opt.MapFrom(src => DateTime.UtcNow))
|
||||
;
|
||||
|
||||
CreateMap<UploadTask, CreateUploadTaskVo>()
|
||||
.ForMember(dest => dest.TaskId, opt => opt.MapFrom(src => src.Id))
|
||||
.ForMember(dest => dest.ChunkSize, opt => opt.MapFrom(src => src.ChunkSize))
|
||||
.ForMember(dest => dest.TotalChunks, opt => opt.MapFrom(src => src.TotalChunks))
|
||||
.ForMember(dest => dest.Concurrency, opt => opt.MapFrom(src => 5))
|
||||
.ForMember(dest => dest.Skip, opt => opt.MapFrom(src => false))
|
||||
.ForMember(dest => dest.Url, opt => opt.MapFrom(src => src.ObjectName))
|
||||
;
|
||||
|
||||
CreateMap<UploadTask, ImageDto>()
|
||||
.ForMember(dest => dest.Url, opt => opt.MapFrom(src => src.ObjectName))
|
||||
.ForMember(dest => dest.FileId, opt => opt.MapFrom(src => src.Id))
|
||||
.ForMember(dest => dest.Provider, opt => opt.MapFrom(src => src.StorageProvider))
|
||||
.ForMember(dest => dest.Format, opt => opt.MapFrom(src => src.ContentType))
|
||||
.ForMember(dest => dest.Size, opt => opt.MapFrom(src => src.FileSize));
|
||||
|
||||
CreateMap<ImageDto, VideoDto>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
8
backend/IM_API/Configs/Options/FileUploadOptions.cs
Normal file
8
backend/IM_API/Configs/Options/FileUploadOptions.cs
Normal file
@ -0,0 +1,8 @@
|
||||
namespace IM_API.Configs.Options
|
||||
{
|
||||
public class FileUploadOptions
|
||||
{
|
||||
public string DefaultStorage { get; set; }
|
||||
public int ChunkSize { get; set; }
|
||||
}
|
||||
}
|
||||
@ -29,7 +29,8 @@ namespace IM_API.Configs
|
||||
services.AddScoped<IGroupService, GroupService>();
|
||||
services.AddScoped<ISequenceIdService, SequenceIdService>();
|
||||
services.AddScoped<ICacheService, RedisCacheService>();
|
||||
services.AddScoped<IEventBus, InMemoryEventBus>();
|
||||
services.AddScoped<IStorageService, LocalStorageService>();
|
||||
services.AddScoped<IUploadTaskService, UploadTaskService>();
|
||||
services.AddSingleton<IJWTService, JWTService>();
|
||||
services.AddSingleton<IRefreshTokenService, RedisRefreshTokenService>();
|
||||
services.AddSingleton<IDistributedLockFactory>(sp =>
|
||||
|
||||
@ -3,6 +3,8 @@ using IM_API.Domain.Events;
|
||||
using IM_API.Dtos;
|
||||
using IM_API.Dtos.Message;
|
||||
using IM_API.Interface.Services;
|
||||
using IM_API.Models;
|
||||
using IM_API.Tools;
|
||||
using IM_API.VOs.Message;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
@ -19,12 +21,12 @@ namespace IM_API.Controllers
|
||||
{
|
||||
private readonly IMessageSevice _messageService;
|
||||
private readonly ILogger<MessageController> _logger;
|
||||
private readonly IEventBus _eventBus;
|
||||
public MessageController(IMessageSevice messageService, ILogger<MessageController> logger, IEventBus eventBus)
|
||||
public MessageController(IMessageSevice messageService,
|
||||
ILogger<MessageController> logger)
|
||||
{
|
||||
_messageService = messageService;
|
||||
_logger = logger;
|
||||
_eventBus = eventBus;
|
||||
|
||||
}
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(BaseResponse<MessageBaseVo>), StatusCodes.Status200OK)]
|
||||
@ -32,15 +34,23 @@ namespace IM_API.Controllers
|
||||
{
|
||||
var userIdstr = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
MessageBaseVo messageBaseVo = new MessageBaseVo();
|
||||
var handledMessage = await _messageService.HandleFileMessageContentAsync(dto);
|
||||
if(dto.ChatType == Models.ChatType.PRIVATE)
|
||||
{
|
||||
messageBaseVo = await _messageService.SendPrivateMessageAsync(int.Parse(userIdstr), dto.ReceiverId, dto);
|
||||
messageBaseVo = await _messageService.SendPrivateMessageAsync(int.Parse(userIdstr), dto.ReceiverId, handledMessage);
|
||||
}
|
||||
else
|
||||
{
|
||||
messageBaseVo = await _messageService.SendGroupMessageAsync(int.Parse(userIdstr), dto.ReceiverId, dto);
|
||||
messageBaseVo = await _messageService.SendGroupMessageAsync(int.Parse(userIdstr), dto.ReceiverId, handledMessage);
|
||||
}
|
||||
return Ok(new BaseResponse<MessageBaseVo>(messageBaseVo));
|
||||
|
||||
if (messageBaseVo.Type != MessageMsgType.Text)
|
||||
{
|
||||
var request = HttpContext?.Request;
|
||||
var baseUrl = $"{request.Scheme}://{request.Host}";
|
||||
messageBaseVo.Content = UrlTools.ProcessMessageUrl(messageBaseVo.Content, baseUrl);
|
||||
}
|
||||
return Ok(new BaseResponse<MessageBaseVo>(messageBaseVo));
|
||||
}
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(BaseResponse<List<MessageBaseVo>>), StatusCodes.Status200OK)]
|
||||
|
||||
124
backend/IM_API/Controllers/UploadController.cs
Normal file
124
backend/IM_API/Controllers/UploadController.cs
Normal file
@ -0,0 +1,124 @@
|
||||
using IM_API.Dtos;
|
||||
using IM_API.Interface.Services;
|
||||
using IM_API.Models.Upload;
|
||||
using IM_API.Tools;
|
||||
using IM_API.VOs;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
using StackExchange.Redis;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using IDatabase = StackExchange.Redis.IDatabase;
|
||||
|
||||
namespace IM_API.Controllers
|
||||
{
|
||||
[Authorize]
|
||||
[Route("api/[controller]")]
|
||||
[ApiController]
|
||||
public class UploadController : ControllerBase
|
||||
{
|
||||
private readonly IWebHostEnvironment _env;
|
||||
private readonly IStorageService _storage;
|
||||
private readonly IDatabase _redis;
|
||||
public UploadController(IWebHostEnvironment env, IStorageService storage, IConnectionMultiplexer connectionMultiplexer)
|
||||
{
|
||||
_env = env;
|
||||
_storage = storage;
|
||||
_redis = connectionMultiplexer.GetDatabase();
|
||||
}
|
||||
[HttpPost("local/{taskId}/parts/{partNumber}")]
|
||||
[ProducesResponseType(typeof(BaseResponse<object?>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> LocalUpload(Guid taskId, int partNumber, IFormFile file)
|
||||
{
|
||||
var baseDir = Path.Combine(_env.ContentRootPath, "uploads"); // 项目根目录下 uploads
|
||||
Directory.CreateDirectory(baseDir);
|
||||
|
||||
var path = Path.Combine(baseDir, "temp", taskId.ToString(), $"{partNumber}.part.tmp");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
|
||||
using var stream = System.IO.File.Create(path);
|
||||
await file.CopyToAsync(stream);
|
||||
|
||||
await _redis.SetAddAsync(RedisKeys.GetUploadPartKey(taskId), partNumber);
|
||||
|
||||
return Ok(new BaseResponse<object?>());
|
||||
}
|
||||
|
||||
[HttpPost("CreateTask")]
|
||||
[ProducesResponseType(typeof(BaseResponse<CreateUploadTaskVo>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> CreateUpload(CreateUploadTaskDto dto)
|
||||
{
|
||||
var vo = await _storage.InitTaskAsync(dto);
|
||||
return Ok(new BaseResponse<CreateUploadTaskVo>(vo));
|
||||
}
|
||||
|
||||
[HttpPost("CreatePart")]
|
||||
public async Task<IActionResult> CreatePart(Guid taskId, int partNum)
|
||||
{
|
||||
var vo = await _storage.CreatePartInstructionAsync(taskId, partNum);
|
||||
return Ok(new BaseResponse<UploadPartInstructionVo>(vo));
|
||||
}
|
||||
|
||||
[HttpPost("CompleteTask")]
|
||||
public async Task<IActionResult> CompleteTask([FromQuery]Guid taskId, [FromBody]List<UploadPartDto> dtos)
|
||||
{
|
||||
var taskIdRes = await _storage.CompleteAsync(taskId, dtos);
|
||||
return Ok(new BaseResponse<string>(data: taskIdRes.ToString()));
|
||||
}
|
||||
|
||||
[HttpGet("events/{taskId}")]
|
||||
[AllowAnonymous]
|
||||
public async Task Events(Guid taskId)
|
||||
{
|
||||
Response.Headers.Add("Content-Type", "text/event-stream");
|
||||
Response.Headers.Add("Cache-Control", "no-cache");
|
||||
Response.Headers.Add("Connection", "keep-alive");
|
||||
var lastProgress = -1;
|
||||
|
||||
while (!HttpContext.RequestAborted.IsCancellationRequested)
|
||||
{
|
||||
var hash = await _redis.HashGetAllAsync(RedisKeys.MergeStatus(taskId));
|
||||
if (hash.Length == 0)
|
||||
{
|
||||
await Task.Delay(1000);
|
||||
continue;
|
||||
}
|
||||
var status = hash.FirstOrDefault(x => x.Name == "status").Value;
|
||||
var progress = hash.FirstOrDefault(x => x.Name == "progress").Value;
|
||||
var url = hash.FirstOrDefault(x => x.Name == "url").Value;
|
||||
|
||||
// 避免重复发送
|
||||
if (progress != lastProgress)
|
||||
{
|
||||
var data = new
|
||||
{
|
||||
status = status.ToString(),
|
||||
progress = progress.ToString(),
|
||||
url = (string)url
|
||||
};
|
||||
|
||||
await Response.WriteAsync($"data: {JsonSerializer.Serialize(data)}\n\n");
|
||||
await Response.Body.FlushAsync();
|
||||
|
||||
// 完成后关闭 SSE
|
||||
if (status == "Completed")
|
||||
break;
|
||||
|
||||
await Task.Delay(1000); // 每秒检查一次
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("upload/{hash}")]
|
||||
public async Task<IActionResult> UploadSmallFile(IFormFile file,string hash)
|
||||
{
|
||||
using var stream = file.OpenReadStream();
|
||||
var res = await _storage.UploadSmallFileAsync(stream, file.FileName, file.ContentType, file.Length, hash);
|
||||
return Ok(new BaseResponse<UploadTask>(res));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@ -16,6 +16,7 @@ namespace IM_API.Domain.Events
|
||||
public DateTimeOffset MessageCreated { get; set; }
|
||||
public string StreamKey { get; set; }
|
||||
public Guid ClientMsgId { get; set; }
|
||||
public string BaseUrl { get; set; }
|
||||
|
||||
|
||||
|
||||
|
||||
13
backend/IM_API/Domain/Events/UploadMergeEvent.cs
Normal file
13
backend/IM_API/Domain/Events/UploadMergeEvent.cs
Normal file
@ -0,0 +1,13 @@
|
||||
using IM_API.Dtos;
|
||||
|
||||
namespace IM_API.Domain.Events
|
||||
{
|
||||
public record UploadMergeEvent : DomainEvent
|
||||
{
|
||||
public override string EventType => "IM.FILES_UPLOAD_MERGE";
|
||||
public Guid TaskId { get; init; }
|
||||
public List<UploadPartDto> Parts { get; init; }
|
||||
public int ChunckCount { get; set; }
|
||||
public string ObjectName { get; set; }
|
||||
}
|
||||
}
|
||||
10
backend/IM_API/Dtos/CreateUploadTaskDto.cs
Normal file
10
backend/IM_API/Dtos/CreateUploadTaskDto.cs
Normal file
@ -0,0 +1,10 @@
|
||||
namespace IM_API.Dtos
|
||||
{
|
||||
public class CreateUploadTaskDto
|
||||
{
|
||||
public string FileName { get; set; } = default!;
|
||||
public long FileSize { get; set; }
|
||||
public string ContentType { get; set; } = default!;
|
||||
public string FileHash { get; set; } = default!;
|
||||
}
|
||||
}
|
||||
26
backend/IM_API/Dtos/Message/MessagTypeDto.cs
Normal file
26
backend/IM_API/Dtos/Message/MessagTypeDto.cs
Normal file
@ -0,0 +1,26 @@
|
||||
namespace IM_API.Dtos.Message
|
||||
{
|
||||
public class RequestMessageType
|
||||
{
|
||||
public Guid FileId { get; set; }
|
||||
public long Size { get; set; }
|
||||
}
|
||||
public class BaseMessageType: RequestMessageType
|
||||
{
|
||||
public string Url { get; set; }
|
||||
public string Provider { get; set; }
|
||||
public string Format { get; set; }
|
||||
public string Text { get; set; }
|
||||
}
|
||||
public class ImageDto() : BaseMessageType
|
||||
{
|
||||
public string Thumb { get; set; }
|
||||
public int W { get; set; }
|
||||
public int H { get; set; }
|
||||
}
|
||||
|
||||
public class VideoDto() : ImageDto
|
||||
{
|
||||
public int Duration { get; set; }
|
||||
}
|
||||
}
|
||||
@ -10,7 +10,7 @@ namespace IM_API.Dtos
|
||||
public Guid MsgId { get; init; }
|
||||
public int SenderId { get; init; }
|
||||
public int ReceiverId { get; init; }
|
||||
public string Content { get; init; } = default!;
|
||||
public string Content { get; set; } = default!;
|
||||
public DateTimeOffset TimeStamp { get; init; }
|
||||
public MessageBaseDto() { }
|
||||
}
|
||||
|
||||
8
backend/IM_API/Dtos/UploadPartDto.cs
Normal file
8
backend/IM_API/Dtos/UploadPartDto.cs
Normal file
@ -0,0 +1,8 @@
|
||||
namespace IM_API.Dtos
|
||||
{
|
||||
public class UploadPartDto
|
||||
{
|
||||
public int PartNumber { get; set; }
|
||||
public string? ETag { get; set; }
|
||||
}
|
||||
}
|
||||
@ -28,6 +28,7 @@
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.3" />
|
||||
<PackageReference Include="RedLock.net" Version="2.3.2" />
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
|
||||
<PackageReference Include="StackExchange.Redis" Version="2.9.32" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.14.0" />
|
||||
|
||||
@ -48,6 +48,6 @@ namespace IM_API.Interface.Services
|
||||
Task<bool> MarkConversationAsReadAsync(int userId,int? userBId,int? groupId);
|
||||
Task<bool> RecallMessageAsync(int userId,int messageId);
|
||||
|
||||
|
||||
Task<MessageBaseDto> HandleFileMessageContentAsync(MessageBaseDto dto);
|
||||
}
|
||||
}
|
||||
|
||||
40
backend/IM_API/Interface/Services/IStorageService.cs
Normal file
40
backend/IM_API/Interface/Services/IStorageService.cs
Normal file
@ -0,0 +1,40 @@
|
||||
using IM_API.Dtos;
|
||||
using IM_API.Models.Upload;
|
||||
using IM_API.VOs;
|
||||
|
||||
namespace IM_API.Interface.Services
|
||||
{
|
||||
public interface IStorageService
|
||||
{
|
||||
string ProviderName { get; }
|
||||
UploadMode Mode { get; }
|
||||
/// <summary>
|
||||
/// 初始化上传任务
|
||||
/// </summary>
|
||||
/// <param name="dto"></param>
|
||||
/// <returns></returns>
|
||||
Task<CreateUploadTaskVo> InitTaskAsync(CreateUploadTaskDto dto);
|
||||
/// <summary>
|
||||
/// 创建分片任务
|
||||
/// </summary>
|
||||
/// <param name="taskId">文件上传任务ID</param>
|
||||
/// <param name="partNumer"></param>
|
||||
/// <returns></returns>
|
||||
Task<UploadPartInstructionVo> CreatePartInstructionAsync(Guid taskId, int partNumer);
|
||||
Task<Guid> CompleteAsync(
|
||||
Guid taskId,
|
||||
List<UploadPartDto> parts
|
||||
);
|
||||
|
||||
Task MergeAsync(Guid taskId, string objectName, int totalChunks, List<UploadPartDto> parts);
|
||||
|
||||
Task<UploadTask> UploadSmallFileAsync(Stream stream, string fileName, string fileType, long size, string hash);
|
||||
string GetDownloadUrl(string objectname);
|
||||
}
|
||||
public enum UploadMode
|
||||
{
|
||||
Proxy, // 本地 / 后端中转
|
||||
Direct // 云直传
|
||||
}
|
||||
|
||||
}
|
||||
12
backend/IM_API/Interface/Services/IUploadTaskService.cs
Normal file
12
backend/IM_API/Interface/Services/IUploadTaskService.cs
Normal file
@ -0,0 +1,12 @@
|
||||
using IM_API.Models.Upload;
|
||||
|
||||
namespace IM_API.Interface.Services
|
||||
{
|
||||
public interface IUploadTaskService
|
||||
{
|
||||
Task AddAsync(UploadTask task);
|
||||
Task<UploadTask?> GetTaskAsync(Guid taskId);
|
||||
Task<UploadTask?> GetTaskAsync(string hash);
|
||||
Task UpdateStatusAsync(Guid taskId, UploadStatus status);
|
||||
}
|
||||
}
|
||||
1167
backend/IM_API/Migrations/20260214101014_add-uploadtask.Designer.cs
generated
Normal file
1167
backend/IM_API/Migrations/20260214101014_add-uploadtask.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
52
backend/IM_API/Migrations/20260214101014_add-uploadtask.cs
Normal file
52
backend/IM_API/Migrations/20260214101014_add-uploadtask.cs
Normal file
@ -0,0 +1,52 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace IM_API.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class adduploadtask : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "UploadTasks",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||
FileName = table.Column<string>(type: "longtext", nullable: false, collation: "latin1_swedish_ci")
|
||||
.Annotation("MySql:CharSet", "latin1"),
|
||||
FileSize = table.Column<long>(type: "bigint", nullable: false),
|
||||
FileHash = table.Column<string>(type: "longtext", nullable: false, collation: "latin1_swedish_ci")
|
||||
.Annotation("MySql:CharSet", "latin1"),
|
||||
ContentType = table.Column<string>(type: "longtext", nullable: false, collation: "latin1_swedish_ci")
|
||||
.Annotation("MySql:CharSet", "latin1"),
|
||||
ChunkSize = table.Column<int>(type: "int", nullable: false),
|
||||
TotalChunks = table.Column<int>(type: "int", nullable: false),
|
||||
Status = table.Column<int>(type: "int", nullable: false),
|
||||
StorageProvider = table.Column<string>(type: "longtext", nullable: false, collation: "latin1_swedish_ci")
|
||||
.Annotation("MySql:CharSet", "latin1"),
|
||||
ObjectName = table.Column<string>(type: "longtext", nullable: false, collation: "latin1_swedish_ci")
|
||||
.Annotation("MySql:CharSet", "latin1"),
|
||||
ProviderUploadId = table.Column<string>(type: "longtext", nullable: true, collation: "latin1_swedish_ci")
|
||||
.Annotation("MySql:CharSet", "latin1"),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "datetime(6)", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PRIMARY", x => x.Id);
|
||||
})
|
||||
.Annotation("MySql:CharSet", "latin1")
|
||||
.Annotation("Relational:Collation", "latin1_swedish_ci");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "UploadTasks");
|
||||
}
|
||||
}
|
||||
}
|
||||
1170
backend/IM_API/Migrations/20260214131542_update-uploadtask.Designer.cs
generated
Normal file
1170
backend/IM_API/Migrations/20260214131542_update-uploadtask.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
186
backend/IM_API/Migrations/20260214131542_update-uploadtask.cs
Normal file
186
backend/IM_API/Migrations/20260214131542_update-uploadtask.cs
Normal file
@ -0,0 +1,186 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace IM_API.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class updateuploadtask : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.RenameTable(
|
||||
name: "UploadTasks",
|
||||
newName: "upload_tasks");
|
||||
|
||||
migrationBuilder.AlterTable(
|
||||
name: "upload_tasks")
|
||||
.Annotation("MySql:CharSet", "utf8mb4")
|
||||
.Annotation("Relational:Collation", "utf8mb4_general_ci")
|
||||
.OldAnnotation("MySql:CharSet", "latin1")
|
||||
.OldAnnotation("Relational:Collation", "latin1_swedish_ci");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "StorageProvider",
|
||||
table: "upload_tasks",
|
||||
type: "longtext",
|
||||
nullable: false,
|
||||
collation: "utf8mb4_general_ci",
|
||||
oldClrType: typeof(string),
|
||||
oldType: "longtext")
|
||||
.Annotation("MySql:CharSet", "utf8mb4")
|
||||
.OldAnnotation("MySql:CharSet", "latin1")
|
||||
.OldAnnotation("Relational:Collation", "latin1_swedish_ci");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "ProviderUploadId",
|
||||
table: "upload_tasks",
|
||||
type: "longtext",
|
||||
nullable: true,
|
||||
collation: "utf8mb4_general_ci",
|
||||
oldClrType: typeof(string),
|
||||
oldType: "longtext",
|
||||
oldNullable: true)
|
||||
.Annotation("MySql:CharSet", "utf8mb4")
|
||||
.OldAnnotation("MySql:CharSet", "latin1")
|
||||
.OldAnnotation("Relational:Collation", "latin1_swedish_ci");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "ObjectName",
|
||||
table: "upload_tasks",
|
||||
type: "longtext",
|
||||
nullable: false,
|
||||
collation: "utf8mb4_general_ci",
|
||||
oldClrType: typeof(string),
|
||||
oldType: "longtext")
|
||||
.Annotation("MySql:CharSet", "utf8mb4")
|
||||
.OldAnnotation("MySql:CharSet", "latin1")
|
||||
.OldAnnotation("Relational:Collation", "latin1_swedish_ci");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "FileName",
|
||||
table: "upload_tasks",
|
||||
type: "longtext",
|
||||
nullable: false,
|
||||
collation: "utf8mb4_general_ci",
|
||||
oldClrType: typeof(string),
|
||||
oldType: "longtext")
|
||||
.Annotation("MySql:CharSet", "utf8mb4")
|
||||
.OldAnnotation("MySql:CharSet", "latin1")
|
||||
.OldAnnotation("Relational:Collation", "latin1_swedish_ci");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "FileHash",
|
||||
table: "upload_tasks",
|
||||
type: "longtext",
|
||||
nullable: false,
|
||||
collation: "utf8mb4_general_ci",
|
||||
oldClrType: typeof(string),
|
||||
oldType: "longtext")
|
||||
.Annotation("MySql:CharSet", "utf8mb4")
|
||||
.OldAnnotation("MySql:CharSet", "latin1")
|
||||
.OldAnnotation("Relational:Collation", "latin1_swedish_ci");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "ContentType",
|
||||
table: "upload_tasks",
|
||||
type: "longtext",
|
||||
nullable: false,
|
||||
collation: "utf8mb4_general_ci",
|
||||
oldClrType: typeof(string),
|
||||
oldType: "longtext")
|
||||
.Annotation("MySql:CharSet", "utf8mb4")
|
||||
.OldAnnotation("MySql:CharSet", "latin1")
|
||||
.OldAnnotation("Relational:Collation", "latin1_swedish_ci");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.RenameTable(
|
||||
name: "upload_tasks",
|
||||
newName: "UploadTasks");
|
||||
|
||||
migrationBuilder.AlterTable(
|
||||
name: "UploadTasks")
|
||||
.Annotation("MySql:CharSet", "latin1")
|
||||
.Annotation("Relational:Collation", "latin1_swedish_ci")
|
||||
.OldAnnotation("MySql:CharSet", "utf8mb4")
|
||||
.OldAnnotation("Relational:Collation", "utf8mb4_general_ci");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "StorageProvider",
|
||||
table: "UploadTasks",
|
||||
type: "longtext",
|
||||
nullable: false,
|
||||
collation: "latin1_swedish_ci",
|
||||
oldClrType: typeof(string),
|
||||
oldType: "longtext")
|
||||
.Annotation("MySql:CharSet", "latin1")
|
||||
.OldAnnotation("MySql:CharSet", "utf8mb4")
|
||||
.OldAnnotation("Relational:Collation", "utf8mb4_general_ci");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "ProviderUploadId",
|
||||
table: "UploadTasks",
|
||||
type: "longtext",
|
||||
nullable: true,
|
||||
collation: "latin1_swedish_ci",
|
||||
oldClrType: typeof(string),
|
||||
oldType: "longtext",
|
||||
oldNullable: true)
|
||||
.Annotation("MySql:CharSet", "latin1")
|
||||
.OldAnnotation("MySql:CharSet", "utf8mb4")
|
||||
.OldAnnotation("Relational:Collation", "utf8mb4_general_ci");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "ObjectName",
|
||||
table: "UploadTasks",
|
||||
type: "longtext",
|
||||
nullable: false,
|
||||
collation: "latin1_swedish_ci",
|
||||
oldClrType: typeof(string),
|
||||
oldType: "longtext")
|
||||
.Annotation("MySql:CharSet", "latin1")
|
||||
.OldAnnotation("MySql:CharSet", "utf8mb4")
|
||||
.OldAnnotation("Relational:Collation", "utf8mb4_general_ci");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "FileName",
|
||||
table: "UploadTasks",
|
||||
type: "longtext",
|
||||
nullable: false,
|
||||
collation: "latin1_swedish_ci",
|
||||
oldClrType: typeof(string),
|
||||
oldType: "longtext")
|
||||
.Annotation("MySql:CharSet", "latin1")
|
||||
.OldAnnotation("MySql:CharSet", "utf8mb4")
|
||||
.OldAnnotation("Relational:Collation", "utf8mb4_general_ci");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "FileHash",
|
||||
table: "UploadTasks",
|
||||
type: "longtext",
|
||||
nullable: false,
|
||||
collation: "latin1_swedish_ci",
|
||||
oldClrType: typeof(string),
|
||||
oldType: "longtext")
|
||||
.Annotation("MySql:CharSet", "latin1")
|
||||
.OldAnnotation("MySql:CharSet", "utf8mb4")
|
||||
.OldAnnotation("Relational:Collation", "utf8mb4_general_ci");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "ContentType",
|
||||
table: "UploadTasks",
|
||||
type: "longtext",
|
||||
nullable: false,
|
||||
collation: "latin1_swedish_ci",
|
||||
oldClrType: typeof(string),
|
||||
oldType: "longtext")
|
||||
.Annotation("MySql:CharSet", "latin1")
|
||||
.OldAnnotation("MySql:CharSet", "utf8mb4")
|
||||
.OldAnnotation("Relational:Collation", "utf8mb4_general_ci");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -768,6 +768,59 @@ namespace IM_API.Migrations
|
||||
MySqlEntityTypeBuilderExtensions.UseCollation(b, "utf8mb4_general_ci");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("IM_API.Models.Upload.UploadTask", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<int>("ChunkSize")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("ContentType")
|
||||
.IsRequired()
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<string>("FileHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<string>("FileName")
|
||||
.IsRequired()
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<long>("FileSize")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("ObjectName")
|
||||
.IsRequired()
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<string>("ProviderUploadId")
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("StorageProvider")
|
||||
.IsRequired()
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<int>("TotalChunks")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("PRIMARY");
|
||||
|
||||
b.ToTable("upload_tasks", (string)null);
|
||||
|
||||
MySqlEntityTypeBuilderExtensions.HasCharSet(b, "utf8mb4");
|
||||
MySqlEntityTypeBuilderExtensions.UseCollation(b, "utf8mb4_general_ci");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("IM_API.Models.User", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using IM_API.Models.Upload;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace IM_API.Models;
|
||||
@ -18,6 +19,7 @@ public partial class ImContext : DbContext
|
||||
public virtual DbSet<Device> Devices { get; set; }
|
||||
|
||||
public virtual DbSet<File> Files { get; set; }
|
||||
public virtual DbSet<UploadTask> UploadTasks { get; set; }
|
||||
|
||||
public virtual DbSet<Friend> Friends { get; set; }
|
||||
|
||||
@ -208,6 +210,17 @@ public partial class ImContext : DbContext
|
||||
.HasConstraintName("files_ibfk_1");
|
||||
});
|
||||
|
||||
modelBuilder.Entity<UploadTask>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("PRIMARY");
|
||||
|
||||
entity
|
||||
.ToTable("upload_tasks")
|
||||
.HasCharSet("utf8mb4")
|
||||
.UseCollation("utf8mb4_general_ci");
|
||||
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Friend>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("PRIMARY");
|
||||
|
||||
10
backend/IM_API/Models/Upload/UploadStatus.cs
Normal file
10
backend/IM_API/Models/Upload/UploadStatus.cs
Normal file
@ -0,0 +1,10 @@
|
||||
namespace IM_API.Models.Upload
|
||||
{
|
||||
public enum UploadStatus
|
||||
{
|
||||
Created,
|
||||
Uploading,
|
||||
Completed,
|
||||
Aborted
|
||||
}
|
||||
}
|
||||
24
backend/IM_API/Models/Upload/UploadTask.cs
Normal file
24
backend/IM_API/Models/Upload/UploadTask.cs
Normal file
@ -0,0 +1,24 @@
|
||||
namespace IM_API.Models.Upload
|
||||
{
|
||||
public class UploadTask
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public string FileName { get; set; } = default!;
|
||||
public long FileSize { get; set; }
|
||||
public string FileHash { get; set; }
|
||||
public string ContentType { get; set; } = default!;
|
||||
|
||||
public int ChunkSize { get; set; }
|
||||
public int TotalChunks { get; set; }
|
||||
|
||||
public UploadStatus Status { get; set; }
|
||||
|
||||
public string StorageProvider { get; set; } = default!;
|
||||
public string ObjectName { get; set; } = default!;
|
||||
|
||||
public string? ProviderUploadId { get; set; } // OSS/S3 UploadId
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
}
|
||||
@ -7,6 +7,7 @@ using IM_API.Models;
|
||||
using IM_API.Tools;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using StackExchange.Redis;
|
||||
using System.Text;
|
||||
@ -42,7 +43,9 @@ namespace IM_API
|
||||
});
|
||||
|
||||
builder.Services.AddRabbitMQ(configuration.GetSection("RabbitMqOptions").Get<RabbitMQOptions>());
|
||||
|
||||
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
|
||||
builder.Services.AddAllService(configuration);
|
||||
|
||||
builder.Services.AddSignalR().AddJsonProtocol(options =>
|
||||
@ -134,6 +137,23 @@ namespace IM_API
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
string uploadPath = Path.Combine(Directory.GetCurrentDirectory(), "Uploads","files");
|
||||
|
||||
// 2. 如果文件夹不存在则创建,防止程序启动报错
|
||||
if (!Directory.Exists(uploadPath))
|
||||
{
|
||||
Directory.CreateDirectory(uploadPath);
|
||||
}
|
||||
|
||||
// 3. 配置静态文件映射
|
||||
app.UseStaticFiles(new StaticFileOptions
|
||||
{
|
||||
// 指定物理磁盘路径
|
||||
FileProvider = new PhysicalFileProvider(uploadPath),
|
||||
// 指定浏览器访问的虚拟前缀(例如:http://localhost:5000/files/1.jpg)
|
||||
RequestPath = "/uploads/files"
|
||||
});
|
||||
|
||||
app.UseCors();
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
|
||||
236
backend/IM_API/Services/LocalStorageService.cs
Normal file
236
backend/IM_API/Services/LocalStorageService.cs
Normal file
@ -0,0 +1,236 @@
|
||||
using AutoMapper;
|
||||
using IM_API.Configs.Options;
|
||||
using IM_API.Domain.Events;
|
||||
using IM_API.Dtos;
|
||||
using IM_API.Exceptions;
|
||||
using IM_API.Interface.Services;
|
||||
using IM_API.Models.Upload;
|
||||
using IM_API.Tools;
|
||||
using IM_API.VOs;
|
||||
using MassTransit;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
using StackExchange.Redis;
|
||||
using System.Security.AccessControl;
|
||||
using System.Security.Claims;
|
||||
using System.Threading.Tasks;
|
||||
using IDatabase = StackExchange.Redis.IDatabase;
|
||||
|
||||
namespace IM_API.Services
|
||||
{
|
||||
public class LocalStorageService : IStorageService
|
||||
{
|
||||
private readonly IMapper _mapper;
|
||||
private readonly IHttpContextAccessor _httpContext;
|
||||
private FileUploadOptions _options;
|
||||
private readonly IUploadTaskService _uploadTaskService;
|
||||
private readonly IDatabase _redis;
|
||||
private readonly IHostEnvironment _env;
|
||||
private readonly ILogger<LocalStorageService> _logger;
|
||||
private readonly IPublishEndpoint _endpoint;
|
||||
public LocalStorageService(IMapper mapper, IHttpContextAccessor httpContextAccessor,
|
||||
IConfiguration configuration, IUploadTaskService uploadTaskService,
|
||||
IConnectionMultiplexer connectionMultiplexer, IHostEnvironment hostEnvironment
|
||||
, ILogger<LocalStorageService> logger, IPublishEndpoint publishEndpoint)
|
||||
{
|
||||
_mapper = mapper;
|
||||
_httpContext = httpContextAccessor;
|
||||
_options = configuration.GetSection("FileUploadOptions").Get<FileUploadOptions>()!;
|
||||
_uploadTaskService = uploadTaskService;
|
||||
_redis = connectionMultiplexer.GetDatabase();
|
||||
_env = hostEnvironment;
|
||||
_logger = logger;
|
||||
_endpoint = publishEndpoint;
|
||||
}
|
||||
|
||||
public UploadMode Mode => UploadMode.Proxy;
|
||||
public string ProviderName => "Local";
|
||||
|
||||
public async Task<Guid> CompleteAsync(Guid taskId, List<UploadPartDto> parts)
|
||||
{
|
||||
var task = await _uploadTaskService.GetTaskAsync(taskId);
|
||||
|
||||
if(task is null)
|
||||
throw new BaseException(CodeDefine.CHUNKE_NOT_FOUND);
|
||||
|
||||
var partsToCheck = Enumerable.Range(1, task.TotalChunks)
|
||||
.Select(i => (RedisValue)i).ToArray();
|
||||
|
||||
var results = await _redis.SetContainsAsync(RedisKeys.GetUploadPartKey(taskId), partsToCheck);
|
||||
// 3. 快速判断是否全部存在
|
||||
bool isAllUploaded = results.All(exists => exists);
|
||||
if (!isAllUploaded) throw new BaseException(CodeDefine.CHUNKE_NOT_FOUND);
|
||||
|
||||
await _endpoint.Publish(new UploadMergeEvent
|
||||
{
|
||||
AggregateId = taskId.ToString(),
|
||||
OccurredAt = DateTime.UtcNow,
|
||||
EventId = Guid.NewGuid(),
|
||||
OperatorId = 0,
|
||||
Parts = parts,
|
||||
TaskId = taskId,
|
||||
ChunckCount = task.TotalChunks,
|
||||
ObjectName = task.ObjectName
|
||||
|
||||
});
|
||||
return taskId;
|
||||
}
|
||||
|
||||
public async Task MergeAsync(Guid taskId, string objectName, int totalChunks, List<UploadPartDto> parts)
|
||||
{
|
||||
var baseDir = Path.Combine(_env.ContentRootPath, "uploads");
|
||||
var tempPath = Path.Combine(baseDir, "temp", taskId.ToString()); // 项目根目录下 uploads // 最终文件存储路径(这里可以用你之前 ObjectNameGenerator 生成的名字)
|
||||
var finalPath = Path.Combine(baseDir, "files", objectName);
|
||||
var finalDir = Path.GetDirectoryName(finalPath);
|
||||
Directory.CreateDirectory(finalDir);
|
||||
try
|
||||
{
|
||||
using (var finalStream = new FileStream(finalPath, FileMode.Create))
|
||||
{
|
||||
for (var i = 1; i <= totalChunks; i++)
|
||||
{
|
||||
var progress = (i * 100.0 / totalChunks);
|
||||
if (i % 5 == 0 || i == totalChunks)
|
||||
{
|
||||
await _redis.HashSetAsync(RedisKeys.MergeStatus(taskId), new HashEntry[]
|
||||
{
|
||||
new("status", "processing"),
|
||||
new("progress", progress.ToString("F2"))
|
||||
});
|
||||
}
|
||||
var chunkPath = Path.Combine(tempPath, $"{i}.part.tmp");
|
||||
if (!File.Exists(chunkPath))
|
||||
throw new BaseException(CodeDefine.CHUNKE_NOT_FOUND);
|
||||
using (var chunkStream = new FileStream(chunkPath, FileMode.Open))
|
||||
{
|
||||
await chunkStream.CopyToAsync(finalStream);
|
||||
}
|
||||
}
|
||||
Directory.Delete(tempPath, true);
|
||||
await _redis.KeyDeleteAsync(RedisKeys.GetUploadPartKey(taskId));
|
||||
await _uploadTaskService.UpdateStatusAsync(taskId, UploadStatus.Completed);
|
||||
await _redis.HashSetAsync(RedisKeys.MergeStatus(taskId), new HashEntry[]
|
||||
{
|
||||
new("status", "Completed"),
|
||||
new("progress", "100"),
|
||||
new("url", objectName)
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
catch (Exception e) when (e is not BaseException)
|
||||
{
|
||||
_logger.LogError(e, e.Message);
|
||||
throw new BaseException(CodeDefine.CHUNKE_COMBINE_FAIL);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<UploadPartInstructionVo> CreatePartInstructionAsync(Guid taskId, int partNumer)
|
||||
{
|
||||
if (await _redis.SetContainsAsync(RedisKeys.GetUploadPartKey(taskId), partNumer)){
|
||||
return new UploadPartInstructionVo
|
||||
{
|
||||
PartNumber = partNumer,
|
||||
Skip = true,
|
||||
Headers = new Dictionary<string, string>()
|
||||
};
|
||||
}
|
||||
var request = _httpContext.HttpContext!.Request;
|
||||
|
||||
var scheme = request.Scheme; // http 或 https
|
||||
var host = request.Host.Value; // localhost:5000 或域名
|
||||
|
||||
var baseUrl = $"{scheme}://{host}/api/upload/local/{taskId}/parts/{partNumer}";
|
||||
var headers = new Dictionary<string, string>();
|
||||
headers.Add("Content-Type", "multipart/form-data");
|
||||
return new UploadPartInstructionVo
|
||||
{
|
||||
Method = "POST",
|
||||
PartNumber = partNumer,
|
||||
Skip = false,
|
||||
Url = baseUrl,
|
||||
Headers = headers
|
||||
};
|
||||
|
||||
|
||||
}
|
||||
|
||||
public async Task<UploadTask> UploadSmallFileAsync(Stream stream, string fileName, string fileType, long size, string hash)
|
||||
{
|
||||
var taskOld = await _uploadTaskService.GetTaskAsync(hash);
|
||||
|
||||
if (taskOld is not null) return taskOld;
|
||||
|
||||
var userId = _httpContext.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
var objectname = ObjectNameGenerator.Generate(new ObjectNameContext
|
||||
{
|
||||
ContentType = fileType,
|
||||
FileName = fileName,
|
||||
UserId = int.Parse(userId)
|
||||
});
|
||||
var path = GetDownloadUrl(objectname);
|
||||
// 4. 将 Stream 写入本地文件
|
||||
using (var fileStream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None))
|
||||
{
|
||||
await stream.CopyToAsync(fileStream);
|
||||
}
|
||||
var task = new UploadTask
|
||||
{
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
ChunkSize = (int)size,
|
||||
ContentType = fileType,
|
||||
FileHash = hash,
|
||||
FileName = fileName,
|
||||
FileSize = size,
|
||||
Id = Guid.NewGuid(),
|
||||
ObjectName = objectname,
|
||||
ProviderUploadId = Guid.NewGuid().ToString(),
|
||||
Status = UploadStatus.Completed,
|
||||
StorageProvider = ProviderName,
|
||||
TotalChunks = 1
|
||||
};
|
||||
await _uploadTaskService.AddAsync(task);
|
||||
return task;
|
||||
|
||||
}
|
||||
|
||||
public string GetDownloadUrl(string objectname)
|
||||
{
|
||||
var baseDir = Path.Combine(_env.ContentRootPath, "uploads"); // 最终文件存储路径(这里可以用你之前 ObjectNameGenerator 生成的名字)
|
||||
var finalPath = Path.Combine(baseDir, "files", objectname);
|
||||
var finalDir = Path.GetDirectoryName(finalPath);
|
||||
Directory.CreateDirectory(finalDir);
|
||||
return finalPath;
|
||||
}
|
||||
|
||||
public async Task<CreateUploadTaskVo> InitTaskAsync(CreateUploadTaskDto dto)
|
||||
{
|
||||
var userId = _httpContext.HttpContext.User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
UploadTask task = _mapper.Map<UploadTask>(dto);
|
||||
var taskOld = await _uploadTaskService.GetTaskAsync(dto.FileHash);
|
||||
if(taskOld != null)
|
||||
{
|
||||
var t = _mapper.Map<CreateUploadTaskVo>(taskOld);
|
||||
t.Skip = false;
|
||||
if (taskOld.Status == UploadStatus.Completed)
|
||||
{
|
||||
t.Skip = true;
|
||||
}
|
||||
return t;
|
||||
task = taskOld;
|
||||
}
|
||||
task.ObjectName = ObjectNameGenerator.Generate(new ObjectNameContext
|
||||
{
|
||||
ContentType = task.ContentType,
|
||||
FileName = task.FileName,
|
||||
UserId = int.Parse(userId)
|
||||
});
|
||||
task.StorageProvider = ProviderName;
|
||||
task.ProviderUploadId = Guid.NewGuid().ToString();
|
||||
task.ChunkSize = _options.ChunkSize;
|
||||
task.TotalChunks = (int)Math.Ceiling((double)task.FileSize / _options.ChunkSize);
|
||||
await _uploadTaskService.AddAsync(task);
|
||||
return _mapper.Map<CreateUploadTaskVo>(task);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -10,9 +10,11 @@ using IM_API.Tools;
|
||||
using IM_API.VOs.Message;
|
||||
using MassTransit;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Newtonsoft.Json;
|
||||
using StackExchange.Redis;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using static MassTransit.Monitoring.Performance.BuiltInCounters;
|
||||
using static Microsoft.EntityFrameworkCore.DbLoggerCategory;
|
||||
|
||||
@ -28,10 +30,13 @@ namespace IM_API.Services
|
||||
private readonly IPublishEndpoint _endpoint;
|
||||
private readonly ISequenceIdService _sequenceIdService;
|
||||
private readonly IUserService _userService;
|
||||
private readonly IUploadTaskService _uploadService;
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
public MessageService(
|
||||
ImContext context, ILogger<MessageService> logger, IMapper mapper,
|
||||
IPublishEndpoint publishEndpoint, ISequenceIdService sequenceIdService,
|
||||
IUserService userService
|
||||
IUserService userService, IUploadTaskService uploadTaskService,
|
||||
IHttpContextAccessor httpContextAccessor
|
||||
)
|
||||
{
|
||||
_context = context;
|
||||
@ -41,6 +46,8 @@ namespace IM_API.Services
|
||||
_endpoint = publishEndpoint;
|
||||
_sequenceIdService = sequenceIdService;
|
||||
_userService = userService;
|
||||
_uploadService = uploadTaskService;
|
||||
_httpContextAccessor = httpContextAccessor;
|
||||
}
|
||||
|
||||
public async Task<List<MessageBaseVo>> GetMessagesAsync(int userId,MessageQueryDto dto)
|
||||
@ -91,6 +98,12 @@ namespace IM_API.Services
|
||||
|
||||
foreach (var item in messages)
|
||||
{
|
||||
if(item.Type != MessageMsgType.Text)
|
||||
{
|
||||
var request = _httpContextAccessor.HttpContext?.Request;
|
||||
var baseUrl = $"{request.Scheme}://{request.Host}";
|
||||
item.Content = UrlTools.ProcessMessageUrl(item.Content, baseUrl);
|
||||
}
|
||||
if(userDict.TryGetValue(item.SenderId, out var user))
|
||||
{
|
||||
item.SenderName = user.NickName;
|
||||
@ -143,7 +156,10 @@ namespace IM_API.Services
|
||||
var message = _mapper.Map<Message>(dto);
|
||||
message.StreamKey = StreamKeyBuilder.Group(groupId);
|
||||
message.SequenceId = await _sequenceIdService.GetNextSquenceIdAsync(message.StreamKey);
|
||||
await _endpoint.Publish(_mapper.Map<MessageCreatedEvent>(message));
|
||||
var publishData = _mapper.Map<MessageCreatedEvent>(message);
|
||||
var request = _httpContextAccessor.HttpContext?.Request;
|
||||
publishData.BaseUrl = $"{request.Scheme}://{request.Host}";
|
||||
await _endpoint.Publish(publishData);
|
||||
return _mapper.Map<MessageBaseVo>(message);
|
||||
|
||||
}
|
||||
@ -156,9 +172,40 @@ namespace IM_API.Services
|
||||
var message = _mapper.Map<Message>(dto);
|
||||
message.StreamKey = StreamKeyBuilder.Private(senderId, receiverId);
|
||||
message.SequenceId = await _sequenceIdService.GetNextSquenceIdAsync(message.StreamKey);
|
||||
await _endpoint.Publish(_mapper.Map<MessageCreatedEvent>(message));
|
||||
var publishData = _mapper.Map<MessageCreatedEvent>(message);
|
||||
var request = _httpContextAccessor.HttpContext?.Request;
|
||||
publishData.BaseUrl = $"{request.Scheme}://{request.Host}";
|
||||
await _endpoint.Publish(publishData);
|
||||
return _mapper.Map<MessageBaseVo>(message);
|
||||
}
|
||||
#endregion
|
||||
|
||||
public async Task<MessageBaseDto> HandleFileMessageContentAsync(MessageBaseDto dto)
|
||||
{
|
||||
if(dto.Type == MessageMsgType.Text)
|
||||
{
|
||||
return dto;
|
||||
}
|
||||
|
||||
var dic = JsonConvert.DeserializeObject<Dictionary<string, object>>(dto.Content);
|
||||
|
||||
if (dic == null || !dic.TryGetValue("fileId", out var fileIdObj))
|
||||
throw new BaseException(CodeDefine.PARAMETER_ERROR);
|
||||
|
||||
var fileInfo = await _uploadService.GetTaskAsync(new Guid(fileIdObj.ToString()));
|
||||
|
||||
if (fileInfo is null)
|
||||
throw new BaseException(CodeDefine.FILE_NOT_FOUND);
|
||||
|
||||
|
||||
|
||||
dic["url"] = fileInfo.ObjectName;
|
||||
dic["provider"] = fileInfo.StorageProvider;
|
||||
dic["size"] = fileInfo.FileSize;
|
||||
|
||||
dto.Content = JsonConvert.SerializeObject(dic);
|
||||
|
||||
return dto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
43
backend/IM_API/Services/UploadTaskService.cs
Normal file
43
backend/IM_API/Services/UploadTaskService.cs
Normal file
@ -0,0 +1,43 @@
|
||||
using IM_API.Interface.Services;
|
||||
using IM_API.Models;
|
||||
using IM_API.Models.Upload;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace IM_API.Services
|
||||
{
|
||||
public class UploadTaskService : IUploadTaskService
|
||||
{
|
||||
private readonly ImContext _context;
|
||||
public UploadTaskService(ImContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task AddAsync(UploadTask task)
|
||||
{
|
||||
_context.UploadTasks.Add(task);
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task<UploadTask?> GetTaskAsync(Guid taskId)
|
||||
{
|
||||
return await _context.UploadTasks.FirstOrDefaultAsync(x => x.Id == taskId);
|
||||
}
|
||||
|
||||
public async Task<UploadTask?> GetTaskAsync(string hash)
|
||||
{
|
||||
return await _context.UploadTasks.FirstOrDefaultAsync(x => x.FileHash == hash);
|
||||
}
|
||||
|
||||
public async Task UpdateStatusAsync(Guid taskId, UploadStatus status)
|
||||
{
|
||||
var task = await _context.UploadTasks.FirstOrDefaultAsync(x => x.Id == taskId);
|
||||
if (task != null)
|
||||
{
|
||||
task.Status = status;
|
||||
_context.UploadTasks.Update(task);
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -105,5 +105,11 @@
|
||||
// 3.9 会话相关错误(3100 ~ 3199)
|
||||
/// <summary>发送时异常</summary>
|
||||
public static CodeDefine CONVERSATION_NOT_FOUND = new CodeDefine(3100, "会话不存在");
|
||||
|
||||
// 3.9 文件相关错误(3200 ~ 3299)
|
||||
/// <summary>分片不存在异常</summary>
|
||||
public static CodeDefine CHUNKE_NOT_FOUND = new CodeDefine(3201, "分片不存在");
|
||||
/// <summary>分片合并异常</summary>
|
||||
public static CodeDefine CHUNKE_COMBINE_FAIL = new CodeDefine(3202, "分片合并失败");
|
||||
}
|
||||
}
|
||||
|
||||
51
backend/IM_API/Tools/ObjectNameGenerator.cs
Normal file
51
backend/IM_API/Tools/ObjectNameGenerator.cs
Normal file
@ -0,0 +1,51 @@
|
||||
namespace IM_API.Tools
|
||||
{
|
||||
public static class ObjectNameGenerator
|
||||
{
|
||||
public static string Generate(ObjectNameContext ctx)
|
||||
{
|
||||
var ext = GetExtension(ctx.FileName, ctx.ContentType);
|
||||
var shortId = Guid.NewGuid().ToString("N")[..12];
|
||||
|
||||
var parts = new List<string>
|
||||
{
|
||||
ctx.Biz,
|
||||
ctx.Now.Year.ToString(),
|
||||
ctx.Now.Month.ToString("D2")
|
||||
};
|
||||
|
||||
if (ctx.UserId.HasValue)
|
||||
{
|
||||
parts.Add(ctx.UserId.Value.ToString());
|
||||
}
|
||||
|
||||
parts.Add($"{shortId}{ext}");
|
||||
|
||||
return string.Join("/", parts);
|
||||
}
|
||||
|
||||
private static string GetExtension(string fileName, string contentType)
|
||||
{
|
||||
var ext = Path.GetExtension(fileName);
|
||||
if (!string.IsNullOrWhiteSpace(ext))
|
||||
return ext.ToLowerInvariant();
|
||||
|
||||
return contentType switch
|
||||
{
|
||||
"image/jpeg" => ".jpg",
|
||||
"image/png" => ".png",
|
||||
"video/mp4" => ".mp4",
|
||||
_ => ".bin"
|
||||
};
|
||||
}
|
||||
}
|
||||
public class ObjectNameContext
|
||||
{
|
||||
public string Biz { get; init; } = "IM";
|
||||
public long? UserId { get; init; }
|
||||
public string FileName { get; init; } = default!;
|
||||
public string ContentType { get; init; } = default!;
|
||||
public DateTimeOffset Now { get; init; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
}
|
||||
@ -2,10 +2,13 @@
|
||||
{
|
||||
public static class RedisKeys
|
||||
{
|
||||
public static string GetUserinfoKey(string userId) => $"user::uinfo::{userId}";
|
||||
public static string GetUserinfoKeyByUsername(string username) => $"user::uinfobyid::{username}";
|
||||
public static string GetSequenceIdKey(string streamKey) => $"chat::seq::{streamKey}";
|
||||
public static string GetSequenceIdLockKey(string streamKey) => $"lock::seq::{streamKey}";
|
||||
public static string GetConnectionIdKey(string userId) => $"signalr::user::con::{userId}";
|
||||
public static string GetUserinfoKey(string userId) => $"user:uinfo:{userId}";
|
||||
public static string GetUserinfoKeyByUsername(string username) => $"user:uinfobyid:{username}";
|
||||
public static string GetSequenceIdKey(string streamKey) => $"chat:seq:{streamKey}";
|
||||
public static string GetSequenceIdLockKey(string streamKey) => $"lock:seq:{streamKey}";
|
||||
public static string GetConnectionIdKey(string userId) => $"signalr:user:con:{userId}";
|
||||
|
||||
public static string GetUploadPartKey(Guid taskId) => $"upload:task:{taskId}:parts";
|
||||
public static string MergeStatus(Guid taskId) => $"upload:task:{taskId}:merge";
|
||||
}
|
||||
}
|
||||
|
||||
64
backend/IM_API/Tools/UrlTools.cs
Normal file
64
backend/IM_API/Tools/UrlTools.cs
Normal file
@ -0,0 +1,64 @@
|
||||
using SixLabors.ImageSharp;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace IM_API.Tools
|
||||
{
|
||||
public static class UrlTools
|
||||
{
|
||||
public static string GetFullUrl(string objectName, string provider, string? baseUrl)
|
||||
{
|
||||
return provider switch
|
||||
{
|
||||
"Local" => $"{baseUrl}/uploads/files/{objectName}",
|
||||
_ => "http://baidu.com",
|
||||
};
|
||||
}
|
||||
|
||||
public static async Task<(int width, int height)> GetImageWH(string url)
|
||||
{
|
||||
using var httpClient = new HttpClient();
|
||||
var stream = await httpClient.GetStreamAsync(url);
|
||||
var info = await Image.IdentifyAsync(stream);
|
||||
return (info.Width, info.Height);
|
||||
}
|
||||
|
||||
public static string ProcessMessageUrl(string contentJson, string? localBaseUrl)
|
||||
{
|
||||
// 1. 解析 JSON 文档(比反序列化快得多)
|
||||
using var doc = JsonDocument.Parse(contentJson);
|
||||
var root = doc.RootElement;
|
||||
|
||||
// 2. 获取 Provider 字段
|
||||
string provider = root.GetProperty("provider").GetString();
|
||||
|
||||
// 3. 根据 Provider 决定前缀
|
||||
string prefix = GetFullUrl("", provider, localBaseUrl);
|
||||
|
||||
// 4. 重新组装(如果只是为了给前端看,建议直接返回带前缀的对象或字符串)
|
||||
// 这里推荐用 JsonNode 方便修改并返回字符串
|
||||
var node = JsonNode.Parse(contentJson);
|
||||
node["url"] = $"{prefix}{node["url"]}";
|
||||
node["thumb"] = $"{prefix}{node["thumb"]}";
|
||||
|
||||
return node.ToJsonString();
|
||||
}
|
||||
public static Stream Base64ToStream(string base64String)
|
||||
{
|
||||
if (string.IsNullOrEmpty(base64String))
|
||||
throw new ArgumentNullException(nameof(base64String));
|
||||
|
||||
// 1. 自动处理可能存在的 Base64 Data URL 前缀
|
||||
string base64Data = base64String.Contains(",")
|
||||
? base64String.Split(',')[1]
|
||||
: base64String;
|
||||
|
||||
// 2. 解码为字节数组
|
||||
byte[] bytes = Convert.FromBase64String(base64Data);
|
||||
|
||||
// 3. 包装进 MemoryStream
|
||||
// 注意:这里直接把 Position 设为 0,符合“方法a”产生即用的原则
|
||||
return new MemoryStream(bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
16
backend/IM_API/VOs/CreateUploadTaskVo.cs
Normal file
16
backend/IM_API/VOs/CreateUploadTaskVo.cs
Normal file
@ -0,0 +1,16 @@
|
||||
using IM_API.Interface.Services;
|
||||
|
||||
namespace IM_API.VOs
|
||||
{
|
||||
public class CreateUploadTaskVo
|
||||
{
|
||||
public Guid TaskId { get; set; }
|
||||
|
||||
public int ChunkSize { get; set; }
|
||||
public int TotalChunks { get; set; }
|
||||
|
||||
public int Concurrency { get; set; } = 4;
|
||||
public string? Url { get; set; }
|
||||
public bool Skip { get; set; }
|
||||
}
|
||||
}
|
||||
14
backend/IM_API/VOs/UploadPartInstuctionVo.cs
Normal file
14
backend/IM_API/VOs/UploadPartInstuctionVo.cs
Normal file
@ -0,0 +1,14 @@
|
||||
namespace IM_API.VOs
|
||||
{
|
||||
public class UploadPartInstructionVo
|
||||
{
|
||||
public bool Skip { get; set; }
|
||||
public int PartNumber { get; set; }
|
||||
|
||||
public string Method { get; set; } = "PUT";
|
||||
public string Url { get; set; } = default!;
|
||||
|
||||
public Dictionary<string, string> Headers { get; set; } = new();
|
||||
}
|
||||
|
||||
}
|
||||
@ -14,7 +14,7 @@
|
||||
"RefreshTokenDays": 30
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Server=frp-era.com;Port=26582;Database=IM;User=product;Password=12345678;",
|
||||
"DefaultConnection": "Server=192.168.5.100;Port=3306;Database=IM;User=product;Password=12345678;",
|
||||
"Redis": "192.168.5.100:6379"
|
||||
},
|
||||
"RabbitMQOptions": {
|
||||
@ -22,5 +22,9 @@
|
||||
"Port": 5672,
|
||||
"Username": "test",
|
||||
"Password": "123456"
|
||||
},
|
||||
"FileUploadOptions": {
|
||||
"DefaultStorage": "Local",
|
||||
"ChunkSize": 5000000,
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,12 +1,21 @@
|
||||
allprojects {
|
||||
repositories {
|
||||
maven { url = uri("https://maven.aliyun.com/repository/google") }
|
||||
maven { url = uri("https://maven.aliyun.com/repository/public") }
|
||||
google()
|
||||
mavenCentral()
|
||||
|
||||
}
|
||||
}
|
||||
//buildscript {
|
||||
// repositories {
|
||||
// // 将 google() 和 mavenCentral() 替换或放在后面
|
||||
// maven { url = uri("https://maven.aliyun.com/repository/google") }
|
||||
// maven { url = uri("https://maven.aliyun.com/repository/public") }
|
||||
// google()
|
||||
// mavenCentral()
|
||||
// }
|
||||
//}
|
||||
//
|
||||
//allprojects {
|
||||
// repositories {
|
||||
// maven { url = uri("https://maven.aliyun.com/repository/google") }
|
||||
// maven { url = uri("https://maven.aliyun.com/repository/public") }
|
||||
// google()
|
||||
// mavenCentral()
|
||||
// }
|
||||
//}
|
||||
|
||||
val newBuildDir: Directory =
|
||||
rootProject.layout.buildDirectory
|
||||
|
||||
663
frontend/app/android/build/reports/problems/problems-report.html
Normal file
663
frontend/app/android/build/reports/problems/problems-report.html
Normal file
File diff suppressed because one or more lines are too long
@ -1,2 +1,12 @@
|
||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
# ?? HTTP ??
|
||||
systemProp.http.proxyHost=127.0.0.1
|
||||
systemProp.http.proxyPort=10808
|
||||
|
||||
# ?? HTTPS ???????????????
|
||||
systemProp.https.proxyHost=127.0.0.1
|
||||
systemProp.https.proxyPort=10808
|
||||
|
||||
# ????????????????????????
|
||||
systemProp.http.nonProxyHosts=localhost|127.0.0.1|*.aliyun.com|mirrors.*
|
||||
@ -1,28 +1,38 @@
|
||||
pluginManagement {
|
||||
val flutterSdkPath =
|
||||
run {
|
||||
val properties = java.util.Properties()
|
||||
file("local.properties").inputStream().use { properties.load(it) }
|
||||
val flutterSdkPath = properties.getProperty("flutter.sdk")
|
||||
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
|
||||
flutterSdkPath
|
||||
}
|
||||
val flutterSdkPath = run {
|
||||
val properties = java.util.Properties()
|
||||
file("local.properties").inputStream().use { properties.load(it) }
|
||||
val flutterSdkPath = properties.getProperty("flutter.sdk")
|
||||
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
|
||||
flutterSdkPath
|
||||
}
|
||||
|
||||
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
|
||||
|
||||
repositories {
|
||||
maven { url = uri("https://maven.aliyun.com/repository/google") }
|
||||
maven { url = uri("https://maven.aliyun.com/repository/public") }
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
//google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS)
|
||||
repositories {
|
||||
maven { url = uri("https://maven.aliyun.com/repository/google") }
|
||||
maven { url = uri("https://maven.aliyun.com/repository/public") }
|
||||
gradlePluginPortal()
|
||||
//google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
id("com.android.application") version "8.11.1" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
||||
id("com.android.application") version "8.1.1" apply false
|
||||
id("org.jetbrains.kotlin.android") version "1.8.22" apply false
|
||||
}
|
||||
|
||||
include(":app")
|
||||
include(":app")
|
||||
@ -6,7 +6,7 @@ packages:
|
||||
description:
|
||||
name: async
|
||||
sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.13.0"
|
||||
boolean_selector:
|
||||
@ -14,23 +14,23 @@ packages:
|
||||
description:
|
||||
name: boolean_selector
|
||||
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
characters:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: characters
|
||||
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
|
||||
url: "https://pub.flutter-io.cn"
|
||||
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
version: "1.4.1"
|
||||
clock:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: clock
|
||||
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
collection:
|
||||
@ -38,7 +38,7 @@ packages:
|
||||
description:
|
||||
name: collection
|
||||
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.1"
|
||||
cupertino_icons:
|
||||
@ -46,7 +46,7 @@ packages:
|
||||
description:
|
||||
name: cupertino_icons
|
||||
sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.8"
|
||||
fake_async:
|
||||
@ -54,7 +54,7 @@ packages:
|
||||
description:
|
||||
name: fake_async
|
||||
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.3"
|
||||
flutter:
|
||||
@ -67,7 +67,7 @@ packages:
|
||||
description:
|
||||
name: flutter_lints
|
||||
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.0"
|
||||
flutter_test:
|
||||
@ -84,16 +84,16 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: go_router
|
||||
sha256: eff94d2a6fc79fa8b811dde79c7549808c2346037ee107a1121b4a644c745f2a
|
||||
url: "https://pub.flutter-io.cn"
|
||||
sha256: "7974313e217a7771557add6ff2238acb63f635317c35fa590d348fb238f00896"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "17.0.1"
|
||||
version: "17.1.0"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker
|
||||
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "11.0.2"
|
||||
leak_tracker_flutter_testing:
|
||||
@ -101,7 +101,7 @@ packages:
|
||||
description:
|
||||
name: leak_tracker_flutter_testing
|
||||
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.10"
|
||||
leak_tracker_testing:
|
||||
@ -109,47 +109,47 @@ packages:
|
||||
description:
|
||||
name: leak_tracker_testing
|
||||
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
lints:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: lints
|
||||
sha256: a5e2b223cb7c9c8efdc663ef484fdd95bb243bff242ef5b13e26883547fce9a0
|
||||
url: "https://pub.flutter-io.cn"
|
||||
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.0"
|
||||
version: "6.1.0"
|
||||
logging:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: logging
|
||||
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
matcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
|
||||
url: "https://pub.flutter-io.cn"
|
||||
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.17"
|
||||
version: "0.12.18"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: material_color_utilities
|
||||
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
||||
url: "https://pub.flutter-io.cn"
|
||||
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.11.1"
|
||||
version: "0.13.0"
|
||||
meta:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.17.0"
|
||||
path:
|
||||
@ -157,7 +157,7 @@ packages:
|
||||
description:
|
||||
name: path
|
||||
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
sky_engine:
|
||||
@ -169,16 +169,16 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_span
|
||||
sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.10.1"
|
||||
version: "1.10.2"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stack_trace
|
||||
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.12.1"
|
||||
stream_channel:
|
||||
@ -186,7 +186,7 @@ packages:
|
||||
description:
|
||||
name: stream_channel
|
||||
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
string_scanner:
|
||||
@ -194,7 +194,7 @@ packages:
|
||||
description:
|
||||
name: string_scanner
|
||||
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
term_glyph:
|
||||
@ -202,23 +202,23 @@ packages:
|
||||
description:
|
||||
name: term_glyph
|
||||
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.2"
|
||||
test_api:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||
url: "https://pub.flutter-io.cn"
|
||||
sha256: "19a78f63e83d3a61f00826d09bc2f60e191bf3504183c001262be6ac75589fb8"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.7"
|
||||
version: "0.7.8"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_math
|
||||
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
vm_service:
|
||||
@ -226,7 +226,7 @@ packages:
|
||||
description:
|
||||
name: vm_service
|
||||
sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "15.0.2"
|
||||
sdks:
|
||||
|
||||
166
frontend/web/package-lock.json
generated
166
frontend/web/package-lock.json
generated
@ -8,6 +8,7 @@
|
||||
"name": "web",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@cloudgeek/vue3-video-player": "^0.3.10",
|
||||
"@microsoft/signalr": "^10.0.0",
|
||||
"@vuelidate/core": "^2.0.3",
|
||||
"@vuelidate/validators": "^2.0.4",
|
||||
@ -15,6 +16,7 @@
|
||||
"feather-icons": "^4.29.2",
|
||||
"idb": "^8.0.3",
|
||||
"pinia": "^3.0.3",
|
||||
"spark-md5": "^3.0.2",
|
||||
"vee-validate": "^4.15.1",
|
||||
"vue": "^3.5.22",
|
||||
"vue-router": "^4.5.1",
|
||||
@ -29,6 +31,7 @@
|
||||
"eslint": "^9.33.0",
|
||||
"eslint-plugin-vue": "~10.4.0",
|
||||
"globals": "^16.3.0",
|
||||
"hevue-img-preview": "^7.1.3",
|
||||
"jsdom": "^27.0.0",
|
||||
"prettier": "3.6.2",
|
||||
"vite": "^7.1.7",
|
||||
@ -105,7 +108,6 @@
|
||||
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.3",
|
||||
@ -570,6 +572,20 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@cloudgeek/vue3-video-player": {
|
||||
"version": "0.3.10",
|
||||
"resolved": "https://registry.npmjs.org/@cloudgeek/vue3-video-player/-/vue3-video-player-0.3.10.tgz",
|
||||
"integrity": "sha512-QMpQK20fpUp1WcyIf+PxxW+0OwAx8TR/cGJg/axcVhMtcZvLmP+BPxdMQd5NKZwJ+DqY7ZHEgkfc+9Ng7Vjxuw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@multiavatar/multiavatar": "^1.0.5",
|
||||
"core-js": "^3.6.5",
|
||||
"event-emitter": "^0.3.5",
|
||||
"ismobilejs": "^1.1.1",
|
||||
"load-script": "^1.0.0",
|
||||
"vue": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/color-helpers": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
|
||||
@ -658,7 +674,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
@ -705,7 +720,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@ -1497,6 +1511,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@multiavatar/multiavatar": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@multiavatar/multiavatar/-/multiavatar-1.0.7.tgz",
|
||||
"integrity": "sha512-Yg9Uw57bmlErsWL0CSv4d6D4ZqVBE00OZmYr9MRgygoXZdboNtsEI6FbBRw1AY8l88Sm1ARcyojtlm2uwUn0Zg==",
|
||||
"license": "SEE LICENSE IN LICENSE"
|
||||
},
|
||||
"node_modules/@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
@ -2670,7 +2690,6 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@ -2874,7 +2893,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.8.9",
|
||||
"caniuse-lite": "^1.0.30001746",
|
||||
@ -3165,6 +3183,19 @@
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d/-/d-1.0.2.tgz",
|
||||
"integrity": "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"es5-ext": "^0.10.64",
|
||||
"type": "^2.7.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
}
|
||||
},
|
||||
"node_modules/data-urls": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.0.tgz",
|
||||
@ -3418,6 +3449,46 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es5-ext": {
|
||||
"version": "0.10.64",
|
||||
"resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz",
|
||||
"integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"es6-iterator": "^2.0.3",
|
||||
"es6-symbol": "^3.1.3",
|
||||
"esniff": "^2.0.1",
|
||||
"next-tick": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/es6-iterator": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz",
|
||||
"integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"d": "1",
|
||||
"es5-ext": "^0.10.35",
|
||||
"es6-symbol": "^3.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/es6-symbol": {
|
||||
"version": "3.1.4",
|
||||
"resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.4.tgz",
|
||||
"integrity": "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d": "^1.0.2",
|
||||
"ext": "^1.7.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz",
|
||||
@ -3489,7 +3560,6 @@
|
||||
"integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@ -3551,7 +3621,6 @@
|
||||
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"eslint-config-prettier": "bin/cli.js"
|
||||
},
|
||||
@ -3688,6 +3757,21 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/esniff": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz",
|
||||
"integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d": "^1.0.1",
|
||||
"es5-ext": "^0.10.62",
|
||||
"event-emitter": "^0.3.5",
|
||||
"type": "^2.7.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/espree": {
|
||||
"version": "10.4.0",
|
||||
"resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
|
||||
@ -3771,6 +3855,16 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/event-emitter": {
|
||||
"version": "0.3.5",
|
||||
"resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz",
|
||||
"integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"d": "1",
|
||||
"es5-ext": "~0.10.14"
|
||||
}
|
||||
},
|
||||
"node_modules/event-target-shim": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
|
||||
@ -3826,6 +3920,15 @@
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ext": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz",
|
||||
"integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"type": "^2.7.2"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
@ -4258,6 +4361,16 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/hevue-img-preview": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/hevue-img-preview/-/hevue-img-preview-7.1.3.tgz",
|
||||
"integrity": "sha512-nLB1eyPUqP2UysJpEJaboHMGKPY8zgsyqaPSUHjxr04jDc2PIg61KFFS/UHc0aa2xpbf5UaeCNoVOHFYdWVOTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"vue": "^3.5.17"
|
||||
}
|
||||
},
|
||||
"node_modules/hookable": {
|
||||
"version": "5.5.3",
|
||||
"resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz",
|
||||
@ -4537,6 +4650,12 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ismobilejs": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ismobilejs/-/ismobilejs-1.1.1.tgz",
|
||||
"integrity": "sha512-VaFW53yt8QO61k2WJui0dHf4SlL8lxBofUuUmwBo0ljPk0Drz2TiuDW4jo3wDcv41qy/SxrJ+VAzJ/qYqsmzRw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jackspeak": {
|
||||
"version": "3.4.3",
|
||||
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
|
||||
@ -4733,6 +4852,12 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/load-script": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/load-script/-/load-script-1.0.0.tgz",
|
||||
"integrity": "sha512-kPEjMFtZvwL9TaZo0uZ2ml+Ye9HUMmPwbYRJ324qF9tqMejwykJ5ggTyvzmrbBeapCAbk98BSbTeovHEEP1uCA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||
@ -4917,6 +5042,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/next-tick": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz",
|
||||
"integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||
@ -5273,7 +5404,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@ -5313,7 +5443,6 @@
|
||||
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
@ -5655,6 +5784,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/spark-md5": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/spark-md5/-/spark-md5-3.0.2.tgz",
|
||||
"integrity": "sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw==",
|
||||
"license": "(WTFPL OR MIT)"
|
||||
},
|
||||
"node_modules/speakingurl": {
|
||||
"version": "14.0.1",
|
||||
"resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz",
|
||||
@ -5937,7 +6072,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@ -6063,6 +6197,12 @@
|
||||
"typescript": ">=4.8.4"
|
||||
}
|
||||
},
|
||||
"node_modules/type": {
|
||||
"version": "2.7.3",
|
||||
"resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz",
|
||||
"integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||
@ -6232,7 +6372,6 @@
|
||||
"integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.5.0",
|
||||
@ -6494,7 +6633,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@ -6508,7 +6646,6 @@
|
||||
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/expect": "3.2.4",
|
||||
@ -6594,7 +6731,6 @@
|
||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.22.tgz",
|
||||
"integrity": "sha512-toaZjQ3a/G/mYaLSbV+QsQhIdMo9x5rrqIpYRObsJ6T/J+RyCSFwN2LHNVH9v8uIcljDNa3QzPVdv3Y6b9hAJQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.22",
|
||||
"@vue/compiler-sfc": "3.5.22",
|
||||
@ -6624,6 +6760,7 @@
|
||||
"integrity": "sha512-CydUvFOQKD928UzZhTp4pr2vWz1L+H99t7Pkln2QSPdvmURT0MoC4wUccfCnuEaihNsu9aYYyk+bep8rlfkUXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"debug": "^4.4.0",
|
||||
"eslint-scope": "^8.2.0",
|
||||
@ -6648,6 +6785,7 @@
|
||||
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
|
||||
@ -15,6 +15,7 @@
|
||||
"format": "prettier --write src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cloudgeek/vue3-video-player": "^0.3.10",
|
||||
"@microsoft/signalr": "^10.0.0",
|
||||
"@vuelidate/core": "^2.0.3",
|
||||
"@vuelidate/validators": "^2.0.4",
|
||||
@ -22,6 +23,7 @@
|
||||
"feather-icons": "^4.29.2",
|
||||
"idb": "^8.0.3",
|
||||
"pinia": "^3.0.3",
|
||||
"spark-md5": "^3.0.2",
|
||||
"vee-validate": "^4.15.1",
|
||||
"vue": "^3.5.22",
|
||||
"vue-router": "^4.5.1",
|
||||
@ -36,6 +38,7 @@
|
||||
"eslint": "^9.33.0",
|
||||
"eslint-plugin-vue": "~10.4.0",
|
||||
"globals": "^16.3.0",
|
||||
"hevue-img-preview": "^7.1.3",
|
||||
"jsdom": "^27.0.0",
|
||||
"prettier": "3.6.2",
|
||||
"vite": "^7.1.7",
|
||||
|
||||
124
frontend/web/src/components/messages/VideoMsg.vue
Normal file
124
frontend/web/src/components/messages/VideoMsg.vue
Normal file
@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<div class="video-msg-container" @click="handlePlay">
|
||||
<img :src="thumbnailUrl" class="video-poster" :style="containerStyle" />
|
||||
|
||||
<div class="play-icon-overlay">
|
||||
<div class="play-button"></div>
|
||||
</div>
|
||||
|
||||
<span v-if="duration" class="duration-label">
|
||||
{{ formatDuration(duration) }}
|
||||
</span>
|
||||
|
||||
<div v-if="uploading" class="upload-mask">
|
||||
<div class="progress">{{ progress }}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
// 缩略图地址(可以是 URL 或 Blob 生成的 ObjectURL)
|
||||
thumbnailUrl: String,
|
||||
// 视频时长(秒)
|
||||
duration: Number,
|
||||
// 视频原始宽高(用于计算比例)
|
||||
w: Number,
|
||||
h: Number,
|
||||
uploading: Boolean,
|
||||
progress: Number
|
||||
});
|
||||
|
||||
const emit = defineEmits(['play']);
|
||||
|
||||
// 格式化时长:如 75 -> "01:15"
|
||||
const formatDuration = (seconds) => {
|
||||
if (!seconds) return '00:00';
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
// 计算容器尺寸,防止长视频或宽视频撑破布局
|
||||
const containerStyle = computed(() => {
|
||||
const maxSide = 200; // 最大显示尺寸
|
||||
if (props.w > props.h) {
|
||||
return { width: maxSide + 'px', height: 'auto', aspectRatio: `${props.w}/${props.h}` };
|
||||
} else {
|
||||
return { height: maxSide + 'px', width: 'auto', aspectRatio: `${props.w}/${props.h}` };
|
||||
}
|
||||
});
|
||||
|
||||
const handlePlay = () => {
|
||||
emit('play');
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.video-msg-container {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background-color: #000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.video-poster {
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
background: #2a2a2a;
|
||||
}
|
||||
|
||||
.play-icon-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.play-button {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
border-radius: 50%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.play-button::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
top: 10px;
|
||||
border-style: solid;
|
||||
border-width: 10px 0 10px 15px;
|
||||
border-color: transparent transparent transparent #333;
|
||||
}
|
||||
|
||||
.duration-label {
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
right: 6px;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.upload-mask {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
30
frontend/web/src/components/messages/VideoPreView.vue
Normal file
30
frontend/web/src/components/messages/VideoPreView.vue
Normal file
@ -0,0 +1,30 @@
|
||||
<template>
|
||||
|
||||
<div id="Video"></div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
import Player from 'xgplayer/dist/simple_player';
|
||||
import volume from 'xgplayer/dist/controls/volume';
|
||||
import playbackRate from 'xgplayer/dist/controls/playbackRate';
|
||||
import { defineProps } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
m: {
|
||||
type: Object,
|
||||
required:true
|
||||
}
|
||||
});
|
||||
|
||||
let player = new Player({
|
||||
id: 'Video',
|
||||
url: props.m.content.url,
|
||||
controlPlugins: [
|
||||
volume,
|
||||
playbackRate
|
||||
],
|
||||
playbackRate: [0.5, 0.75, 1, 1.5, 2] //传入倍速可选数组
|
||||
});
|
||||
|
||||
</script>
|
||||
158
frontend/web/src/components/messages/VoiceMsg.vue
Normal file
158
frontend/web/src/components/messages/VoiceMsg.vue
Normal file
@ -0,0 +1,158 @@
|
||||
<template>
|
||||
<div
|
||||
class="voice-card"
|
||||
:class="{ 'playing': isPlaying }"
|
||||
:style="{ width: bubbleWidth + 'px' }"
|
||||
@click="togglePlay"
|
||||
>
|
||||
<div class="play-icon-wrap">
|
||||
<svg v-if="!isPlaying" viewBox="0 0 24 24" class="svg-obj">
|
||||
<path fill="currentColor" d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
<svg v-else viewBox="0 0 24 24" class="svg-obj">
|
||||
<path fill="currentColor" d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="wave-track">
|
||||
<div v-for="n in 40" :key="n" class="wave-item"></div>
|
||||
</div>
|
||||
|
||||
<span class="time-label">{{ Math.floor(duration) }}''</span>
|
||||
|
||||
<div v-if="!isRead" class="unread-dot"></div>
|
||||
|
||||
<audio ref="audioRef" :src="url" @ended="stopPlay" @error="stopPlay"></audio>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
url: { type: String, required: true },
|
||||
duration: { type: Number, default: 0 },
|
||||
isRead: { type: Boolean, default: true }
|
||||
});
|
||||
|
||||
const emit = defineEmits(['played']);
|
||||
const audioRef = ref(null);
|
||||
const isPlaying = ref(false);
|
||||
|
||||
const bubbleWidth = computed(() => Math.min(110 + props.duration * 4, 260));
|
||||
|
||||
const togglePlay = (e) => {
|
||||
e.stopPropagation();
|
||||
if (isPlaying.value) {
|
||||
audioRef.value.pause();
|
||||
stopPlay();
|
||||
} else {
|
||||
audioRef.value.play();
|
||||
isPlaying.value = true;
|
||||
if (!props.isRead) emit('played');
|
||||
}
|
||||
};
|
||||
|
||||
const stopPlay = () => {
|
||||
isPlaying.value = false;
|
||||
if (audioRef.value) audioRef.value.currentTime = 0;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 核心改动:纯白背景 + 物理阴影 + 明确边框 */
|
||||
.voice-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 40px;
|
||||
padding: 0 14px;
|
||||
background: #ffffff; /* 强迫其在灰白背景上显现 */
|
||||
border: 1.5px solid #e8eaed; /* 更有质感的深色边框 */
|
||||
border-radius: 20px; /* 全圆角更现代 */
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
user-select: none;
|
||||
color: #202124;
|
||||
/* 增加微妙的投影,产生高度感 */
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.voice-card:hover {
|
||||
border-color: #007aff;
|
||||
box-shadow: 0 4px 12px rgba(0, 122, 255, 0.12);
|
||||
}
|
||||
|
||||
.voice-card:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
/* 播放图标:使用品牌蓝 */
|
||||
.play-icon-wrap {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #007aff;
|
||||
}
|
||||
.svg-obj { width: 100%; height: 100%; }
|
||||
|
||||
/* 波形区 */
|
||||
.wave-track {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
margin: 0 12px;
|
||||
height: 24px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wave-item {
|
||||
width: 2px;
|
||||
height: 4px;
|
||||
background-color: #bdc1c6; /* 加深线条颜色,防止看不清 */
|
||||
border-radius: 1px;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
/* 播放态动画 */
|
||||
.playing .wave-item {
|
||||
background-color: #007aff;
|
||||
animation: bar-throb 0.7s infinite alternate;
|
||||
}
|
||||
|
||||
/* 使用 nth-child 赋予不同的波浪起伏感 */
|
||||
.wave-item:nth-child(4n+1) { height: 6px; animation-delay: 0.1s; }
|
||||
.wave-item:nth-child(4n+2) { height: 14px; animation-delay: 0.3s; }
|
||||
.wave-item:nth-child(4n+3) { height: 10px; animation-delay: 0.2s; }
|
||||
.wave-item:nth-child(4n+4) { height: 6px; animation-delay: 0.4s; }
|
||||
|
||||
@keyframes bar-throb {
|
||||
from { transform: scaleY(1); opacity: 0.7; }
|
||||
to { transform: scaleY(1.8); opacity: 1; }
|
||||
}
|
||||
|
||||
.time-label {
|
||||
font-size: 13px;
|
||||
font-weight: 800; /* 加粗时间,更清晰 */
|
||||
color: #5f6368;
|
||||
flex-shrink: 0;
|
||||
font-family: 'Helvetica Neue', sans-serif;
|
||||
}
|
||||
|
||||
/* 未读红点:增加发光效果 */
|
||||
.unread-dot {
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
right: -2px;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
background: #f44336;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #fff;
|
||||
box-shadow: 0 1px 4px rgba(244, 67, 54, 0.4);
|
||||
}
|
||||
</style>
|
||||
40
frontend/web/src/constants/fileTypeDefine.js
Normal file
40
frontend/web/src/constants/fileTypeDefine.js
Normal file
@ -0,0 +1,40 @@
|
||||
export const getMessageType = (fileType) => {
|
||||
if (!fileType) return FILE_TYPE.File; // 兜底处理
|
||||
|
||||
// 处理图片
|
||||
if (fileType.startsWith('image/')) {
|
||||
return FILE_TYPE.Image;
|
||||
}
|
||||
|
||||
// 处理音频(录音消息)
|
||||
if (fileType.startsWith('audio/')) {
|
||||
return FILE_TYPE.Voice;
|
||||
}
|
||||
|
||||
// 处理视频
|
||||
if (fileType.startsWith('video/')) {
|
||||
return FILE_TYPE.Video;
|
||||
}
|
||||
|
||||
// 常见文档类型的特殊处理(可选)
|
||||
const documentTypes = [
|
||||
'application/pdf',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'text/plain'
|
||||
];
|
||||
|
||||
if (documentTypes.includes(fileType)) {
|
||||
return FILE_TYPE.File;
|
||||
}
|
||||
|
||||
// 其他所有情况统一归类为文件
|
||||
return FILE_TYPE.File;
|
||||
};
|
||||
|
||||
export const FILE_TYPE = Object.freeze({
|
||||
Image: 'Image',
|
||||
Video: 'Video',
|
||||
Voice: 'Voice',
|
||||
File: 'File'
|
||||
});
|
||||
29
frontend/web/src/constants/fileTypeInfo.js
Normal file
29
frontend/web/src/constants/fileTypeInfo.js
Normal file
@ -0,0 +1,29 @@
|
||||
export class MessageBaseInfo {
|
||||
constructor(format, text) {
|
||||
this.format = format;
|
||||
this.text = text;
|
||||
}
|
||||
}
|
||||
|
||||
export class ImageInfo extends MessageBaseInfo {
|
||||
constructor(format, text, w, h, Thumb) {
|
||||
super(format, text);
|
||||
this.w = w;
|
||||
this.h = h;
|
||||
this.thumb = Thumb;
|
||||
}
|
||||
}
|
||||
|
||||
export class VideoInfo extends ImageInfo {
|
||||
constructor(format, text, w, h, Thumb, duration) {
|
||||
super(format, text, w, h, Thumb);
|
||||
this.duration = duration;
|
||||
}
|
||||
}
|
||||
|
||||
export class VoiceInfo extends MessageBaseInfo {
|
||||
constructor(format, text, duration) {
|
||||
super(format, text);
|
||||
this.duration = duration;
|
||||
}
|
||||
}
|
||||
7
frontend/web/src/constants/uploadStatus.js
Normal file
7
frontend/web/src/constants/uploadStatus.js
Normal file
@ -0,0 +1,7 @@
|
||||
|
||||
export const UPLOAD_STATUS = Object.freeze({
|
||||
UPLOADING: 'uploading',
|
||||
UPLOADED: 'uploaded',
|
||||
MERGING: 'merging',
|
||||
COMPLETE: 'complete'
|
||||
})
|
||||
@ -7,10 +7,16 @@ import router from './router'
|
||||
import MyButton from './components/MyButton.vue'
|
||||
import IconInput from './components/IconInput.vue'
|
||||
|
||||
import Vue3VideoPlayer from '@cloudgeek/vue3-video-player'
|
||||
import '@cloudgeek/vue3-video-player/dist/vue3-video-player.css'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(Vue3VideoPlayer, {
|
||||
lang: 'zh-CN' // 可选,语言包
|
||||
})
|
||||
app.component('MyButton', MyButton)
|
||||
app.component('IconInput', IconInput)
|
||||
|
||||
|
||||
@ -5,99 +5,100 @@ import { useAuthStore } from '@/stores/auth';
|
||||
import { authService } from './auth';
|
||||
|
||||
const message = useMessage();
|
||||
const authStore = useAuthStore();
|
||||
|
||||
let waitqueue = [];
|
||||
let isRefreshing = false;
|
||||
const authURL = ['/auth/login', '/auth/register', '/auth/refresh'];
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api', // 从环境变量中读取基础 URL
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api', // 从环境变量中读取基础 URL
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
|
||||
}
|
||||
})
|
||||
|
||||
api.interceptors.request.use(
|
||||
config => {
|
||||
const token = authStore.token;
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
err => {
|
||||
return Promise.reject(err);
|
||||
config => {
|
||||
const authStore = useAuthStore();
|
||||
const token = authStore.token;
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
err => {
|
||||
return Promise.reject(err);
|
||||
}
|
||||
)
|
||||
|
||||
api.interceptors.response.use(
|
||||
response => {
|
||||
return response.data;
|
||||
},
|
||||
async err => {
|
||||
const { config, response } = err;
|
||||
if (response) {
|
||||
switch (response.status) {
|
||||
case 401:
|
||||
if (authURL.some(x => config.url.includes(x))) {
|
||||
authStore.logout();
|
||||
message.error('未登录,请登录后操作。');
|
||||
router.push('/auth/login')
|
||||
break;
|
||||
}
|
||||
if (config._retry) {
|
||||
break;
|
||||
}
|
||||
response => {
|
||||
return response.data;
|
||||
},
|
||||
async err => {
|
||||
const authStore = useAuthStore();
|
||||
const { config, response } = err;
|
||||
if (response) {
|
||||
switch (response.status) {
|
||||
case 401:
|
||||
if (authURL.some(x => config.url.includes(x))) {
|
||||
authStore.logout();
|
||||
message.error('未登录,请登录后操作。');
|
||||
router.push('/auth/login')
|
||||
break;
|
||||
}
|
||||
if (config._retry) {
|
||||
break;
|
||||
}
|
||||
|
||||
config._retry = true;
|
||||
// 已经在刷新 → 排队
|
||||
if (isRefreshing) {
|
||||
return new Promise(resolve => {
|
||||
waitqueue.push(token => {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
resolve(api(config))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
isRefreshing = true;
|
||||
const refreshToken = authStore.refreshToken;
|
||||
if (refreshToken != null && refreshToken != '') {
|
||||
const res = await authService.refresh(refreshToken)
|
||||
authStore.setLoginInfo(res.data.token, res.data.refreshToken, res.data.userInfo)
|
||||
waitqueue.forEach(cb => cb(authStore.token));
|
||||
waitqueue = [];
|
||||
config.headers.Authorization = `Bearer ${authStore.token}`
|
||||
return api(config)
|
||||
}
|
||||
authStore.logout();
|
||||
message.error('未登录,请登录后操作。');
|
||||
router.push('/auth/login')
|
||||
break;
|
||||
case 400:
|
||||
if (response.data && response.data.code == 1003) {
|
||||
message.error(response.data.message);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
message.error('请求错误,请检查网络。');
|
||||
break;
|
||||
}
|
||||
return Promise.reject(err);
|
||||
} else {
|
||||
message.error('请求错误,请检查网络。');
|
||||
return Promise.reject(err);
|
||||
}
|
||||
config._retry = true;
|
||||
// 已经在刷新 → 排队
|
||||
if (isRefreshing) {
|
||||
return new Promise(resolve => {
|
||||
waitqueue.push(token => {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
resolve(api(config))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
isRefreshing = true;
|
||||
const refreshToken = authStore.refreshToken;
|
||||
if (refreshToken != null && refreshToken != '') {
|
||||
const res = await authService.refresh(refreshToken)
|
||||
authStore.setLoginInfo(res.data.token, res.data.refreshToken, res.data.userInfo)
|
||||
waitqueue.forEach(cb => cb(authStore.token));
|
||||
waitqueue = [];
|
||||
config.headers.Authorization = `Bearer ${authStore.token}`
|
||||
return api(config)
|
||||
}
|
||||
authStore.logout();
|
||||
message.error('未登录,请登录后操作。');
|
||||
router.push('/auth/login')
|
||||
break;
|
||||
case 400:
|
||||
if (response.data && response.data.code == 1003) {
|
||||
message.error(response.data.message);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
message.error('请求错误,请检查网络。');
|
||||
break;
|
||||
}
|
||||
return Promise.reject(err);
|
||||
} else {
|
||||
message.error('请求错误,请检查网络。');
|
||||
return Promise.reject(err);
|
||||
}
|
||||
|
||||
}
|
||||
)
|
||||
|
||||
export const request = {
|
||||
get: (url, config) => api.get(url, config),
|
||||
post: (url, data, config) => api.post(url, data, config),
|
||||
put: (url, data, config) => api.put(url, data, config),
|
||||
delete: (url, config) => api.delete(url, config),
|
||||
instance: api,
|
||||
};
|
||||
get: (url, config) => api.get(url, config),
|
||||
post: (url, data, config) => api.post(url, data, config),
|
||||
put: (url, data, config) => api.put(url, data, config),
|
||||
delete: (url, config) => api.delete(url, config),
|
||||
instance: api,
|
||||
};
|
||||
|
||||
52
frontend/web/src/services/upload/uploadService.js
Normal file
52
frontend/web/src/services/upload/uploadService.js
Normal file
@ -0,0 +1,52 @@
|
||||
import { request } from "../api";
|
||||
|
||||
export const uploadService = {
|
||||
/**
|
||||
* 创建文件上传任务
|
||||
* @param {*} fileName 文件名
|
||||
* @param {*} fileSize 文件大小
|
||||
* @param {*} contentType 文件类型
|
||||
* @param {*} fileHash 文件哈希
|
||||
* @returns
|
||||
*/
|
||||
createUploadTask: (fileName, fileSize, contentType, fileHash) => request.post('/Upload/CreateTask', {
|
||||
fileName: fileName,
|
||||
fileSize: fileSize,
|
||||
contentType: contentType,
|
||||
fileHash: fileHash
|
||||
}),
|
||||
/**
|
||||
* 创建分段任务
|
||||
* @param {*} taskId 任务ID
|
||||
* @param {*} partNum 分段序号
|
||||
* @returns
|
||||
*/
|
||||
createPartTask: (taskId, partNum) => request.post(`/Upload/CreatePart?taskId=${taskId}&partNum=${partNum}`),
|
||||
|
||||
completeTask: (taskId, data) => request.post(`/Upload/CompleteTask?taskId=${taskId}`, data),
|
||||
|
||||
uploadPart: (uploadUrl, headers, file, onProgress) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
return request.post(
|
||||
uploadUrl,
|
||||
formData,
|
||||
{
|
||||
baseURL: '',
|
||||
headers, // 不要包含 Content-Type
|
||||
onUploadProgress: e => {
|
||||
if (onProgress && e.total) {
|
||||
onProgress(e.loaded / e.total)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
uploadSmallFile: (file, hash) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
return request.post(`/Upload/upload/${hash}`, formData);
|
||||
}
|
||||
}
|
||||
122
frontend/web/src/services/upload/uploader.js
Normal file
122
frontend/web/src/services/upload/uploader.js
Normal file
@ -0,0 +1,122 @@
|
||||
import { reactive } from "vue";
|
||||
import { uploadService } from "./uploadService";
|
||||
import { getFileHash, sliceFile } from "@/utils/uploadTools";
|
||||
import { request } from "../api";
|
||||
import { UPLOAD_STATUS } from "@/constants/uploadStatus";
|
||||
|
||||
|
||||
export const uploadFile = async (file, {
|
||||
onProgress,
|
||||
onPartComplete
|
||||
} = {}) => {
|
||||
|
||||
const fileHash = await getFileHash(file);
|
||||
const { taskId, chunkSize, concurrency, skip, url } = (await uploadService.createUploadTask(file.name, file.size, file.type, fileHash)).data;
|
||||
|
||||
if (skip) {
|
||||
const uploadStatus = {
|
||||
status: UPLOAD_STATUS.COMPLETE,
|
||||
progress: 100,
|
||||
taskId: taskId,
|
||||
url: url
|
||||
}
|
||||
onProgress?.(uploadStatus)
|
||||
return;
|
||||
}
|
||||
|
||||
const chunks = sliceFile(file, chunkSize);
|
||||
|
||||
const comleteData = [];
|
||||
|
||||
let chunkProgress = reactive(new Array(chunks.length).fill(0))
|
||||
|
||||
const tasks = chunks.map((chunk, index) => {
|
||||
return async () => {
|
||||
const partNum = index + 1;
|
||||
const { skip, method, url, headers, partNumber } = (await uploadService.createPartTask(taskId, partNum)).data;
|
||||
if (!skip) {
|
||||
const { data } = await uploadService.uploadPart(url, headers, chunks[index], p => {
|
||||
chunkProgress[index] = p;
|
||||
// 第三步:计算总进度
|
||||
// 把账本上所有的百分比加起来
|
||||
const sum = chunkProgress.reduce((acc, cur) => acc + cur, 0);
|
||||
const total = (sum / chunks.length) * 100;
|
||||
const displayTotal = total.toFixed(2);
|
||||
const uploadStatus = {
|
||||
status: displayTotal == 100 ? UPLOAD_STATUS.UPLOADED : UPLOAD_STATUS.UPLOADING,
|
||||
progress: displayTotal,
|
||||
taskId: taskId,
|
||||
url: null
|
||||
}
|
||||
onProgress?.(uploadStatus)
|
||||
});
|
||||
onPartComplete?.(partNum)
|
||||
return data;
|
||||
} else {
|
||||
return { skip, partNumber };
|
||||
}
|
||||
}
|
||||
});
|
||||
const results = await concurrentUpload(tasks, concurrency);
|
||||
|
||||
const errors = results.filter(r => r.status === 'rejected');
|
||||
|
||||
if (errors.length > 0) return;
|
||||
|
||||
await uploadService.completeTask(taskId, comleteData);
|
||||
|
||||
const evtSource = new EventSource(`${request.instance.defaults.baseURL}/upload/events/${taskId}`);
|
||||
|
||||
evtSource.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
const uploadStatus = {
|
||||
status: data.progress == 100 ? UPLOAD_STATUS.COMPLETE : UPLOAD_STATUS.MERGING,
|
||||
taskId: taskId,
|
||||
progress: data.progress,
|
||||
url: data.url
|
||||
}
|
||||
onProgress?.(uploadStatus)
|
||||
|
||||
if (data.status === "Completed") {
|
||||
evtSource.close();
|
||||
}
|
||||
};
|
||||
|
||||
evtSource.onerror = (err) => {
|
||||
console.error("SSE 连接异常", err);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
const concurrentUpload = async (tasks, limit = 3, maxRetries = 3) => {
|
||||
const results = [];
|
||||
const executing = [];
|
||||
for (const task of tasks) {
|
||||
const retryTask = async (task) => {
|
||||
let attempt = 0;
|
||||
while (attempt <= maxRetries) {
|
||||
try {
|
||||
return await task();
|
||||
} catch (e) {
|
||||
attempt++;
|
||||
if (attempt > maxRetries) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const p = Promise.resolve().then(() => retryTask(task));
|
||||
results.push(p);
|
||||
|
||||
if (limit <= tasks.length) {
|
||||
const e = p.finally(() => executing.splice(executing.indexOf(e), 1));
|
||||
executing.push(e);
|
||||
|
||||
if (executing.length >= limit) {
|
||||
await Promise.race(executing);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.allSettled(results);
|
||||
}
|
||||
@ -4,115 +4,118 @@ import { messageService } from "@/services/message";
|
||||
import { useConversationStore } from "./conversation";
|
||||
|
||||
export const useChatStore = defineStore('chat', {
|
||||
state: () => ({
|
||||
activeConversationId: null,
|
||||
activeSessionId: null,
|
||||
maxSequenceId: null,
|
||||
isEnded: false,
|
||||
messages: [],
|
||||
pageSize: 20
|
||||
}),
|
||||
actions: {
|
||||
// 抽取统一的排序去重方法
|
||||
async pushAndSortMessagesAsync(newMsgs, sessionId, shouldSaveToDb = true) {
|
||||
if (shouldSaveToDb) {
|
||||
for (const m of newMsgs) {
|
||||
await messagesDb.save({ ...m, sessionId });
|
||||
}
|
||||
}
|
||||
|
||||
if (sessionId == this.activeSessionId) {
|
||||
const combined = [...this.messages, ...newMsgs];
|
||||
// 1. 根据 msgId 或唯一 key 去重
|
||||
const uniqueMap = new Map();
|
||||
combined.forEach(m => uniqueMap.set(m.msgId || m.sequenceId, m));
|
||||
|
||||
// 2. 转换为数组并按sequenceId升序排序 (旧的在前,新的在后)
|
||||
this.messages = Array.from(uniqueMap.values()).sort((a, b) => {
|
||||
return a.sequenceId - b.sequenceId;
|
||||
});
|
||||
this.maxSequenceId = this.messages.reduce((max, m) =>
|
||||
m.sequenceId > max ? m.sequenceId : max,
|
||||
null // 初始值
|
||||
);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* 切换会话加载当前会话消息列表
|
||||
* @param {*} sessionId
|
||||
*/
|
||||
async swtichSession(sessionId, conversationId) {
|
||||
this.activeSessionId = sessionId;
|
||||
this.activeConversationId = conversationId;
|
||||
this.messages = [];
|
||||
this.isEnded = false;
|
||||
//先从浏览器缓存加载一部分消息列表
|
||||
const localHistory = await messagesDb.getLatestMessages(sessionId, this.pageSize);
|
||||
console.log(localHistory)
|
||||
if (localHistory.length > 0) {
|
||||
this.messages = localHistory;
|
||||
this.maxSequenceId = this.messages.reduce((max, m) =>
|
||||
m.sequenceId > max ? m.sequenceId : max,
|
||||
null // 初始值
|
||||
);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* 从服务器加载新消息
|
||||
* @param {*} sessionId
|
||||
* @returns
|
||||
*/
|
||||
async fetchNewMsgFromServier(conversationId, sequenceId) {
|
||||
const newMsg = (await messageService.getMessages(conversationId, sequenceId, sequenceId ? 1 : 0, this.pageSize)).data;
|
||||
if (newMsg.length > 0) {
|
||||
return newMsg;
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
/**
|
||||
* 从服务器加载历史消息
|
||||
* @param {*} sessionId
|
||||
* @param {*} msgId
|
||||
* @returns
|
||||
*/
|
||||
async fetchHistoryFromServer(conversationId, sequenceId) {
|
||||
const res = (await messageService.getMessages(conversationId, sequenceId, 0, this.pageSize)).data;
|
||||
|
||||
if (res.length > 0) {
|
||||
const sessionId = this.activeSessionId;
|
||||
return res;
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
/**
|
||||
* 加载更多历史消息
|
||||
*/
|
||||
async loadMoreMessages() {
|
||||
let minSequenceId = 0;
|
||||
if (!this.messages || this.messages.length === 0) return;
|
||||
minSequenceId = this.messages.reduce((min, m) =>
|
||||
(m.sequenceId < min ? m.sequenceId : min),
|
||||
this.messages[0].sequenceId // 使用第一项作为初始参考值
|
||||
);
|
||||
const dbCacheList = await messagesDb.getPageMessages(this.activeSessionId, minSequenceId, this.pageSize)
|
||||
const dbMaxSequenceId = dbCacheList.reduce((max, m) =>
|
||||
m.sequenceId > max ? m.sequenceId : max,
|
||||
null // 初始值
|
||||
);
|
||||
if (dbCacheList.length < this.pageSize) {
|
||||
const newList = await this.fetchHistoryFromServer(this.activeConversationId, minSequenceId)
|
||||
if (newList.length === 0) this.isEnded = true;
|
||||
await this.pushAndSortMessagesAsync(newList, this.activeSessionId, true);
|
||||
} else if (dbMaxSequenceId < minSequenceId - 1) {
|
||||
const newList = await this.fetchHistoryFromServer(this.activeConversationId, minSequenceId)
|
||||
if (newList.length === 0) this.isEnded = true;
|
||||
await this.pushAndSortMessagesAsync(newList, this.activeSessionId, true);
|
||||
}
|
||||
else {
|
||||
await this.pushAndSortMessagesAsync(dbCacheList, this.activeSessionId, false);
|
||||
}
|
||||
state: () => ({
|
||||
activeConversationId: null,
|
||||
activeSessionId: null,
|
||||
maxSequenceId: null,
|
||||
isEnded: false,
|
||||
messages: [],
|
||||
pageSize: 20
|
||||
}),
|
||||
actions: {
|
||||
// 抽取统一的排序去重方法
|
||||
async pushAndSortMessagesAsync(newMsgs, sessionId, shouldSaveToDb = true) {
|
||||
if (shouldSaveToDb) {
|
||||
for (const m of newMsgs) {
|
||||
if (m.type != 'Text' && !m.isLoading && !m.isError && !m.isImgLoading) {
|
||||
m.content = JSON.parse(m.content);
|
||||
}
|
||||
await messagesDb.save({ ...m, sessionId });
|
||||
}
|
||||
}
|
||||
|
||||
if (sessionId == this.activeSessionId) {
|
||||
const combined = [...this.messages, ...newMsgs];
|
||||
// 1. 根据 msgId 或唯一 key 去重
|
||||
const uniqueMap = new Map();
|
||||
combined.forEach(m => uniqueMap.set(m.msgId || m.sequenceId, m));
|
||||
|
||||
// 2. 转换为数组并按sequenceId升序排序 (旧的在前,新的在后)
|
||||
this.messages = Array.from(uniqueMap.values()).sort((a, b) => {
|
||||
return a.sequenceId - b.sequenceId;
|
||||
});
|
||||
this.maxSequenceId = this.messages.reduce((max, m) =>
|
||||
m.sequenceId > max ? m.sequenceId : max,
|
||||
null // 初始值
|
||||
);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* 切换会话加载当前会话消息列表
|
||||
* @param {*} sessionId
|
||||
*/
|
||||
async swtichSession(sessionId, conversationId) {
|
||||
this.activeSessionId = sessionId;
|
||||
this.activeConversationId = conversationId;
|
||||
this.messages = [];
|
||||
this.isEnded = false;
|
||||
//先从浏览器缓存加载一部分消息列表
|
||||
const localHistory = await messagesDb.getLatestMessages(sessionId, this.pageSize);
|
||||
console.log(localHistory)
|
||||
if (localHistory.length > 0) {
|
||||
this.messages = localHistory;
|
||||
this.maxSequenceId = this.messages.reduce((max, m) =>
|
||||
m.sequenceId > max ? m.sequenceId : max,
|
||||
null // 初始值
|
||||
);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* 从服务器加载新消息
|
||||
* @param {*} sessionId
|
||||
* @returns
|
||||
*/
|
||||
async fetchNewMsgFromServier(conversationId, sequenceId) {
|
||||
const newMsg = (await messageService.getMessages(conversationId, sequenceId, sequenceId ? 1 : 0, this.pageSize)).data;
|
||||
if (newMsg.length > 0) {
|
||||
return newMsg;
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
/**
|
||||
* 从服务器加载历史消息
|
||||
* @param {*} sessionId
|
||||
* @param {*} msgId
|
||||
* @returns
|
||||
*/
|
||||
async fetchHistoryFromServer(conversationId, sequenceId) {
|
||||
const res = (await messageService.getMessages(conversationId, sequenceId, 0, this.pageSize)).data;
|
||||
|
||||
if (res.length > 0) {
|
||||
const sessionId = this.activeSessionId;
|
||||
return res;
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
/**
|
||||
* 加载更多历史消息
|
||||
*/
|
||||
async loadMoreMessages() {
|
||||
let minSequenceId = 0;
|
||||
if (!this.messages || this.messages.length === 0) return;
|
||||
minSequenceId = this.messages.reduce((min, m) =>
|
||||
(m.sequenceId < min ? m.sequenceId : min),
|
||||
this.messages[0].sequenceId // 使用第一项作为初始参考值
|
||||
);
|
||||
const dbCacheList = await messagesDb.getPageMessages(this.activeSessionId, minSequenceId, this.pageSize)
|
||||
const dbMaxSequenceId = dbCacheList.reduce((max, m) =>
|
||||
m.sequenceId > max ? m.sequenceId : max,
|
||||
null // 初始值
|
||||
);
|
||||
if (dbCacheList.length < this.pageSize) {
|
||||
const newList = await this.fetchHistoryFromServer(this.activeConversationId, minSequenceId)
|
||||
if (newList.length === 0) this.isEnded = true;
|
||||
await this.pushAndSortMessagesAsync(newList, this.activeSessionId, true);
|
||||
} else if (dbMaxSequenceId < minSequenceId - 1) {
|
||||
const newList = await this.fetchHistoryFromServer(this.activeConversationId, minSequenceId)
|
||||
if (newList.length === 0) this.isEnded = true;
|
||||
await this.pushAndSortMessagesAsync(newList, this.activeSessionId, true);
|
||||
}
|
||||
else {
|
||||
await this.pushAndSortMessagesAsync(dbCacheList, this.activeSessionId, false);
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
119
frontend/web/src/utils/imageTools.js
Normal file
119
frontend/web/src/utils/imageTools.js
Normal file
@ -0,0 +1,119 @@
|
||||
export const loadImage = (url) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img); // 成功时返回 img 对象
|
||||
img.onerror = (err) => reject(err); // 失败时报错
|
||||
img.src = url;
|
||||
});
|
||||
};
|
||||
/**
|
||||
* 生成图片缩略图 (返回 Blob 文件)
|
||||
*/
|
||||
export function generateImageThumbnailBlob(img, maxWidth = 200) {
|
||||
return new Promise((resolve) => {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const scale = maxWidth / img.width;
|
||||
canvas.width = maxWidth;
|
||||
canvas.height = img.height * scale;
|
||||
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
// 关键:导出为 Blob
|
||||
canvas.toBlob((blob) => {
|
||||
resolve(blob);
|
||||
}, 'image/jpeg', 0.8);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 获取视频缩略图 (返回 Blob 文件)
|
||||
* 报错或无法渲染画面时自动生成全黑占位图
|
||||
*/
|
||||
export function getVideoThumbnailBlob(file, seekTime = 1) {
|
||||
return new Promise((resolve) => {
|
||||
const video = document.createElement('video');
|
||||
const url = URL.createObjectURL(file);
|
||||
video.muted = true;
|
||||
video.src = url;
|
||||
|
||||
// 统一定义一个生成黑色背景的方法
|
||||
const resolveBlackThumbnail = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
// 如果视频元数据拿不到宽高,给一个默认的 16:9 尺寸
|
||||
canvas.width = video.videoWidth || 320;
|
||||
canvas.height = video.videoHeight || 180;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.fillStyle = '#000000'; // 全黑
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
canvas.toBlob((blob) => {
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(blob);
|
||||
}, 'image/jpeg', 0.8);
|
||||
};
|
||||
|
||||
video.onloadedmetadata = () => {
|
||||
// 检查:如果元数据加载了但没有画面尺寸(只有音频的视频常现)
|
||||
if (video.videoWidth === 0) {
|
||||
resolveBlackThumbnail();
|
||||
} else {
|
||||
video.currentTime = seekTime;
|
||||
}
|
||||
};
|
||||
|
||||
video.onseeked = () => {
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// 尝试绘制,如果解码失败这里可能抛出异常
|
||||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
canvas.toBlob((blob) => {
|
||||
URL.revokeObjectURL(url);
|
||||
// 如果导出的 blob 大小极小(可能是空的),可以在这里进一步检查
|
||||
resolve(blob);
|
||||
}, 'image/jpeg', 0.8);
|
||||
} catch (e) {
|
||||
resolveBlackThumbnail();
|
||||
}
|
||||
};
|
||||
|
||||
// 报错处理:不再 reject,而是给一张黑图
|
||||
video.onerror = () => {
|
||||
console.warn("视频封面截取失败,已生成全黑占位图");
|
||||
resolveBlackThumbnail();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取视频时长
|
||||
* @param {File} file - 视频文件对象
|
||||
* @returns {Promise<number>} - 返回秒数 (float)
|
||||
*/
|
||||
export function getVideoDuration(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const video = document.createElement('video');
|
||||
const url = URL.createObjectURL(file);
|
||||
|
||||
video.preload = 'metadata'; // 关键:只加载元数据
|
||||
video.src = url;
|
||||
|
||||
// 当元数据加载完成后触发
|
||||
video.onloadedmetadata = () => {
|
||||
const duration = video.duration;
|
||||
URL.revokeObjectURL(url); // 释放内存
|
||||
resolve(duration);
|
||||
};
|
||||
|
||||
video.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
reject("无法解析视频元数据");
|
||||
};
|
||||
});
|
||||
}
|
||||
55
frontend/web/src/utils/uploadTools.js
Normal file
55
frontend/web/src/utils/uploadTools.js
Normal file
@ -0,0 +1,55 @@
|
||||
import SparkMD5 from "spark-md5";
|
||||
|
||||
export const getFileHash = (file) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice;
|
||||
const chunkSize = 2 * 1024 * 1024; // 每次读取 2MB
|
||||
const chunks = Math.ceil(file.size / chunkSize);
|
||||
let currentChunk = 0;
|
||||
const spark = new SparkMD5.ArrayBuffer();
|
||||
const fileReader = new FileReader();
|
||||
|
||||
fileReader.onload = (e) => {
|
||||
spark.append(e.target.result); // 将二进制数据添加到计算器
|
||||
currentChunk++;
|
||||
|
||||
if (currentChunk < chunks) {
|
||||
loadNext();
|
||||
} else {
|
||||
resolve(spark.end()); // 完成计算,返回最终结果
|
||||
}
|
||||
};
|
||||
|
||||
fileReader.onerror = () => {
|
||||
reject('文件读取出错');
|
||||
};
|
||||
|
||||
function loadNext() {
|
||||
const start = currentChunk * chunkSize;
|
||||
const end = start + chunkSize >= file.size ? file.size : start + chunkSize;
|
||||
fileReader.readAsArrayBuffer(blobSlice.call(file, start, end));
|
||||
}
|
||||
|
||||
loadNext();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件分片
|
||||
* @param {*} file 文件
|
||||
* @param {*} chunkSize 分片大小
|
||||
* @returns
|
||||
*/
|
||||
export const sliceFile = (file, chunkSize) => {
|
||||
const chuncks = [];
|
||||
let index = 0;
|
||||
|
||||
while (index < file.size) {
|
||||
chuncks.push(file.slice(index, index + chunkSize));
|
||||
index += chunkSize;
|
||||
}
|
||||
|
||||
return chuncks;
|
||||
}
|
||||
|
||||
|
||||
@ -7,38 +7,18 @@
|
||||
</div>
|
||||
|
||||
<div class="search-box">
|
||||
<input
|
||||
v-model="keyword"
|
||||
type="text"
|
||||
placeholder="搜索 ID / 手机号"
|
||||
@keyup.enter="onSearch"
|
||||
<input
|
||||
type="file"
|
||||
placeholder="搜索 ID / 手机号"
|
||||
@change="handleFileChange"
|
||||
/>
|
||||
<button class="search-btn" @click="onSearch" :loading="loading">
|
||||
<span v-if="!loading">搜索</span>
|
||||
<span v-else class="spinner"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<transition name="fade-slide">
|
||||
<div v-if="userResult" class="user-card">
|
||||
<div class="user-info">
|
||||
<img :src="userResult.avatar" class="avatar" />
|
||||
<div class="detail">
|
||||
<span class="name">{{ userResult.nickname }}</span>
|
||||
<span class="id">ID: {{ userResult.userId }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="add-action-btn" @click="handleAdd">添加好友</button>
|
||||
</div>
|
||||
<div v-else-if="hasSearched && !userResult" class="empty-state">
|
||||
未找到该用户,请检查输入是否正确
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { uploadFile } from '@/services/upload/uploader';
|
||||
import { ref } from 'vue';
|
||||
|
||||
// 接收父组件的 v-model 用于控制显示隐藏
|
||||
@ -60,7 +40,7 @@ const close = () => {
|
||||
|
||||
const onSearch = async () => {
|
||||
if (!keyword.value) return;
|
||||
|
||||
|
||||
loading.value = true;
|
||||
hasSearched.value = false;
|
||||
|
||||
@ -68,7 +48,7 @@ const onSearch = async () => {
|
||||
setTimeout(() => {
|
||||
loading.value = false;
|
||||
hasSearched.value = true;
|
||||
|
||||
|
||||
// 模拟搜索结果
|
||||
if (keyword.value === '12345') {
|
||||
userResult.value = {
|
||||
@ -82,6 +62,16 @@ const onSearch = async () => {
|
||||
}, 600);
|
||||
};
|
||||
|
||||
|
||||
const handleFileChange = async (event) => {
|
||||
const file = event.target.files[0];
|
||||
uploadFile(file, {
|
||||
onProgress: (p) => {
|
||||
console.log(`当前进度:${p}%`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
emit('add-friend', userResult.value);
|
||||
// 通常这里会显示“已发送请求”提示
|
||||
@ -229,4 +219,4 @@ const handleAdd = () => {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@ -27,7 +27,7 @@
|
||||
<span class="name">{{ s.targetName ?? '未知用户' }}</span>
|
||||
<span class="time">{{ formatDate(s.dateTime) ?? '1970/1/1 00:00:00' }}</span>
|
||||
</div>
|
||||
<div class="last-msg">{{ s.lastMessage ?? '获取消息内容失败' }}</div>
|
||||
<div class="last-msg">{{ lastMessageHandler(s.lastMessage) ?? '获取消息内容失败' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -94,13 +94,26 @@ function actionHandler(type){
|
||||
case 'addFriend':
|
||||
searchUserModal.value = true;
|
||||
break;
|
||||
case 'createGroup':
|
||||
case 'createGroup':
|
||||
createGroupModal.value = true;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function lastMessageHandler(text){
|
||||
try{
|
||||
const data = JSON.parse(text);
|
||||
if(data.text){
|
||||
return data.text;
|
||||
}else{
|
||||
return text
|
||||
}
|
||||
}catch(e){
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
const chatStore = useChatStore();
|
||||
|
||||
watch(
|
||||
|
||||
@ -12,20 +12,84 @@
|
||||
<HistoryLoading ref="loadingRef" :loading="isLoading" :finished="isFinished" :error="hasError" @retry="loadHistoryMsg"/>
|
||||
<UserHoverCard ref="userHoverCardRef"/>
|
||||
<ContextMenu ref="menuRef"/>
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div v-if="videoOpen" class="video-overlay" @click.self="videoOpen = false">
|
||||
|
||||
<div class="video-dialog">
|
||||
<div class="close-bar" @click="videoOpen = false">
|
||||
<span>正在播放视频</span>
|
||||
<button class="close-btn">×</button>
|
||||
</div>
|
||||
|
||||
<div class="player-wrapper">
|
||||
<vue3-video-player
|
||||
:src="videoUrl"
|
||||
poster="https://xxx.jpg"
|
||||
:controls="true"
|
||||
:autoplay="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<div v-for="m in chatStore.messages" :key="m.id" :class="['msg', m.senderId == myInfo.id ? 'mine' : 'other']">
|
||||
<img @mouseenter="(e) => handleHoverCard(e,m)" @mouseleave="closeHoverCard" :src="m.senderId == myInfo.id ? (myInfo?.avatar || defaultAvatar) : m.senderAvatar ?? defaultAvatar" class="avatar-chat" />
|
||||
|
||||
<img @mouseenter="(e) => handleHoverCard(e,m)" @mouseleave="closeHoverCard" :src="m.senderId == myInfo.id ? (myInfo?.avatar || defaultAvatar) :m.chatType == MESSAGE_TYPE.GROUP ? m.senderAvatar : conversationInfo.targetAvatar ?? defaultAvatar" class="avatar-chat" />
|
||||
|
||||
<div class="msg-content">
|
||||
<div class="group-sendername" v-if="m.chatType == MESSAGE_TYPE.GROUP && m.senderId != myInfo.id">{{ m.senderName }}</div>
|
||||
<div class="bubble" @contextmenu.prevent="(e) => handleRightClick(e, m)">
|
||||
<div v-if="m.type === 'Text'">{{ m.content }}</div>
|
||||
<div v-else-if="m.type === 'emoji'" class="emoji-msg">{{ m.content }}</div>
|
||||
<div v-else-if="m.type === FILE_TYPE.Image" class="image-msg-container" :style="getImageStyle(m.content)">
|
||||
<img
|
||||
class="image-msg-content"
|
||||
:src="m.isImgLoading || m.isLoading ? m.localUrl : m.content.thumb"
|
||||
alt="图片消息" @click="imagePreview(m)"
|
||||
>
|
||||
|
||||
<div v-if="m.isImgLoading || m.isError" class="image-overlay">
|
||||
<div v-if="m.isImgLoading" class="progress-box">
|
||||
<div class="circular-progress">
|
||||
<svg width="40" height="40" viewBox="0 0 40 40">
|
||||
<circle class="bg" cx="20" cy="20" r="16" />
|
||||
<circle
|
||||
class="bar"
|
||||
cx="20" cy="20" r="16"
|
||||
:style="{ strokeDashoffset: 100 - (m.progress || 0) }"
|
||||
/>
|
||||
</svg>
|
||||
<span class="pct">{{ m.progress || 0 }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<i v-if="m.isError" class="error-icon" v-html="feather.icons['alert-circle'].toSvg({width:24, height: 24})"></i>
|
||||
</div>
|
||||
</div>
|
||||
<VideoMsg v-else-if="m.type === FILE_TYPE.Video"
|
||||
:thumbnailUrl="m.localUrl ?? m.content.thumb"
|
||||
:duration="m.content.duration"
|
||||
:w="m.content.w"
|
||||
:h="m.content.h"
|
||||
:uploading="m.isImgLoading"
|
||||
:progress="+m.progress"
|
||||
@play="playHandler(m)"
|
||||
/>
|
||||
<VoiceMsg v-else-if="m.type === FILE_TYPE.Voice"
|
||||
:url="m.localUrl ?? m.content.url"
|
||||
:duration="m.content.duration"
|
||||
:isRead="true"
|
||||
:isSelf="m.senderId != myInfo.id"
|
||||
/>
|
||||
<div class="status" v-if="m.senderId == myInfo.id">
|
||||
<i v-if="m.isError" style="color: red;" v-html="feather.icons['alert-circle'].toSvg({width:18, height: 18})"></i>
|
||||
<i v-if="m.isLoading" class="loaderIcon" v-html="feather.icons['loader'].toSvg({width:18, height: 18})"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<span class="msg-time">{{ formatDate(m.timeStamp) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -33,16 +97,18 @@
|
||||
|
||||
<footer class="chat-footer">
|
||||
<div class="toolbar">
|
||||
<button class="tool-btn" @click="toggleEmoji" v-html="feather.icons['smile'].toSvg({width:18, height: 18})">
|
||||
<button class="tool-btn" @click="toggleEmoji" v-html="feather.icons['smile'].toSvg({width:25, height: 25})">
|
||||
</button>
|
||||
<label class="tool-btn">
|
||||
<i v-html="feather.icons['file'].toSvg({width:18, height: 18})"></i>
|
||||
<input type="file" hidden @change="handleFile" />
|
||||
<i v-html="feather.icons['file'].toSvg({width:25, height: 25})"></i>
|
||||
<input type="file" hidden @change="handleFile($event.target.files)" />
|
||||
</label>
|
||||
<button :class="['tool-btn', isRecord ? 'is-recording' : '']" @mousedown="startRecord" @mouseup="stopRecord" v-html="feather.icons[isRecord ? 'mic' : 'mic-off'].toSvg({width:25, height: 25})">
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
v-model="input"
|
||||
placeholder="请输入消息..."
|
||||
<textarea
|
||||
v-model="input"
|
||||
placeholder="请输入消息..."
|
||||
@keydown.enter.exact.prevent="sendText"
|
||||
></textarea>
|
||||
<div class="send-row">
|
||||
@ -53,24 +119,28 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, nextTick, onMounted, watch, onUnmounted } from 'vue';
|
||||
import { ref, nextTick, onMounted, watch, onUnmounted } from 'vue';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import defaultAvatar from '@/assets/default_avatar.png';
|
||||
import { messageService } from '@/services/message';
|
||||
import { formatDate } from '@/utils/formatDate';
|
||||
import { useChatStore } from '@/stores/chat';
|
||||
import { generateSessionId } from '@/utils/sessionIdTools';
|
||||
import { useSignalRStore } from '@/stores/signalr';
|
||||
import { useConversationStore } from '@/stores/conversation';
|
||||
import feather from 'feather-icons';
|
||||
import { onBeforeRouteUpdate } from 'vue-router';
|
||||
import { MESSAGE_TYPE } from '@/constants/MessageType';
|
||||
import { GetLocalIso } from '@/utils/dateTool';
|
||||
import HistoryLoading from '@/components/messages/HistoryLoading.vue';
|
||||
import { useMessage } from '@/components/messages/useAlert';
|
||||
import { SYSTEM_BASE_STATUS } from '@/constants/systemBaseStatus';
|
||||
import UserHoverCard from '@/components/user/UserHoverCard.vue';
|
||||
import ContextMenu from '@/components/ContextMenu.vue';
|
||||
import { useSendMessageHandler } from './hooks/useSendMessageHandler';
|
||||
import { previewImages } from 'hevue-img-preview/v3'
|
||||
import { useMessage } from '@/components/messages/useAlert';
|
||||
import { FILE_TYPE, getMessageType } from '@/constants/fileTypeDefine';
|
||||
import { generateImageThumbnailBlob, getVideoDuration, getVideoThumbnailBlob, loadImage } from '@/utils/imageTools';
|
||||
import { ImageInfo, VideoInfo, VoiceInfo } from '@/constants/fileTypeInfo';
|
||||
import VideoMsg from '@/components/messages/VideoMsg.vue';
|
||||
import VoiceMsg from '@/components/messages/VoiceMsg.vue';
|
||||
|
||||
|
||||
const props = defineProps({
|
||||
id:{
|
||||
@ -82,6 +152,8 @@ const props = defineProps({
|
||||
const chatStore = useChatStore();
|
||||
const signalRStore = useSignalRStore();
|
||||
const conversationStore = useConversationStore();
|
||||
const message = useMessage();
|
||||
const {sendMessage, sendFileMessage, sendTextMessage} = useSendMessageHandler();
|
||||
|
||||
const input = ref(''); // 输入框内容
|
||||
const historyRef = ref(null); // 绑定 DOM 用于滚动
|
||||
@ -98,8 +170,95 @@ const isLoading = ref(false);
|
||||
const isFinished = ref(false);
|
||||
const hasError = ref(false);
|
||||
let observer = null;
|
||||
const isRecord = ref(false);
|
||||
let mediaRecorder = null;
|
||||
let audioChunks = []; // 用于存储录音的数据片段
|
||||
const videoUrl = ref(null);
|
||||
const videoOpen = ref(false)
|
||||
|
||||
|
||||
const getImageStyle = (content) => {
|
||||
const maxWidth = 200; // 最大宽度
|
||||
const maxHeight = 200; // 最大高度
|
||||
const minSize = 60; // 最小尺寸,防止变成一个点
|
||||
|
||||
let w = content.W || maxWidth;
|
||||
let h = content.H || maxHeight;
|
||||
|
||||
const ratio = w / h;
|
||||
|
||||
if (w > h) {
|
||||
// 横图
|
||||
w = Math.min(w, maxWidth);
|
||||
h = w / ratio;
|
||||
} else {
|
||||
// 纵图
|
||||
h = Math.min(h, maxHeight);
|
||||
w = h * ratio;
|
||||
}
|
||||
|
||||
return {
|
||||
width: `${Math.max(w, minSize)}px`,
|
||||
height: `${Math.max(h, minSize)}px`
|
||||
};
|
||||
};
|
||||
|
||||
const imagePreview = (m) => {
|
||||
const imageList = chatStore.messages
|
||||
.filter(x => x.type == 'Image')
|
||||
;
|
||||
const index = imageList.indexOf(m);
|
||||
previewImages({
|
||||
imgList: imageList.map(m => m.content.url),
|
||||
nowImgIndex: index
|
||||
});
|
||||
}
|
||||
|
||||
const startRecord = async () => {
|
||||
try{
|
||||
const stream = await navigator.mediaDevices.getUserMedia({audio:true});
|
||||
mediaRecorder = new MediaRecorder(stream);
|
||||
audioChunks = [];
|
||||
mediaRecorder.ondataavailable = e => {
|
||||
if(e.data.size > 0) audioChunks.push(e.data);
|
||||
}
|
||||
// 录音结束后的处理
|
||||
mediaRecorder.onstop = () => {
|
||||
const audioBlob = new Blob(audioChunks, { type: 'audio/mp3' });
|
||||
// 生成一个唯一的文件名:时间戳 + 随机数 + 后缀
|
||||
const fileName = `audio_${Date.now()}_${Math.floor(Math.random() * 1000)}.mp3`;
|
||||
|
||||
// 将 Blob 转换为 File 对象
|
||||
const audioFile = new File([audioBlob], fileName, {
|
||||
type: 'audio/mp3',
|
||||
lastModified: Date.now()
|
||||
});
|
||||
handleFile([audioFile]).finally(() => {
|
||||
// 停止所有轨道以关闭麦克风图标
|
||||
stream.getTracks().forEach(track => track.stop());
|
||||
});
|
||||
};
|
||||
|
||||
mediaRecorder.start();
|
||||
isRecord.value = true;
|
||||
}catch(e){
|
||||
console.log(e)
|
||||
message.error('无法获取麦克风权限!');
|
||||
}
|
||||
}
|
||||
|
||||
const stopRecord = async () => {
|
||||
if (mediaRecorder && isRecord.value) {
|
||||
mediaRecorder.stop();
|
||||
isRecord.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const playHandler = (m) => {
|
||||
videoUrl.value = m.content.url
|
||||
videoOpen.value = true
|
||||
}
|
||||
|
||||
const loadHistoryMsg = async () => {
|
||||
// 1. 如果正在加载,或者已经彻底没数据了,才拦截
|
||||
if (isLoading.value || isFinished.value) return;
|
||||
@ -145,30 +304,30 @@ const closeHoverCard = () => {
|
||||
const handleRightClick = (e, m) => {
|
||||
e.stopPropagation();
|
||||
const items = [
|
||||
{
|
||||
label: '复制',
|
||||
action: () => console.log('打开之前的悬浮卡片', user)
|
||||
{
|
||||
label: '复制',
|
||||
action: () => console.log('打开之前的悬浮卡片', user)
|
||||
},
|
||||
{
|
||||
label: '转发',
|
||||
action: () => console.log('进入私聊', user.id)
|
||||
{
|
||||
label: '转发',
|
||||
action: () => console.log('进入私聊', user.id)
|
||||
},
|
||||
{
|
||||
label: '多选',
|
||||
action: () => {}
|
||||
{
|
||||
label: '多选',
|
||||
action: () => {}
|
||||
},
|
||||
{
|
||||
label: '翻译',
|
||||
action: () => {}
|
||||
{
|
||||
label: '翻译',
|
||||
action: () => {}
|
||||
},
|
||||
{
|
||||
label: '引用',
|
||||
action: () => {}
|
||||
{
|
||||
label: '引用',
|
||||
action: () => {}
|
||||
},
|
||||
{
|
||||
label: '删除',
|
||||
type: 'danger',
|
||||
action: () => alert('删除成功')
|
||||
{
|
||||
label: '删除',
|
||||
type: 'danger',
|
||||
action: () => alert('删除成功')
|
||||
}
|
||||
];
|
||||
menuRef.value.show(e, items);
|
||||
@ -196,40 +355,9 @@ const scrollToBottom = async () => {
|
||||
async function sendText() {
|
||||
if (!input.value.trim()) return;
|
||||
// 根据 C# MessageBaseDto 构造的示例对象
|
||||
const msg = {
|
||||
type: "Text", // 消息类型,例如 'Text', 'Image', 'File'
|
||||
chatType: conversationInfo.value.chatType, // 'PRIVATE' 或 'GROUP'
|
||||
senderId: conversationInfo.value.userId, // 当前用户ID (对应 int)
|
||||
receiverId: conversationInfo.value.targetId, // 接收者ID (对应 int)
|
||||
content: input.value,
|
||||
timeStamp: new Date(), // 对应 DateTime
|
||||
msgId: self.crypto.randomUUID()
|
||||
};
|
||||
input.value = ''; // 清空输入框
|
||||
//设置消息为加载状态
|
||||
msg.isLoading = true;
|
||||
//将临时消息推送到消息列表(存库,方便后续重试)
|
||||
await chatStore.pushAndSortMessagesAsync([msg], generateSessionId(msg.senderId, msg.receiverId, msg.chatType == MESSAGE_TYPE.GROUP), true);
|
||||
//更新当前会话最新消息
|
||||
conversationInfo.value.lastMessage = msg.content;
|
||||
//从列表取出消息
|
||||
let updateMsg = msg;
|
||||
try{
|
||||
const res = await messageService.sendMessage(msg);
|
||||
if(res.code != SYSTEM_BASE_STATUS.SUCCESS){
|
||||
updateMsg.isError = true;
|
||||
}else{
|
||||
//发送成功将后端生成的sequenceId更新
|
||||
updateMsg = res.data;
|
||||
}
|
||||
}catch{
|
||||
updateMsg.isError = true;
|
||||
}finally{
|
||||
updateMsg.isLoading = false;
|
||||
chatStore.pushAndSortMessagesAsync([updateMsg], generateSessionId(msg.senderId, msg.receiverId, msg.chatType == MESSAGE_TYPE.GROUP), true);
|
||||
|
||||
msg.isLoading = false;
|
||||
}
|
||||
const content = input.value;
|
||||
input.value = '';
|
||||
await sendTextMessage(content, conversationInfo);
|
||||
}
|
||||
|
||||
// 通话模拟
|
||||
@ -238,9 +366,35 @@ function startCall(type) {
|
||||
}
|
||||
|
||||
// 文件处理模拟
|
||||
function handleFile(e) {
|
||||
const file = e.target.files[0];
|
||||
if (file) console.log('选中文件:', file.name);
|
||||
async function handleFile(files) {
|
||||
const file = files[0];
|
||||
let info = {};
|
||||
let localUrl = null;
|
||||
let img = null;
|
||||
switch(getMessageType(file.type)){
|
||||
case FILE_TYPE.Image:
|
||||
localUrl = URL.createObjectURL(file);
|
||||
img = await loadImage(localUrl);
|
||||
info = new ImageInfo(file.type, '[图片]', img.width, img.height, await generateImageThumbnailBlob(await loadImage(localUrl), 200));
|
||||
break;
|
||||
|
||||
case FILE_TYPE.Video: {
|
||||
const imgBlob = await getVideoThumbnailBlob(file);
|
||||
localUrl = URL.createObjectURL(imgBlob);
|
||||
img = await loadImage(localUrl);
|
||||
|
||||
info = new VideoInfo(file.type,'[视频]', img.width, img.height, imgBlob, await getVideoDuration(file));
|
||||
break;
|
||||
}
|
||||
case FILE_TYPE.Voice: {
|
||||
localUrl = URL.createObjectURL(file);
|
||||
info = new VoiceInfo(file.type,'[语音消息]', await getVideoDuration(file));
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
await sendFileMessage(file, conversationInfo,info,localUrl);
|
||||
}
|
||||
|
||||
function toggleEmoji() {
|
||||
@ -267,7 +421,7 @@ const initChat = async (newId) => {
|
||||
isFinished.value = false;
|
||||
scrollToBottom();
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
watch(
|
||||
@ -276,16 +430,16 @@ watch(
|
||||
() => {
|
||||
// 如果没有 ID,或者 Store 还没加载,返回一个安全的默认值
|
||||
if (!conversationStore.conversations.length) {
|
||||
return [props.id,null];
|
||||
return [props.id,null];
|
||||
}
|
||||
|
||||
|
||||
// 尝试找到当前会话
|
||||
const session = conversationStore.conversations.find(x => x.id == Number(props.id));
|
||||
|
||||
|
||||
// 返回 [ID, 状态],用了 ?. 即使 session 是 undefined 也只会返回 undefined,不会报错
|
||||
return [props.id, session?.isInitialized];
|
||||
},
|
||||
|
||||
|
||||
// 2. 回调逻辑
|
||||
async ([newId, isInited], [oldId, oldInited]) => {
|
||||
// 基础防守:如果 ID 无效,直接跳过
|
||||
@ -297,20 +451,20 @@ watch(
|
||||
//if (newId !== oldId) {
|
||||
// 注意:这里只是加载本地缓存,不负责去服务器拉新
|
||||
// 真正的“拉新”逻辑交给下面的 isInited 判断
|
||||
await initChat(newId);
|
||||
await initChat(newId);
|
||||
//}
|
||||
|
||||
// 场景 B:检测到需要补洞 (isInited 变为 false)
|
||||
// 无论是“刚进页面”还是“SignalR重连”导致的,都会命中这里
|
||||
if (isInited === false) {
|
||||
console.log(`[同步触发] 会话 ${newId} 需要补洞...`);
|
||||
|
||||
|
||||
// 1. 获取当前断点(本地最新一条消息的 ID)
|
||||
const currentMax = chatStore.maxSequenceId;
|
||||
|
||||
//const currentMax = chatStore.maxSequenceId;
|
||||
|
||||
// 2. 去服务器拉取增量数据
|
||||
const msgList = await chatStore.fetchNewMsgFromServier(newId, currentMax);
|
||||
|
||||
const msgList = await chatStore.fetchNewMsgFromServier(newId);
|
||||
|
||||
const session = conversationStore.conversations.find(x => x.id == Number(newId));
|
||||
if(msgList && msgList.length > 0){
|
||||
const minSequenceId = Math.min(...msgList.map(m => m.sequenceId));
|
||||
@ -321,7 +475,7 @@ watch(
|
||||
await chatStore.pushAndSortMessagesAsync(msgList, generateSessionId(session.userId, session.targetId, session.chatType == MESSAGE_TYPE.GROUP), true);
|
||||
}
|
||||
// 3. 如果有新消息,存入 Store
|
||||
|
||||
|
||||
|
||||
|
||||
// 4. 关键步骤:手动把状态回正!
|
||||
@ -342,7 +496,7 @@ const initObs = async () => {
|
||||
await nextTick();
|
||||
observer = new IntersectionObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
|
||||
|
||||
// 只有当 Loading 组件进入视口,且满足触发条件
|
||||
if (entry.isIntersecting) {
|
||||
loadHistoryMsg();
|
||||
@ -399,10 +553,168 @@ onUnmounted(() => {
|
||||
background: #f5f5f5;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
/* 遮罩层:全屏、黑色半透明、固定定位 */
|
||||
.video-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background-color: rgba(0, 0, 0, 0.85);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999; /* 确保在最顶层 */
|
||||
}
|
||||
|
||||
/* 播放器弹窗主体 */
|
||||
.video-dialog {
|
||||
position: relative;
|
||||
width: 90%;
|
||||
max-width: 1000px;
|
||||
background: #000;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
/* 顶部状态栏(包含关闭按钮) */
|
||||
.close-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 20px;
|
||||
background: #1a1a1a;
|
||||
color: #eee;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #fff;
|
||||
font-size: 28px;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
transform: scale(1.2);
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.player-wrapper {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9; /* 锁定 16:9 比例 */
|
||||
background: #000;
|
||||
}
|
||||
|
||||
/* 进场动画 */
|
||||
.fade-enter-active, .fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
.fade-enter-from, .fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.tool-btn {
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
border: none;
|
||||
background: none;
|
||||
display: flex;
|
||||
margin-right: 20px;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tool-btn:hover {
|
||||
background: #b8b8b8;
|
||||
}
|
||||
|
||||
.tool-btn.is-recording {
|
||||
background-color: rgba(0, 196, 98, 0.1);
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { transform: scale(1); }
|
||||
50% { transform: scale(1.1); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
|
||||
.image-msg {
|
||||
max-width: 100px;
|
||||
max-height: 200px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* 容器:由计算属性决定宽高 */
|
||||
.image-msg-container {
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background-color: #f0f0f0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* 图片:填满容器但不拉伸 */
|
||||
.image-msg-content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover; /* 关键:裁剪而非拉伸 */
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 覆盖层 */
|
||||
.image-overlay {
|
||||
position: absolute;
|
||||
inset: 0; /* 铺满父容器 */
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* 环形进度条样式 */
|
||||
.circular-progress {
|
||||
position: relative;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
.circular-progress svg {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
.circular-progress circle {
|
||||
fill: none;
|
||||
stroke-width: 3;
|
||||
}
|
||||
.circular-progress .bg {
|
||||
stroke: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
.circular-progress .bar {
|
||||
stroke: #ffffff;
|
||||
stroke-dasharray: 100; /* 这里的 100 对应周长 */
|
||||
transition: stroke-dashoffset 0.2s;
|
||||
}
|
||||
.circular-progress .pct {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
}
|
||||
.error-icon {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
|
||||
@ -501,6 +813,11 @@ textarea {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
|
||||
.send-row {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
@ -514,4 +831,4 @@ textarea {
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@ -0,0 +1,109 @@
|
||||
import { useChatStore } from "@/stores/chat";
|
||||
import { generateSessionId } from "@/utils/sessionIdTools";
|
||||
import { MESSAGE_TYPE } from "@/constants/MessageType";
|
||||
import { messageService } from "@/services/message";
|
||||
import { SYSTEM_BASE_STATUS } from "@/constants/systemBaseStatus";
|
||||
import { uploadFile } from "@/services/upload/uploader";
|
||||
import { UPLOAD_STATUS } from "@/constants/uploadStatus";
|
||||
import { getMessageType } from "@/constants/fileTypeDefine";
|
||||
import { uploadService } from "@/services/upload/uploadService";
|
||||
import { getFileHash } from "@/utils/uploadTools";
|
||||
|
||||
export function useSendMessageHandler() {
|
||||
const sendMessage = async (msg) => {
|
||||
const chatStore = useChatStore();
|
||||
//设置消息为加载状态
|
||||
const msgServer = { ...msg }
|
||||
msg.isLoading = true;
|
||||
//将临时消息推送到消息列表(存库,方便后续重试)
|
||||
await chatStore.pushAndSortMessagesAsync([msg], generateSessionId(msg.senderId, msg.receiverId, msg.chatType == MESSAGE_TYPE.GROUP), true);
|
||||
//从列表取出消息
|
||||
let updateMsg = msg;
|
||||
try {
|
||||
const res = await messageService.sendMessage(msgServer);
|
||||
if (res.code != SYSTEM_BASE_STATUS.SUCCESS) {
|
||||
updateMsg.isError = true;
|
||||
} else {
|
||||
//发送成功将后端生成的sequenceId更新
|
||||
updateMsg = res.data;
|
||||
}
|
||||
} catch {
|
||||
updateMsg.isError = true;
|
||||
} finally {
|
||||
updateMsg.isLoading = false;
|
||||
chatStore.pushAndSortMessagesAsync([updateMsg], generateSessionId(msg.senderId, msg.receiverId, msg.chatType == MESSAGE_TYPE.GROUP), true);
|
||||
|
||||
msg.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
const sendTextMessage = async (text, conversationInfo) => {
|
||||
const msg = {
|
||||
type: 'Text', // 消息类型,例如 'Text', 'Image', 'File'
|
||||
chatType: conversationInfo.value.chatType, // 'PRIVATE' 或 'GROUP'
|
||||
senderId: conversationInfo.value.userId, // 当前用户ID (对应 int)
|
||||
receiverId: conversationInfo.value.targetId, // 接收者ID (对应 int)
|
||||
content: text,
|
||||
timeStamp: new Date(), // 对应 DateTime
|
||||
msgId: self.crypto.randomUUID()
|
||||
};
|
||||
//更新当前会话最新消息
|
||||
conversationInfo.value.lastMessage = msg.content;
|
||||
await sendMessage(msg);
|
||||
}
|
||||
const sendFileMessage = async (file, conversationInfo, info, localUrl) => {
|
||||
const chatStore = useChatStore();
|
||||
const msg = {
|
||||
type: getMessageType(file.type), // 消息类型,例如 'Text', 'Image', 'File'
|
||||
chatType: conversationInfo.value.chatType, // 'PRIVATE' 或 'GROUP'
|
||||
senderId: conversationInfo.value.userId, // 当前用户ID (对应 int)
|
||||
receiverId: conversationInfo.value.targetId, // 接收者ID (对应 int)
|
||||
content: '',
|
||||
timeStamp: new Date(), // 对应 DateTime
|
||||
msgId: self.crypto.randomUUID(),
|
||||
localUrl: localUrl,
|
||||
progress: 0
|
||||
};
|
||||
//更新当前会话最新消息
|
||||
conversationInfo.value.lastMessage = info.text;
|
||||
msg.isImgLoading = true;
|
||||
await chatStore.pushAndSortMessagesAsync([msg], generateSessionId(msg.senderId, msg.receiverId, msg.chatType == MESSAGE_TYPE.GROUP), true);
|
||||
|
||||
if (info.thumb) {
|
||||
const hash = await getFileHash(info.thumb);
|
||||
try {
|
||||
const { data } = await uploadService.uploadSmallFile(info.thumb, hash);
|
||||
info.thumb = data.objectName;
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
msg.isError = true;
|
||||
msg.isLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
await uploadFile(file, {
|
||||
onProgress: async (e) => {
|
||||
if (!e.status) return;
|
||||
switch (e.status) {
|
||||
case UPLOAD_STATUS.MERGING:
|
||||
case UPLOAD_STATUS.UPLOADING:
|
||||
msg.progress = e.progress;
|
||||
break;
|
||||
case UPLOAD_STATUS.COMPLETE:
|
||||
msg.progress = 100;
|
||||
msg.isImgLoading = false;
|
||||
info.fileId = e.taskId;
|
||||
msg.content = JSON.stringify(info);
|
||||
await sendMessage(msg);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return { sendMessage, sendFileMessage, sendTextMessage };
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user