更新
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

View File

@ -22,5 +22,9 @@
"Port": 5672,
"Username": "test",
"Password": "123456"
},
"FileUploadOptions": {
"DefaultStorage": "Local",
"ChunkSize": 10,
}
}

View File

@ -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")]

View File

@ -1 +1 @@
1b9e709aa84e0b4f6260cd10cf25bfc3a30c60e75a3966fc7d4cdf489eae898b
fa24b386648cc4dba48ae5e3f91e5303b0dfd0971bba62bc27ca1580a5064337

View File

@ -1 +1 @@
6e6df2b3d9fe8d3830882bef146134864f65ca58bc5ea4bac684eaec55cfd628
b2b545acb4173028f4d41b8d8c0aea03ecbcecb4eeabe997ca466c2e265beff6

View File

@ -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"
}
}
}

View File

@ -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\" />

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"
}
}
}

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",

View File

@ -1,3 +1,4 @@
bin/
obj/
.vs/
.vs/
uploads/

View File

@ -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)

View File

@ -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);
}
}
}

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 =>

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>();
}
}
}

View File

@ -0,0 +1,8 @@
namespace IM_API.Configs.Options
{
public class FileUploadOptions
{
public string DefaultStorage { get; set; }
public int ChunkSize { get; set; }
}
}

View File

@ -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 =>

View File

@ -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)]

View File

@ -0,0 +1,124 @@
using IM_API.Dtos;
using IM_API.Interface.Services;
using IM_API.Models.Upload;
using IM_API.Tools;
using IM_API.VOs;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore.Storage;
using StackExchange.Redis;
using System.Text;
using System.Text.Json;
using IDatabase = StackExchange.Redis.IDatabase;
namespace IM_API.Controllers
{
[Authorize]
[Route("api/[controller]")]
[ApiController]
public class UploadController : ControllerBase
{
private readonly IWebHostEnvironment _env;
private readonly IStorageService _storage;
private readonly IDatabase _redis;
public UploadController(IWebHostEnvironment env, IStorageService storage, IConnectionMultiplexer connectionMultiplexer)
{
_env = env;
_storage = storage;
_redis = connectionMultiplexer.GetDatabase();
}
[HttpPost("local/{taskId}/parts/{partNumber}")]
[ProducesResponseType(typeof(BaseResponse<object?>), StatusCodes.Status200OK)]
public async Task<IActionResult> LocalUpload(Guid taskId, int partNumber, IFormFile file)
{
var baseDir = Path.Combine(_env.ContentRootPath, "uploads"); // 项目根目录下 uploads
Directory.CreateDirectory(baseDir);
var path = Path.Combine(baseDir, "temp", taskId.ToString(), $"{partNumber}.part.tmp");
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
using var stream = System.IO.File.Create(path);
await file.CopyToAsync(stream);
await _redis.SetAddAsync(RedisKeys.GetUploadPartKey(taskId), partNumber);
return Ok(new BaseResponse<object?>());
}
[HttpPost("CreateTask")]
[ProducesResponseType(typeof(BaseResponse<CreateUploadTaskVo>), StatusCodes.Status200OK)]
public async Task<IActionResult> CreateUpload(CreateUploadTaskDto dto)
{
var vo = await _storage.InitTaskAsync(dto);
return Ok(new BaseResponse<CreateUploadTaskVo>(vo));
}
[HttpPost("CreatePart")]
public async Task<IActionResult> CreatePart(Guid taskId, int partNum)
{
var vo = await _storage.CreatePartInstructionAsync(taskId, partNum);
return Ok(new BaseResponse<UploadPartInstructionVo>(vo));
}
[HttpPost("CompleteTask")]
public async Task<IActionResult> CompleteTask([FromQuery]Guid taskId, [FromBody]List<UploadPartDto> dtos)
{
var taskIdRes = await _storage.CompleteAsync(taskId, dtos);
return Ok(new BaseResponse<string>(data: taskIdRes.ToString()));
}
[HttpGet("events/{taskId}")]
[AllowAnonymous]
public async Task Events(Guid taskId)
{
Response.Headers.Add("Content-Type", "text/event-stream");
Response.Headers.Add("Cache-Control", "no-cache");
Response.Headers.Add("Connection", "keep-alive");
var lastProgress = -1;
while (!HttpContext.RequestAborted.IsCancellationRequested)
{
var hash = await _redis.HashGetAllAsync(RedisKeys.MergeStatus(taskId));
if (hash.Length == 0)
{
await Task.Delay(1000);
continue;
}
var status = hash.FirstOrDefault(x => x.Name == "status").Value;
var progress = hash.FirstOrDefault(x => x.Name == "progress").Value;
var url = hash.FirstOrDefault(x => x.Name == "url").Value;
// 避免重复发送
if (progress != lastProgress)
{
var data = new
{
status = status.ToString(),
progress = progress.ToString(),
url = (string)url
};
await Response.WriteAsync($"data: {JsonSerializer.Serialize(data)}\n\n");
await Response.Body.FlushAsync();
// 完成后关闭 SSE
if (status == "Completed")
break;
await Task.Delay(1000); // 每秒检查一次
}
}
}
[HttpPost("upload/{hash}")]
public async Task<IActionResult> UploadSmallFile(IFormFile file,string hash)
{
using var stream = file.OpenReadStream();
var res = await _storage.UploadSmallFileAsync(stream, file.FileName, file.ContentType, file.Length, hash);
return Ok(new BaseResponse<UploadTask>(res));
}
}
}

View File

@ -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; }

View File

@ -0,0 +1,13 @@
using IM_API.Dtos;
namespace IM_API.Domain.Events
{
public record UploadMergeEvent : DomainEvent
{
public override string EventType => "IM.FILES_UPLOAD_MERGE";
public Guid TaskId { get; init; }
public List<UploadPartDto> Parts { get; init; }
public int ChunckCount { get; set; }
public string ObjectName { get; set; }
}
}

View File

@ -0,0 +1,10 @@
namespace IM_API.Dtos
{
public class CreateUploadTaskDto
{
public string FileName { get; set; } = default!;
public long FileSize { get; set; }
public string ContentType { get; set; } = default!;
public string FileHash { get; set; } = default!;
}
}

View File

@ -0,0 +1,26 @@
namespace IM_API.Dtos.Message
{
public class RequestMessageType
{
public Guid FileId { get; set; }
public long Size { get; set; }
}
public class BaseMessageType: RequestMessageType
{
public string Url { get; set; }
public string Provider { get; set; }
public string Format { get; set; }
public string Text { get; set; }
}
public class ImageDto() : BaseMessageType
{
public string Thumb { get; set; }
public int W { get; set; }
public int H { get; set; }
}
public class VideoDto() : ImageDto
{
public int Duration { get; set; }
}
}

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() { }
}

View File

@ -0,0 +1,8 @@
namespace IM_API.Dtos
{
public class UploadPartDto
{
public int PartNumber { get; set; }
public string? ETag { get; set; }
}
}

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" />

View File

@ -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);
}
}

View File

@ -0,0 +1,40 @@
using IM_API.Dtos;
using IM_API.Models.Upload;
using IM_API.VOs;
namespace IM_API.Interface.Services
{
public interface IStorageService
{
string ProviderName { get; }
UploadMode Mode { get; }
/// <summary>
/// 初始化上传任务
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
Task<CreateUploadTaskVo> InitTaskAsync(CreateUploadTaskDto dto);
/// <summary>
/// 创建分片任务
/// </summary>
/// <param name="taskId">文件上传任务ID</param>
/// <param name="partNumer"></param>
/// <returns></returns>
Task<UploadPartInstructionVo> CreatePartInstructionAsync(Guid taskId, int partNumer);
Task<Guid> CompleteAsync(
Guid taskId,
List<UploadPartDto> parts
);
Task MergeAsync(Guid taskId, string objectName, int totalChunks, List<UploadPartDto> parts);
Task<UploadTask> UploadSmallFileAsync(Stream stream, string fileName, string fileType, long size, string hash);
string GetDownloadUrl(string objectname);
}
public enum UploadMode
{
Proxy, // 本地 / 后端中转
Direct // 云直传
}
}

View File

@ -0,0 +1,12 @@
using IM_API.Models.Upload;
namespace IM_API.Interface.Services
{
public interface IUploadTaskService
{
Task AddAsync(UploadTask task);
Task<UploadTask?> GetTaskAsync(Guid taskId);
Task<UploadTask?> GetTaskAsync(string hash);
Task UpdateStatusAsync(Guid taskId, UploadStatus status);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,52 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace IM_API.Migrations
{
/// <inheritdoc />
public partial class adduploadtask : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "UploadTasks",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FileName = table.Column<string>(type: "longtext", nullable: false, collation: "latin1_swedish_ci")
.Annotation("MySql:CharSet", "latin1"),
FileSize = table.Column<long>(type: "bigint", nullable: false),
FileHash = table.Column<string>(type: "longtext", nullable: false, collation: "latin1_swedish_ci")
.Annotation("MySql:CharSet", "latin1"),
ContentType = table.Column<string>(type: "longtext", nullable: false, collation: "latin1_swedish_ci")
.Annotation("MySql:CharSet", "latin1"),
ChunkSize = table.Column<int>(type: "int", nullable: false),
TotalChunks = table.Column<int>(type: "int", nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
StorageProvider = table.Column<string>(type: "longtext", nullable: false, collation: "latin1_swedish_ci")
.Annotation("MySql:CharSet", "latin1"),
ObjectName = table.Column<string>(type: "longtext", nullable: false, collation: "latin1_swedish_ci")
.Annotation("MySql:CharSet", "latin1"),
ProviderUploadId = table.Column<string>(type: "longtext", nullable: true, collation: "latin1_swedish_ci")
.Annotation("MySql:CharSet", "latin1"),
CreatedAt = table.Column<DateTimeOffset>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PRIMARY", x => x.Id);
})
.Annotation("MySql:CharSet", "latin1")
.Annotation("Relational:Collation", "latin1_swedish_ci");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "UploadTasks");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,186 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace IM_API.Migrations
{
/// <inheritdoc />
public partial class updateuploadtask : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameTable(
name: "UploadTasks",
newName: "upload_tasks");
migrationBuilder.AlterTable(
name: "upload_tasks")
.Annotation("MySql:CharSet", "utf8mb4")
.Annotation("Relational:Collation", "utf8mb4_general_ci")
.OldAnnotation("MySql:CharSet", "latin1")
.OldAnnotation("Relational:Collation", "latin1_swedish_ci");
migrationBuilder.AlterColumn<string>(
name: "StorageProvider",
table: "upload_tasks",
type: "longtext",
nullable: false,
collation: "utf8mb4_general_ci",
oldClrType: typeof(string),
oldType: "longtext")
.Annotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("MySql:CharSet", "latin1")
.OldAnnotation("Relational:Collation", "latin1_swedish_ci");
migrationBuilder.AlterColumn<string>(
name: "ProviderUploadId",
table: "upload_tasks",
type: "longtext",
nullable: true,
collation: "utf8mb4_general_ci",
oldClrType: typeof(string),
oldType: "longtext",
oldNullable: true)
.Annotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("MySql:CharSet", "latin1")
.OldAnnotation("Relational:Collation", "latin1_swedish_ci");
migrationBuilder.AlterColumn<string>(
name: "ObjectName",
table: "upload_tasks",
type: "longtext",
nullable: false,
collation: "utf8mb4_general_ci",
oldClrType: typeof(string),
oldType: "longtext")
.Annotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("MySql:CharSet", "latin1")
.OldAnnotation("Relational:Collation", "latin1_swedish_ci");
migrationBuilder.AlterColumn<string>(
name: "FileName",
table: "upload_tasks",
type: "longtext",
nullable: false,
collation: "utf8mb4_general_ci",
oldClrType: typeof(string),
oldType: "longtext")
.Annotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("MySql:CharSet", "latin1")
.OldAnnotation("Relational:Collation", "latin1_swedish_ci");
migrationBuilder.AlterColumn<string>(
name: "FileHash",
table: "upload_tasks",
type: "longtext",
nullable: false,
collation: "utf8mb4_general_ci",
oldClrType: typeof(string),
oldType: "longtext")
.Annotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("MySql:CharSet", "latin1")
.OldAnnotation("Relational:Collation", "latin1_swedish_ci");
migrationBuilder.AlterColumn<string>(
name: "ContentType",
table: "upload_tasks",
type: "longtext",
nullable: false,
collation: "utf8mb4_general_ci",
oldClrType: typeof(string),
oldType: "longtext")
.Annotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("MySql:CharSet", "latin1")
.OldAnnotation("Relational:Collation", "latin1_swedish_ci");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameTable(
name: "upload_tasks",
newName: "UploadTasks");
migrationBuilder.AlterTable(
name: "UploadTasks")
.Annotation("MySql:CharSet", "latin1")
.Annotation("Relational:Collation", "latin1_swedish_ci")
.OldAnnotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("Relational:Collation", "utf8mb4_general_ci");
migrationBuilder.AlterColumn<string>(
name: "StorageProvider",
table: "UploadTasks",
type: "longtext",
nullable: false,
collation: "latin1_swedish_ci",
oldClrType: typeof(string),
oldType: "longtext")
.Annotation("MySql:CharSet", "latin1")
.OldAnnotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("Relational:Collation", "utf8mb4_general_ci");
migrationBuilder.AlterColumn<string>(
name: "ProviderUploadId",
table: "UploadTasks",
type: "longtext",
nullable: true,
collation: "latin1_swedish_ci",
oldClrType: typeof(string),
oldType: "longtext",
oldNullable: true)
.Annotation("MySql:CharSet", "latin1")
.OldAnnotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("Relational:Collation", "utf8mb4_general_ci");
migrationBuilder.AlterColumn<string>(
name: "ObjectName",
table: "UploadTasks",
type: "longtext",
nullable: false,
collation: "latin1_swedish_ci",
oldClrType: typeof(string),
oldType: "longtext")
.Annotation("MySql:CharSet", "latin1")
.OldAnnotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("Relational:Collation", "utf8mb4_general_ci");
migrationBuilder.AlterColumn<string>(
name: "FileName",
table: "UploadTasks",
type: "longtext",
nullable: false,
collation: "latin1_swedish_ci",
oldClrType: typeof(string),
oldType: "longtext")
.Annotation("MySql:CharSet", "latin1")
.OldAnnotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("Relational:Collation", "utf8mb4_general_ci");
migrationBuilder.AlterColumn<string>(
name: "FileHash",
table: "UploadTasks",
type: "longtext",
nullable: false,
collation: "latin1_swedish_ci",
oldClrType: typeof(string),
oldType: "longtext")
.Annotation("MySql:CharSet", "latin1")
.OldAnnotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("Relational:Collation", "utf8mb4_general_ci");
migrationBuilder.AlterColumn<string>(
name: "ContentType",
table: "UploadTasks",
type: "longtext",
nullable: false,
collation: "latin1_swedish_ci",
oldClrType: typeof(string),
oldType: "longtext")
.Annotation("MySql:CharSet", "latin1")
.OldAnnotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("Relational:Collation", "utf8mb4_general_ci");
}
}
}

View File

@ -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")

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");

View File

@ -0,0 +1,10 @@
namespace IM_API.Models.Upload
{
public enum UploadStatus
{
Created,
Uploading,
Completed,
Aborted
}
}

View File

@ -0,0 +1,24 @@
namespace IM_API.Models.Upload
{
public class UploadTask
{
public Guid Id { get; set; }
public string FileName { get; set; } = default!;
public long FileSize { get; set; }
public string FileHash { get; set; }
public string ContentType { get; set; } = default!;
public int ChunkSize { get; set; }
public int TotalChunks { get; set; }
public UploadStatus Status { get; set; }
public string StorageProvider { get; set; } = default!;
public string ObjectName { get; set; } = default!;
public string? ProviderUploadId { get; set; } // OSS/S3 UploadId
public DateTimeOffset CreatedAt { get; set; }
}
}

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.

View File

@ -0,0 +1,236 @@
using AutoMapper;
using IM_API.Configs.Options;
using IM_API.Domain.Events;
using IM_API.Dtos;
using IM_API.Exceptions;
using IM_API.Interface.Services;
using IM_API.Models.Upload;
using IM_API.Tools;
using IM_API.VOs;
using MassTransit;
using Microsoft.EntityFrameworkCore.Storage;
using StackExchange.Redis;
using System.Security.AccessControl;
using System.Security.Claims;
using System.Threading.Tasks;
using IDatabase = StackExchange.Redis.IDatabase;
namespace IM_API.Services
{
public class LocalStorageService : IStorageService
{
private readonly IMapper _mapper;
private readonly IHttpContextAccessor _httpContext;
private FileUploadOptions _options;
private readonly IUploadTaskService _uploadTaskService;
private readonly IDatabase _redis;
private readonly IHostEnvironment _env;
private readonly ILogger<LocalStorageService> _logger;
private readonly IPublishEndpoint _endpoint;
public LocalStorageService(IMapper mapper, IHttpContextAccessor httpContextAccessor,
IConfiguration configuration, IUploadTaskService uploadTaskService,
IConnectionMultiplexer connectionMultiplexer, IHostEnvironment hostEnvironment
, ILogger<LocalStorageService> logger, IPublishEndpoint publishEndpoint)
{
_mapper = mapper;
_httpContext = httpContextAccessor;
_options = configuration.GetSection("FileUploadOptions").Get<FileUploadOptions>()!;
_uploadTaskService = uploadTaskService;
_redis = connectionMultiplexer.GetDatabase();
_env = hostEnvironment;
_logger = logger;
_endpoint = publishEndpoint;
}
public UploadMode Mode => UploadMode.Proxy;
public string ProviderName => "Local";
public async Task<Guid> CompleteAsync(Guid taskId, List<UploadPartDto> parts)
{
var task = await _uploadTaskService.GetTaskAsync(taskId);
if(task is null)
throw new BaseException(CodeDefine.CHUNKE_NOT_FOUND);
var partsToCheck = Enumerable.Range(1, task.TotalChunks)
.Select(i => (RedisValue)i).ToArray();
var results = await _redis.SetContainsAsync(RedisKeys.GetUploadPartKey(taskId), partsToCheck);
// 3. 快速判断是否全部存在
bool isAllUploaded = results.All(exists => exists);
if (!isAllUploaded) throw new BaseException(CodeDefine.CHUNKE_NOT_FOUND);
await _endpoint.Publish(new UploadMergeEvent
{
AggregateId = taskId.ToString(),
OccurredAt = DateTime.UtcNow,
EventId = Guid.NewGuid(),
OperatorId = 0,
Parts = parts,
TaskId = taskId,
ChunckCount = task.TotalChunks,
ObjectName = task.ObjectName
});
return taskId;
}
public async Task MergeAsync(Guid taskId, string objectName, int totalChunks, List<UploadPartDto> parts)
{
var baseDir = Path.Combine(_env.ContentRootPath, "uploads");
var tempPath = Path.Combine(baseDir, "temp", taskId.ToString()); // 项目根目录下 uploads // 最终文件存储路径(这里可以用你之前 ObjectNameGenerator 生成的名字)
var finalPath = Path.Combine(baseDir, "files", objectName);
var finalDir = Path.GetDirectoryName(finalPath);
Directory.CreateDirectory(finalDir);
try
{
using (var finalStream = new FileStream(finalPath, FileMode.Create))
{
for (var i = 1; i <= totalChunks; i++)
{
var progress = (i * 100.0 / totalChunks);
if (i % 5 == 0 || i == totalChunks)
{
await _redis.HashSetAsync(RedisKeys.MergeStatus(taskId), new HashEntry[]
{
new("status", "processing"),
new("progress", progress.ToString("F2"))
});
}
var chunkPath = Path.Combine(tempPath, $"{i}.part.tmp");
if (!File.Exists(chunkPath))
throw new BaseException(CodeDefine.CHUNKE_NOT_FOUND);
using (var chunkStream = new FileStream(chunkPath, FileMode.Open))
{
await chunkStream.CopyToAsync(finalStream);
}
}
Directory.Delete(tempPath, true);
await _redis.KeyDeleteAsync(RedisKeys.GetUploadPartKey(taskId));
await _uploadTaskService.UpdateStatusAsync(taskId, UploadStatus.Completed);
await _redis.HashSetAsync(RedisKeys.MergeStatus(taskId), new HashEntry[]
{
new("status", "Completed"),
new("progress", "100"),
new("url", objectName)
});
}
}
catch (Exception e) when (e is not BaseException)
{
_logger.LogError(e, e.Message);
throw new BaseException(CodeDefine.CHUNKE_COMBINE_FAIL);
}
}
public async Task<UploadPartInstructionVo> CreatePartInstructionAsync(Guid taskId, int partNumer)
{
if (await _redis.SetContainsAsync(RedisKeys.GetUploadPartKey(taskId), partNumer)){
return new UploadPartInstructionVo
{
PartNumber = partNumer,
Skip = true,
Headers = new Dictionary<string, string>()
};
}
var request = _httpContext.HttpContext!.Request;
var scheme = request.Scheme; // http 或 https
var host = request.Host.Value; // localhost:5000 或域名
var baseUrl = $"{scheme}://{host}/api/upload/local/{taskId}/parts/{partNumer}";
var headers = new Dictionary<string, string>();
headers.Add("Content-Type", "multipart/form-data");
return new UploadPartInstructionVo
{
Method = "POST",
PartNumber = partNumer,
Skip = false,
Url = baseUrl,
Headers = headers
};
}
public async Task<UploadTask> UploadSmallFileAsync(Stream stream, string fileName, string fileType, long size, string hash)
{
var taskOld = await _uploadTaskService.GetTaskAsync(hash);
if (taskOld is not null) return taskOld;
var userId = _httpContext.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier);
var objectname = ObjectNameGenerator.Generate(new ObjectNameContext
{
ContentType = fileType,
FileName = fileName,
UserId = int.Parse(userId)
});
var path = GetDownloadUrl(objectname);
// 4. 将 Stream 写入本地文件
using (var fileStream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None))
{
await stream.CopyToAsync(fileStream);
}
var task = new UploadTask
{
CreatedAt = DateTime.UtcNow,
ChunkSize = (int)size,
ContentType = fileType,
FileHash = hash,
FileName = fileName,
FileSize = size,
Id = Guid.NewGuid(),
ObjectName = objectname,
ProviderUploadId = Guid.NewGuid().ToString(),
Status = UploadStatus.Completed,
StorageProvider = ProviderName,
TotalChunks = 1
};
await _uploadTaskService.AddAsync(task);
return task;
}
public string GetDownloadUrl(string objectname)
{
var baseDir = Path.Combine(_env.ContentRootPath, "uploads"); // 最终文件存储路径(这里可以用你之前 ObjectNameGenerator 生成的名字)
var finalPath = Path.Combine(baseDir, "files", objectname);
var finalDir = Path.GetDirectoryName(finalPath);
Directory.CreateDirectory(finalDir);
return finalPath;
}
public async Task<CreateUploadTaskVo> InitTaskAsync(CreateUploadTaskDto dto)
{
var userId = _httpContext.HttpContext.User.FindFirstValue(ClaimTypes.NameIdentifier);
UploadTask task = _mapper.Map<UploadTask>(dto);
var taskOld = await _uploadTaskService.GetTaskAsync(dto.FileHash);
if(taskOld != null)
{
var t = _mapper.Map<CreateUploadTaskVo>(taskOld);
t.Skip = false;
if (taskOld.Status == UploadStatus.Completed)
{
t.Skip = true;
}
return t;
task = taskOld;
}
task.ObjectName = ObjectNameGenerator.Generate(new ObjectNameContext
{
ContentType = task.ContentType,
FileName = task.FileName,
UserId = int.Parse(userId)
});
task.StorageProvider = ProviderName;
task.ProviderUploadId = Guid.NewGuid().ToString();
task.ChunkSize = _options.ChunkSize;
task.TotalChunks = (int)Math.Ceiling((double)task.FileSize / _options.ChunkSize);
await _uploadTaskService.AddAsync(task);
return _mapper.Map<CreateUploadTaskVo>(task);
}
}
}

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;
}
}
}

View File

@ -0,0 +1,43 @@
using IM_API.Interface.Services;
using IM_API.Models;
using IM_API.Models.Upload;
using Microsoft.EntityFrameworkCore;
namespace IM_API.Services
{
public class UploadTaskService : IUploadTaskService
{
private readonly ImContext _context;
public UploadTaskService(ImContext context)
{
_context = context;
}
public async Task AddAsync(UploadTask task)
{
_context.UploadTasks.Add(task);
await _context.SaveChangesAsync();
}
public async Task<UploadTask?> GetTaskAsync(Guid taskId)
{
return await _context.UploadTasks.FirstOrDefaultAsync(x => x.Id == taskId);
}
public async Task<UploadTask?> GetTaskAsync(string hash)
{
return await _context.UploadTasks.FirstOrDefaultAsync(x => x.FileHash == hash);
}
public async Task UpdateStatusAsync(Guid taskId, UploadStatus status)
{
var task = await _context.UploadTasks.FirstOrDefaultAsync(x => x.Id == taskId);
if (task != null)
{
task.Status = status;
_context.UploadTasks.Update(task);
await _context.SaveChangesAsync();
}
}
}
}

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, "分片合并失败");
}
}

View File

@ -0,0 +1,51 @@
namespace IM_API.Tools
{
public static class ObjectNameGenerator
{
public static string Generate(ObjectNameContext ctx)
{
var ext = GetExtension(ctx.FileName, ctx.ContentType);
var shortId = Guid.NewGuid().ToString("N")[..12];
var parts = new List<string>
{
ctx.Biz,
ctx.Now.Year.ToString(),
ctx.Now.Month.ToString("D2")
};
if (ctx.UserId.HasValue)
{
parts.Add(ctx.UserId.Value.ToString());
}
parts.Add($"{shortId}{ext}");
return string.Join("/", parts);
}
private static string GetExtension(string fileName, string contentType)
{
var ext = Path.GetExtension(fileName);
if (!string.IsNullOrWhiteSpace(ext))
return ext.ToLowerInvariant();
return contentType switch
{
"image/jpeg" => ".jpg",
"image/png" => ".png",
"video/mp4" => ".mp4",
_ => ".bin"
};
}
}
public class ObjectNameContext
{
public string Biz { get; init; } = "IM";
public long? UserId { get; init; }
public string FileName { get; init; } = default!;
public string ContentType { get; init; } = default!;
public DateTimeOffset Now { get; init; } = DateTimeOffset.UtcNow;
}
}

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";
}
}

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);
}
}
}

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; }
}
}

View File

@ -0,0 +1,14 @@
namespace IM_API.VOs
{
public class UploadPartInstructionVo
{
public bool Skip { get; set; }
public int PartNumber { get; set; }
public string Method { get; set; } = "PUT";
public string Url { get; set; } = default!;
public Dictionary<string, string> Headers { get; set; } = new();
}
}

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,
}
}

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

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.*

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")

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:

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"
},

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",

View File

@ -0,0 +1,124 @@
<template>
<div class="video-msg-container" @click="handlePlay">
<img :src="thumbnailUrl" class="video-poster" :style="containerStyle" />
<div class="play-icon-overlay">
<div class="play-button"></div>
</div>
<span v-if="duration" class="duration-label">
{{ formatDuration(duration) }}
</span>
<div v-if="uploading" class="upload-mask">
<div class="progress">{{ progress }}%</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
// URL Blob ObjectURL
thumbnailUrl: String,
//
duration: Number,
//
w: Number,
h: Number,
uploading: Boolean,
progress: Number
});
const emit = defineEmits(['play']);
// 75 -> "01:15"
const formatDuration = (seconds) => {
if (!seconds) return '00:00';
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
};
//
const containerStyle = computed(() => {
const maxSide = 200; //
if (props.w > props.h) {
return { width: maxSide + 'px', height: 'auto', aspectRatio: `${props.w}/${props.h}` };
} else {
return { height: maxSide + 'px', width: 'auto', aspectRatio: `${props.w}/${props.h}` };
}
});
const handlePlay = () => {
emit('play');
};
</script>
<style scoped>
.video-msg-container {
position: relative;
cursor: pointer;
border-radius: 8px;
overflow: hidden;
background-color: #000;
display: flex;
align-items: center;
justify-content: center;
}
.video-poster {
display: block;
object-fit: cover;
background: #2a2a2a;
}
.play-icon-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.2);
}
.play-button {
width: 40px;
height: 40px;
background: rgba(255, 255, 255, 0.8);
border-radius: 50%;
position: relative;
}
.play-button::after {
content: '';
position: absolute;
left: 16px;
top: 10px;
border-style: solid;
border-width: 10px 0 10px 15px;
border-color: transparent transparent transparent #333;
}
.duration-label {
position: absolute;
bottom: 4px;
right: 6px;
color: #fff;
font-size: 12px;
background: rgba(0, 0, 0, 0.5);
padding: 2px 6px;
border-radius: 4px;
}
.upload-mask {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
}
</style>

View File

@ -0,0 +1,30 @@
<template>
<div id="Video"></div>
</template>
<script setup>
import Player from 'xgplayer/dist/simple_player';
import volume from 'xgplayer/dist/controls/volume';
import playbackRate from 'xgplayer/dist/controls/playbackRate';
import { defineProps } from 'vue';
const props = defineProps({
m: {
type: Object,
required:true
}
});
let player = new Player({
id: 'Video',
url: props.m.content.url,
controlPlugins: [
volume,
playbackRate
],
playbackRate: [0.5, 0.75, 1, 1.5, 2] //
});
</script>

View File

@ -0,0 +1,158 @@
<template>
<div
class="voice-card"
:class="{ 'playing': isPlaying }"
:style="{ width: bubbleWidth + 'px' }"
@click="togglePlay"
>
<div class="play-icon-wrap">
<svg v-if="!isPlaying" viewBox="0 0 24 24" class="svg-obj">
<path fill="currentColor" d="M8 5v14l11-7z"/>
</svg>
<svg v-else viewBox="0 0 24 24" class="svg-obj">
<path fill="currentColor" d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
</svg>
</div>
<div class="wave-track">
<div v-for="n in 40" :key="n" class="wave-item"></div>
</div>
<span class="time-label">{{ Math.floor(duration) }}''</span>
<div v-if="!isRead" class="unread-dot"></div>
<audio ref="audioRef" :src="url" @ended="stopPlay" @error="stopPlay"></audio>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
const props = defineProps({
url: { type: String, required: true },
duration: { type: Number, default: 0 },
isRead: { type: Boolean, default: true }
});
const emit = defineEmits(['played']);
const audioRef = ref(null);
const isPlaying = ref(false);
const bubbleWidth = computed(() => Math.min(110 + props.duration * 4, 260));
const togglePlay = (e) => {
e.stopPropagation();
if (isPlaying.value) {
audioRef.value.pause();
stopPlay();
} else {
audioRef.value.play();
isPlaying.value = true;
if (!props.isRead) emit('played');
}
};
const stopPlay = () => {
isPlaying.value = false;
if (audioRef.value) audioRef.value.currentTime = 0;
};
</script>
<style scoped>
/* 核心改动:纯白背景 + 物理阴影 + 明确边框 */
.voice-card {
display: flex;
align-items: center;
height: 40px;
padding: 0 14px;
background: #ffffff; /* 强迫其在灰白背景上显现 */
border: 1.5px solid #e8eaed; /* 更有质感的深色边框 */
border-radius: 20px; /* 全圆角更现代 */
cursor: pointer;
position: relative;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
user-select: none;
color: #202124;
/* 增加微妙的投影,产生高度感 */
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.voice-card:hover {
border-color: #007aff;
box-shadow: 0 4px 12px rgba(0, 122, 255, 0.12);
}
.voice-card:active {
transform: scale(0.96);
}
/* 播放图标:使用品牌蓝 */
.play-icon-wrap {
width: 20px;
height: 20px;
flex-shrink: 0;
display: flex;
align-items: center;
color: #007aff;
}
.svg-obj { width: 100%; height: 100%; }
/* 波形区 */
.wave-track {
flex: 1;
display: flex;
align-items: center;
gap: 3px;
margin: 0 12px;
height: 24px;
overflow: hidden;
}
.wave-item {
width: 2px;
height: 4px;
background-color: #bdc1c6; /* 加深线条颜色,防止看不清 */
border-radius: 1px;
flex-shrink: 0;
transition: all 0.2s ease;
}
/* 播放态动画 */
.playing .wave-item {
background-color: #007aff;
animation: bar-throb 0.7s infinite alternate;
}
/* 使用 nth-child 赋予不同的波浪起伏感 */
.wave-item:nth-child(4n+1) { height: 6px; animation-delay: 0.1s; }
.wave-item:nth-child(4n+2) { height: 14px; animation-delay: 0.3s; }
.wave-item:nth-child(4n+3) { height: 10px; animation-delay: 0.2s; }
.wave-item:nth-child(4n+4) { height: 6px; animation-delay: 0.4s; }
@keyframes bar-throb {
from { transform: scaleY(1); opacity: 0.7; }
to { transform: scaleY(1.8); opacity: 1; }
}
.time-label {
font-size: 13px;
font-weight: 800; /* 加粗时间,更清晰 */
color: #5f6368;
flex-shrink: 0;
font-family: 'Helvetica Neue', sans-serif;
}
/* 未读红点:增加发光效果 */
.unread-dot {
position: absolute;
top: -2px;
right: -2px;
width: 9px;
height: 9px;
background: #f44336;
border-radius: 50%;
border: 2px solid #fff;
box-shadow: 0 1px 4px rgba(244, 67, 54, 0.4);
}
</style>

View File

@ -0,0 +1,40 @@
export const getMessageType = (fileType) => {
if (!fileType) return FILE_TYPE.File; // 兜底处理
// 处理图片
if (fileType.startsWith('image/')) {
return FILE_TYPE.Image;
}
// 处理音频(录音消息)
if (fileType.startsWith('audio/')) {
return FILE_TYPE.Voice;
}
// 处理视频
if (fileType.startsWith('video/')) {
return FILE_TYPE.Video;
}
// 常见文档类型的特殊处理(可选)
const documentTypes = [
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'text/plain'
];
if (documentTypes.includes(fileType)) {
return FILE_TYPE.File;
}
// 其他所有情况统一归类为文件
return FILE_TYPE.File;
};
export const FILE_TYPE = Object.freeze({
Image: 'Image',
Video: 'Video',
Voice: 'Voice',
File: 'File'
});

View 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;
}
}

View File

@ -0,0 +1,7 @@
export const UPLOAD_STATUS = Object.freeze({
UPLOADING: 'uploading',
UPLOADED: 'uploaded',
MERGING: 'merging',
COMPLETE: 'complete'
})

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)

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,
};

View File

@ -0,0 +1,52 @@
import { request } from "../api";
export const uploadService = {
/**
* 创建文件上传任务
* @param {*} fileName 文件名
* @param {*} fileSize 文件大小
* @param {*} contentType 文件类型
* @param {*} fileHash 文件哈希
* @returns
*/
createUploadTask: (fileName, fileSize, contentType, fileHash) => request.post('/Upload/CreateTask', {
fileName: fileName,
fileSize: fileSize,
contentType: contentType,
fileHash: fileHash
}),
/**
* 创建分段任务
* @param {*} taskId 任务ID
* @param {*} partNum 分段序号
* @returns
*/
createPartTask: (taskId, partNum) => request.post(`/Upload/CreatePart?taskId=${taskId}&partNum=${partNum}`),
completeTask: (taskId, data) => request.post(`/Upload/CompleteTask?taskId=${taskId}`, data),
uploadPart: (uploadUrl, headers, file, onProgress) => {
const formData = new FormData()
formData.append('file', file)
return request.post(
uploadUrl,
formData,
{
baseURL: '',
headers, // 不要包含 Content-Type
onUploadProgress: e => {
if (onProgress && e.total) {
onProgress(e.loaded / e.total)
}
}
}
)
},
uploadSmallFile: (file, hash) => {
const formData = new FormData()
formData.append('file', file)
return request.post(`/Upload/upload/${hash}`, formData);
}
}

View File

@ -0,0 +1,122 @@
import { reactive } from "vue";
import { uploadService } from "./uploadService";
import { getFileHash, sliceFile } from "@/utils/uploadTools";
import { request } from "../api";
import { UPLOAD_STATUS } from "@/constants/uploadStatus";
export const uploadFile = async (file, {
onProgress,
onPartComplete
} = {}) => {
const fileHash = await getFileHash(file);
const { taskId, chunkSize, concurrency, skip, url } = (await uploadService.createUploadTask(file.name, file.size, file.type, fileHash)).data;
if (skip) {
const uploadStatus = {
status: UPLOAD_STATUS.COMPLETE,
progress: 100,
taskId: taskId,
url: url
}
onProgress?.(uploadStatus)
return;
}
const chunks = sliceFile(file, chunkSize);
const comleteData = [];
let chunkProgress = reactive(new Array(chunks.length).fill(0))
const tasks = chunks.map((chunk, index) => {
return async () => {
const partNum = index + 1;
const { skip, method, url, headers, partNumber } = (await uploadService.createPartTask(taskId, partNum)).data;
if (!skip) {
const { data } = await uploadService.uploadPart(url, headers, chunks[index], p => {
chunkProgress[index] = p;
// 第三步:计算总进度
// 把账本上所有的百分比加起来
const sum = chunkProgress.reduce((acc, cur) => acc + cur, 0);
const total = (sum / chunks.length) * 100;
const displayTotal = total.toFixed(2);
const uploadStatus = {
status: displayTotal == 100 ? UPLOAD_STATUS.UPLOADED : UPLOAD_STATUS.UPLOADING,
progress: displayTotal,
taskId: taskId,
url: null
}
onProgress?.(uploadStatus)
});
onPartComplete?.(partNum)
return data;
} else {
return { skip, partNumber };
}
}
});
const results = await concurrentUpload(tasks, concurrency);
const errors = results.filter(r => r.status === 'rejected');
if (errors.length > 0) return;
await uploadService.completeTask(taskId, comleteData);
const evtSource = new EventSource(`${request.instance.defaults.baseURL}/upload/events/${taskId}`);
evtSource.onmessage = (event) => {
const data = JSON.parse(event.data);
const uploadStatus = {
status: data.progress == 100 ? UPLOAD_STATUS.COMPLETE : UPLOAD_STATUS.MERGING,
taskId: taskId,
progress: data.progress,
url: data.url
}
onProgress?.(uploadStatus)
if (data.status === "Completed") {
evtSource.close();
}
};
evtSource.onerror = (err) => {
console.error("SSE 连接异常", err);
};
}
const concurrentUpload = async (tasks, limit = 3, maxRetries = 3) => {
const results = [];
const executing = [];
for (const task of tasks) {
const retryTask = async (task) => {
let attempt = 0;
while (attempt <= maxRetries) {
try {
return await task();
} catch (e) {
attempt++;
if (attempt > maxRetries) {
throw e;
}
}
}
}
const p = Promise.resolve().then(() => retryTask(task));
results.push(p);
if (limit <= tasks.length) {
const e = p.finally(() => executing.splice(executing.indexOf(e), 1));
executing.push(e);
if (executing.length >= limit) {
await Promise.race(executing);
}
}
}
return Promise.allSettled(results);
}

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);
}
}
})
}
})

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("无法解析视频元数据");
};
});
}

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;
}

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>

View File

@ -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(

View File

@ -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>

View File

@ -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 };
}