更新
This commit is contained in:
2026-02-23 18:52:32 +08:00
parent d429560511
commit 123fa6a7aa
81 changed files with 5952 additions and 407 deletions
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.
@@ -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\" />
+34 -1
View File
@@ -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"
}
}
}
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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);
}
}
}
+2
View File
@@ -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 =>
+32
View File
@@ -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>();
}
}
}
@@ -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)]
@@ -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; }
@@ -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; }
}
}
@@ -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!;
}
}
@@ -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; }
}
}
+1 -1
View File
@@ -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
View File
@@ -0,0 +1,8 @@
namespace IM_API.Dtos
{
public class UploadPartDto
{
public int PartNumber { get; set; }
public string? ETag { get; set; }
}
}
+1
View File
@@ -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);
}
}
@@ -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 // 云直传
}
}
@@ -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);
}
}
File diff suppressed because it is too large Load Diff
@@ -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");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -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")
+13
View File
@@ -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");
@@ -0,0 +1,10 @@
namespace IM_API.Models.Upload
{
public enum UploadStatus
{
Created,
Uploading,
Completed,
Aborted
}
}
@@ -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; }
}
}
+21 -1
View File
@@ -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.
@@ -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);
}
}
}
+50 -3
View File
@@ -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;
}
}
}
@@ -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();
}
}
}
}
+6
View File
@@ -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, "分片合并失败");
}
}
@@ -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;
}
}
+8 -5
View File
@@ -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
View 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
View 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; }
}
}
@@ -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();
}
}
+5 -1
View File
@@ -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,
}
}
+18 -9
View File
@@ -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
File diff suppressed because one or more lines are too long
+10
View File
@@ -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.*
+23 -13
View File
@@ -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")
+40 -40
View File
@@ -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:
+152 -14
View File
@@ -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"
},
+3
View File
@@ -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",
@@ -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>
@@ -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>
@@ -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>
@@ -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'
});
@@ -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;
}
}
@@ -0,0 +1,7 @@
export const UPLOAD_STATUS = Object.freeze({
UPLOADING: 'uploading',
UPLOADED: 'uploaded',
MERGING: 'merging',
COMPLETE: 'complete'
})
+6
View File
@@ -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)
+78 -77
View File
@@ -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,
};
@@ -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);
}
}
@@ -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);
}
+113 -110
View File
@@ -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
View 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
View 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;
}
+18 -28
View File
@@ -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">&times;</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 };
}