Merge branch 'feature-nxdev' of https://gitea.nxsir.cn/code/IM into feature-nxdev

This commit is contained in:
西街长安 2026-02-01 13:22:12 +08:00
commit 6d747327df
73 changed files with 1342 additions and 484 deletions

View File

@ -4,13 +4,14 @@ using Moq;
using AutoMapper;
using IM_API.Services;
using IM_API.Models;
using IM_API.Dtos;
using IM_API.Exceptions;
using IM_API.Tools;
using Microsoft.Extensions.Logging;
using System;
using System.Threading;
using System.Threading.Tasks;
using IM_API.Dtos.Auth;
using IM_API.Dtos.User;
public class AuthServiceTests
{

View File

@ -1,6 +1,6 @@
using AutoMapper;
using IM_API.Domain.Events;
using IM_API.Dtos;
using IM_API.Dtos.Friend;
using IM_API.Exceptions;
using IM_API.Models;
using IM_API.Services;

View File

@ -4,12 +4,12 @@ using Moq;
using AutoMapper;
using IM_API.Services;
using IM_API.Models;
using IM_API.Dtos;
using IM_API.Exceptions;
using IM_API.Tools;
using Microsoft.Extensions.Logging;
using System;
using System.Threading.Tasks;
using IM_API.Dtos.User;
public class UserServiceTests
{

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+83be063e7fbdba82984ef3beb231bd8a0a983c2f")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+22038345c32db146ffb78173c5410f954daa64e0")]
[assembly: System.Reflection.AssemblyProductAttribute("IMTest")]
[assembly: System.Reflection.AssemblyTitleAttribute("IMTest")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]

View File

@ -1 +1 @@
495895405696fa7fe9836aaaa2da1791d39c26be9cfe301758e0fe7d7c9164b6
ee1fc45f192938903a153f1c2e3b53f60a2184cb806b87d9b57b487095b98264

View File

@ -6,21 +6,18 @@ namespace IM_API.Application.EventHandlers.FriendAddHandler
{
public class FriendAddConversationHandler : IConsumer<FriendAddEvent>
{
private readonly IFriendSerivce _friendService;
public FriendAddConversationHandler(IFriendSerivce friendService)
private readonly IConversationService _cService;
public FriendAddConversationHandler(IConversationService cService)
{
_friendService = friendService;
_cService = cService;
}
public async Task Consume(ConsumeContext<FriendAddEvent> context)
{
var @event = context.Message;
//为请求发起人添加好友记录
await _friendService.MakeFriendshipAsync(
@event.RequestUserId, @event.ResponseUserId, @event.RequestInfo.RemarkName);
//为接收人添加好友记录
await _friendService.MakeFriendshipAsync(
@event.ResponseUserId, @event.RequestUserId, @event.requestUserRemarkname);
await _cService.MakeConversationAsync(@event.RequestUserId, @event.ResponseUserId, Models.ChatType.PRIVATE);
await _cService.MakeConversationAsync(@event.ResponseUserId, @event.RequestUserId, Models.ChatType.PRIVATE);
}
}
}

View File

@ -0,0 +1,29 @@
using IM_API.Domain.Events;
using IM_API.Interface.Services;
using MassTransit;
namespace IM_API.Application.EventHandlers.FriendAddHandler
{
public class FriendAddDBHandler : IConsumer<FriendAddEvent>
{
private readonly IFriendSerivce _friendService;
private readonly ILogger<FriendAddDBHandler> _logger;
public FriendAddDBHandler(IFriendSerivce friendService, ILogger<FriendAddDBHandler> logger)
{
_friendService = friendService;
_logger = logger;
}
public async Task Consume(ConsumeContext<FriendAddEvent> context)
{
var @event = context.Message;
//为请求发起人添加好友记录
await _friendService.MakeFriendshipAsync(
@event.RequestUserId, @event.ResponseUserId, @event.RequestInfo.RemarkName);
//为接收人添加好友记录
await _friendService.MakeFriendshipAsync(
@event.ResponseUserId, @event.RequestUserId, @event.requestUserRemarkname);
}
}
}

View File

@ -0,0 +1,13 @@
using IM_API.Domain.Events;
using MassTransit;
namespace IM_API.Application.EventHandlers.GroupRequestHandler
{
public class GroupRequestSignalRHandler : IConsumer<GroupRequestEvent>
{
Task IConsumer<GroupRequestEvent>.Consume(ConsumeContext<GroupRequestEvent> context)
{
throw new NotImplementedException();
}
}
}

View File

@ -1,5 +1,6 @@
using IM_API.Domain.Events;
using IM_API.Dtos;
using IM_API.Dtos.Friend;
using IM_API.Hubs;
using IM_API.Interface.Services;
using MassTransit;

View File

@ -1,5 +1,6 @@
using IM_API.Application.EventHandlers.FriendAddHandler;
using IM_API.Application.EventHandlers.MessageCreatedHandler;
using IM_API.Application.EventHandlers.RequestFriendHandler;
using MassTransit;
namespace IM_API.Configs
@ -12,8 +13,10 @@ namespace IM_API.Configs
{
x.AddConsumer<ConversationEventHandler>();
x.AddConsumer<SignalREventHandler>();
x.AddConsumer<FriendAddConversationHandler>();
x.AddConsumer<FriendAddDBHandler>();
x.AddConsumer<FriendAddSignalRHandler>();
x.AddConsumer<RequestFriendSignalRHandler>();
x.AddConsumer<FriendAddConversationHandler>();
x.UsingRabbitMq((ctx,cfg) =>
{
@ -22,6 +25,7 @@ namespace IM_API.Configs
h.Username(options.Username);
h.Password(options.Password);
});
cfg.ConfigureEndpoints(ctx);
});
});

View File

@ -1,6 +1,11 @@
using AutoMapper;
using IM_API.Domain.Events;
using IM_API.Dtos;
using IM_API.Dtos.Auth;
using IM_API.Dtos.Conversation;
using IM_API.Dtos.Friend;
using IM_API.Dtos.Group;
using IM_API.Dtos.User;
using IM_API.Models;
namespace IM_API.Configs
@ -131,6 +136,21 @@ namespace IM_API.Configs
CreateMap<Group, ConversationDto>()
.ForMember(dest => dest.TargetAvatar, opt => opt.MapFrom(src => src.Avatar))
.ForMember(dest => dest.TargetName, opt => opt.MapFrom(src => src.Name));
//群模型转换
CreateMap<Group, GroupInfoDto>()
.ForMember(dest => dest.Status, opt => opt.MapFrom(src => src.StatusEnum))
.ForMember(dest => dest.AllMembersBanned, opt => opt.MapFrom(src => src.AllMembersBannedEnum))
.ForMember(dest => dest.Auhority, opt => opt.MapFrom(src => src.AuhorityEnum));
CreateMap<GroupCreateDto, Group>()
.ForMember(dest => dest.Name, opt => opt.MapFrom(src => src.Name))
.ForMember(dest => dest.Avatar, opt => opt.MapFrom(src => src.Avatar))
.ForMember(dest => dest.Created, opt => opt.MapFrom(src => DateTime.UtcNow))
.ForMember(dest => dest.AllMembersBannedEnum, opt => opt.MapFrom(src => GroupAllMembersBanned.ALLOWED))
.ForMember(dest => dest.AuhorityEnum, opt => opt.MapFrom(src => GroupAuhority.REQUIRE_CONSENT))
.ForMember(dest => dest.StatusEnum, opt => opt.MapFrom(src => GroupStatus.Normal));
}
}
}

View File

@ -1,5 +1,7 @@
using AutoMapper;
using IM_API.Dtos;
using IM_API.Dtos.Auth;
using IM_API.Dtos.User;
using IM_API.Interface.Services;
using IM_API.Tools;
using Microsoft.AspNetCore.Http;

View File

@ -1,4 +1,5 @@
using IM_API.Dtos;
using IM_API.Dtos.Conversation;
using IM_API.Interface.Services;
using IM_API.Models;
using Microsoft.AspNetCore.Authorization;

View File

@ -1,4 +1,5 @@
using IM_API.Dtos;
using IM_API.Dtos.Friend;
using IM_API.Interface.Services;
using IM_API.Models;
using Microsoft.AspNetCore.Authorization;
@ -60,7 +61,7 @@ namespace IM_API.Controllers
/// <returns></returns>
[HttpPost]
public async Task<IActionResult> HandleRequest(
[FromRoute]int id, [FromBody]FriendRequestHandleDto dto
[FromQuery]int id, [FromBody]FriendRequestHandleDto dto
)
{
await _friendService.HandleFriendRequestAsync(new HandleFriendRequestDto()

View File

@ -1,4 +1,5 @@
using IM_API.Dtos;
using IM_API.Dtos.User;
using IM_API.Interface.Services;
using IM_API.Tools;
using Microsoft.AspNetCore.Authorization;

View File

@ -1,4 +1,4 @@
using IM_API.Dtos;
using IM_API.Dtos.Friend;
namespace IM_API.Domain.Events
{

View File

@ -0,0 +1,10 @@
namespace IM_API.Domain.Events
{
public record GroupInviteEvent : DomainEvent
{
public override string EventType => "IM.GROUPS_GROUP_INVITE";
public required List<int> Ids { get; init; }
public int GroupId { get; init; }
public int UserId { get; init; }
}
}

View File

@ -0,0 +1,13 @@
using IM_API.Dtos.Group;
namespace IM_API.Domain.Events
{
public record GroupRequestEvent : DomainEvent
{
public override string EventType => "IM.GROUPS_GROUP_REQUEST";
public int GroupId { get; init; }
public int UserId { get; set; }
public string Description { get; set; }
}
}

View File

@ -1,4 +1,4 @@
namespace IM_API.Dtos
namespace IM_API.Dtos.Auth
{
public record RefreshDto(string refreshToken);
}

View File

@ -1,4 +1,6 @@
namespace IM_API.Dtos
using IM_API.Dtos.User;
namespace IM_API.Dtos.Auth
{
public class LoginDto
{
@ -8,9 +10,9 @@
public DateTime ExpireAt { get; set; }
public LoginDto(UserInfoDto userInfo,string token, string refreshToken, DateTime expireAt) {
this.userInfo = userInfo;
this.Token = token;
this.RefreshToken = refreshToken;
this.ExpireAt = expireAt;
Token = token;
RefreshToken = refreshToken;
ExpireAt = expireAt;
}
}
}

View File

@ -1,7 +1,7 @@
using IM_API.Tools;
using System.ComponentModel.DataAnnotations;
namespace IM_API.Dtos
namespace IM_API.Dtos.Auth
{
public class LoginRequestDto
{

View File

@ -1,6 +1,6 @@
using System.ComponentModel.DataAnnotations;
namespace IM_API.Dtos
namespace IM_API.Dtos.Auth
{
public class RegisterRequestDto
{

View File

@ -1,4 +1,4 @@
namespace IM_API.Dtos
namespace IM_API.Dtos.Conversation
{
public class ClearConversationsDto
{

View File

@ -1,6 +1,6 @@
using IM_API.Models;
namespace IM_API.Dtos
namespace IM_API.Dtos.Conversation
{
public class ConversationDto
{

View File

@ -1,7 +1,8 @@
using IM_API.Models;
using IM_API.Dtos.User;
using IM_API.Models;
using System.ComponentModel.DataAnnotations;
namespace IM_API.Dtos
namespace IM_API.Dtos.Friend
{
public record FriendInfoDto
{

View File

@ -1,6 +1,6 @@
using System.ComponentModel.DataAnnotations;
namespace IM_API.Dtos
namespace IM_API.Dtos.Friend
{
public class FriendRequestDto
{

View File

@ -1,6 +1,6 @@
using IM_API.Models;
namespace IM_API.Dtos
namespace IM_API.Dtos.Friend
{
public class FriendRequestResDto
{

View File

@ -1,4 +1,4 @@
namespace IM_API.Dtos
namespace IM_API.Dtos.Friend
{
public class HandleFriendRequestDto
{

View File

@ -0,0 +1,17 @@
using IM_API.Models;
namespace IM_API.Dtos.Group
{
public class GroupCreateDto
{
/// <summary>
/// 群聊名称
/// </summary>
public string Name { get; set; } = null!;
/// <summary>
/// 群头像
/// </summary>
public string Avatar { get; set; } = null!;
}
}

View File

@ -0,0 +1,51 @@
using IM_API.Models;
namespace IM_API.Dtos.Group
{
public class GroupInfoDto
{
public int Id { get; set; }
/// <summary>
/// 群聊名称
/// </summary>
public string Name { get; set; } = null!;
/// <summary>
/// 群主
/// </summary>
public int GroupMaster { get; set; }
/// <summary>
/// 群权限
/// 0需管理员同意,1任意人可加群,2不允许任何人加入
/// </summary>
public GroupAuhority Auhority { get; set; }
/// <summary>
/// 全员禁言0允许发言2全员禁言
/// </summary>
public GroupAllMembersBanned AllMembersBanned { get; set; }
/// <summary>
/// 群聊状态
/// (1正常,2封禁)
/// </summary>
public GroupStatus Status { get; set; }
/// <summary>
/// 群公告
/// </summary>
public string? Announcement { get; set; }
/// <summary>
/// 群聊创建时间
/// </summary>
public DateTime Created { get; set; }
/// <summary>
/// 群头像
/// </summary>
public string Avatar { get; set; } = null!;
}
}

View File

@ -1,6 +1,6 @@
using System.ComponentModel.DataAnnotations;
namespace IM_API.Dtos
namespace IM_API.Dtos.User
{
public class UpdateUserDto
{

View File

@ -1,7 +1,7 @@
using IM_API.Models;
using System.ComponentModel.DataAnnotations;
namespace IM_API.Dtos
namespace IM_API.Dtos.User
{
public record PasswordResetDto
{

View File

@ -2,7 +2,7 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace IM_API.Dtos
namespace IM_API.Dtos.User
{
public class UserInfoDto
{

View File

@ -31,4 +31,8 @@
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.14.0" />
</ItemGroup>
<ItemGroup>
<Folder Include="Application\EventHandlers\GroupInviteHandler\" />
</ItemGroup>
</Project>

View File

@ -1,4 +1,5 @@
using IM_API.Dtos;
using IM_API.Dtos.Auth;
using IM_API.Dtos.User;
using IM_API.Models;
namespace IM_API.Interface.Services

View File

@ -1,4 +1,4 @@
using IM_API.Dtos;
using IM_API.Dtos.Conversation;
using IM_API.Models;
namespace IM_API.Interface.Services
@ -42,5 +42,13 @@ namespace IM_API.Interface.Services
/// <param name="conversationId"></param>
/// <returns></returns>
Task<bool> ClearUnreadCountAsync(int userId, int conversationId);
/// <summary>
/// 为用户创建会话
/// </summary>
/// <param name="userAId"></param>
/// <param name="userBId"></param>
/// <param name="chatType"></param>
/// <returns></returns>
Task MakeConversationAsync(int userAId, int userBId, ChatType chatType);
}
}

View File

@ -1,4 +1,4 @@
using IM_API.Dtos;
using IM_API.Dtos.Friend;
using IM_API.Models;
namespace IM_API.Interface.Services

View File

@ -0,0 +1,39 @@
using IM_API.Dtos.Group;
namespace IM_API.Interface.Services
{
public interface IGroupService
{
/// <summary>
/// 邀请好友入群
/// </summary>
/// <param name="userId">操作者ID</param>
/// <param name="groupId">群ID</param>
/// <param name="userIds">邀请的用户列表</param>
/// <returns></returns>
Task InviteUsers(int userId,int groupId, List<int> userIds);
/// <summary>
/// 加入群聊
/// </summary>
/// <param name="userId">操作者ID</param>
/// <param name="groupId">群ID</param>
/// <returns></returns>
Task JoinGroup(int userId,int groupId);
/// <summary>
/// 创建群聊
/// </summary>
/// <param name="userId">操作者ID</param>
/// <param name="groupCreateDto">群信息</param>
/// <param name="userIds">邀请用户列表</param>
/// <returns></returns>
Task<GroupInfoDto> CreateGroup(int userId, GroupCreateDto groupCreateDto, List<int> userIds);
/// <summary>
/// 删除群
/// </summary>
/// <param name="userId">操作者ID</param>
/// <param name="groupId">群ID</param>
/// <returns></returns>
Task DeleteGroup(int userId, int groupId);
//Task UpdateGroupAuthori
}
}

View File

@ -1,4 +1,4 @@
using IM_API.Dtos;
using IM_API.Dtos.User;
using IM_API.Models;
namespace IM_API.Interface.Services

View File

@ -53,5 +53,7 @@ public partial class Group
public virtual User GroupMasterNavigation { get; set; } = null!;
public virtual ICollection<GroupMember> GroupMembers { get; set; } = new List<GroupMember>();
public virtual ICollection<GroupRequest> GroupRequests { get; set; } = new List<GroupRequest>();
}

View File

@ -27,7 +27,7 @@ public partial class GroupMember
/// </summary>
public DateTime Created { get; set; }
public virtual User Group { get; set; } = null!;
public virtual Group Group { get; set; } = null!;
public virtual User User { get; set; } = null!;
}

View File

@ -432,12 +432,12 @@ public partial class ImContext : DbContext
.HasComment("用户编号")
.HasColumnType("int(11)");
entity.HasOne(d => d.Group).WithMany(p => p.GroupMemberGroups)
entity.HasOne(d => d.Group).WithMany(p => p.GroupMembers)
.HasForeignKey(d => d.GroupId)
.OnDelete(DeleteBehavior.ClientSetNull)
.HasConstraintName("group_member_ibfk_2");
entity.HasOne(d => d.User).WithMany(p => p.GroupMemberUsers)
entity.HasOne(d => d.User).WithMany(p => p.GroupMembers)
.HasForeignKey(d => d.UserId)
.OnDelete(DeleteBehavior.ClientSetNull)
.HasConstraintName("group_member_ibfk_1");

View File

@ -73,9 +73,7 @@ public partial class User
public virtual ICollection<GroupInvite> GroupInviteInvitedUserNavigations { get; set; } = new List<GroupInvite>();
public virtual ICollection<GroupMember> GroupMemberGroups { get; set; } = new List<GroupMember>();
public virtual ICollection<GroupMember> GroupMemberUsers { get; set; } = new List<GroupMember>();
public virtual ICollection<GroupMember> GroupMembers { get; set; } = new List<GroupMember>();
public virtual ICollection<GroupRequest> GroupRequests { get; set; } = new List<GroupRequest>();

View File

@ -1,5 +1,6 @@
using AutoMapper;
using IM_API.Dtos;
using IM_API.Dtos.Auth;
using IM_API.Dtos.User;
using IM_API.Exceptions;
using IM_API.Interface.Services;
using IM_API.Models;

View File

@ -1,5 +1,5 @@
using AutoMapper;
using IM_API.Dtos;
using IM_API.Dtos.Conversation;
using IM_API.Exceptions;
using IM_API.Interface.Services;
using IM_API.Models;
@ -134,5 +134,27 @@ namespace IM_API.Services
return true;
}
public async Task MakeConversationAsync(int userAId, int userBId, ChatType chatType)
{
var userAcExist = await _context.Conversations.AnyAsync(x => x.UserId == userAId && x.TargetId == userBId);
if (userAcExist) return;
var streamKey = chatType == ChatType.PRIVATE ?
StreamKeyBuilder.Private(userAId, userBId) : StreamKeyBuilder.Group(userBId);
var conversation = new Conversation()
{
ChatType = (int)chatType,
LastMessage = "",
LastMessageTime = DateTime.UtcNow,
LastReadMessageId = -1,
StreamKey = streamKey,
TargetId = userBId,
UnreadCount = 0,
UserId = userAId
};
_context.Conversations.Add(conversation);
await _context.SaveChangesAsync();
}
}
}

View File

@ -1,6 +1,6 @@
using AutoMapper;
using IM_API.Domain.Events;
using IM_API.Dtos;
using IM_API.Dtos.Friend;
using IM_API.Exceptions;
using IM_API.Interface.Services;
using IM_API.Models;
@ -117,15 +117,12 @@ namespace IM_API.Services
var friend = await _context.Friends.FirstOrDefaultAsync(
x => x.UserId == friendRequest.RequestUser && x.FriendId == friendRequest.ResponseUser
);
if (friend is null) throw new BaseException(CodeDefine.FRIEND_RELATION_NOT_FOUND);
if (friend.StatusEnum != FriendStatus.Pending) throw new BaseException(CodeDefine.FRIEND_REQUEST_EXISTS);
if (friend != null) throw new BaseException(CodeDefine.ALREADY_FRIENDS);
//处理好友请求操作
switch (requestDto.Action)
{
//拒绝后标记
case HandleFriendRequestAction.Reject:
friend.StatusEnum = FriendStatus.Declined;
friendRequest.StateEnum = FriendRequestState.Declined;
break;
@ -141,6 +138,8 @@ namespace IM_API.Services
OperatorId = friendRequest.ResponseUser,
RequestInfo = _mapper.Map<FriendRequestDto>(friendRequest),
requestUserRemarkname = requestDto.RemarkName,
RequestUserId = friendRequest.RequestUser,
ResponseUserId = friendRequest.ResponseUser
});
break;

View File

@ -0,0 +1,110 @@
using AutoMapper;
using IM_API.Domain.Events;
using IM_API.Dtos.Group;
using IM_API.Exceptions;
using IM_API.Interface.Services;
using IM_API.Models;
using IM_API.Tools;
using MassTransit;
using Microsoft.EntityFrameworkCore;
using System;
namespace IM_API.Services
{
public class GroupService : IGroupService
{
private readonly ImContext _context;
private readonly IMapper _mapper;
private readonly ILogger<GroupService> _logger;
private readonly IPublishEndpoint _endPoint;
public GroupService(ImContext context, IMapper mapper, ILogger<GroupService> logger, IPublishEndpoint publishEndpoint)
{
_context = context;
_mapper = mapper;
_logger = logger;
_endPoint = publishEndpoint;
}
private async Task<List<GroupInvite>> GetGroupInvites(int userId, int groupId, List<int> ids)
{
DateTime dateTime = DateTime.UtcNow;
//验证被邀请用户是否为好友
var validFriendIds = await _context.Friends
.Where(f => f.UserId == userId && ids.Contains(f.FriendId))
.Select(f => f.FriendId)
.ToListAsync();
//创建群成员对象
return validFriendIds.Select(fid => new GroupInvite
{
Created = dateTime,
GroupId = groupId,
InvitedUser = fid,
StateEnum = GroupInviteState.Pending,
InviteUser = userId
}).ToList();
}
public async Task<GroupInfoDto> CreateGroup(int userId, GroupCreateDto groupCreateDto, List<int> userIds)
{
using var transaction = await _context.Database.BeginTransactionAsync();
try
{
//先创建群
DateTime dateTime = DateTime.UtcNow;
Group group = _mapper.Map<Group>(groupCreateDto);
group.GroupMaster = userId;
_context.Groups.Add(group);
await _context.SaveChangesAsync();
var groupInvites = new List<GroupInvite>();
if (userIds.Count > 0)
{
groupInvites = await GetGroupInvites(userId,group.Id, userIds);
_context.GroupInvites.AddRange(groupInvites);
}
var groupMember = new GroupMember
{
UserId = userId,
Created = dateTime,
RoleEnum = GroupMemberRole.Master,
GroupId = group.Id
};
_context.GroupMembers.Add(groupMember);
await _context.SaveChangesAsync();
await transaction.CommitAsync();
await _endPoint.Publish(new GroupInviteEvent
{
AggregateId = userId.ToString(),
GroupId = group.Id,
EventId = Guid.NewGuid(),
OccurredAt = dateTime,
Ids = groupInvites.Select(x => x.Id).ToList(),
OperatorId = userId,
UserId = userId
});
return _mapper.Map<GroupInfoDto>(group);
}
catch
{
await transaction.RollbackAsync();
throw;
}
}
public Task DeleteGroup(int userId, int groupId)
{
throw new NotImplementedException();
}
public async Task InviteUsers(int userId, int groupId, List<int> userIds)
{
var group = await _context.Groups.FirstOrDefaultAsync(
x => x.Id == groupId) ?? throw new BaseException(CodeDefine.GROUP_NOT_FOUND);
}
public Task JoinGroup(int userId, int groupId)
{
throw new NotImplementedException();
}
}
}

View File

@ -1,5 +1,5 @@
using AutoMapper;
using IM_API.Dtos;
using IM_API.Dtos.User;
using IM_API.Exceptions;
using IM_API.Interface.Services;
using IM_API.Models;

View File

@ -1,4 +1,4 @@
VITE_API_BASE_URL = http://localhost:5202/api
VITE_SIGNALR_BASE_URL = http://localhost:5202/chat/
#VITE_API_BASE_URL = https://im.test.nxsir.cn/api
#VITE_SIGNALR_BASE_URL = https://im.test.nxsir.cn/chat/
#VITE_API_BASE_URL = http://localhost:5202/api
#VITE_SIGNALR_BASE_URL = http://localhost:5202/chat/
VITE_API_BASE_URL = https://im.test.nxsir.cn/api
VITE_SIGNALR_BASE_URL = https://im.test.nxsir.cn/chat/

View File

@ -67,12 +67,12 @@ const vClickOutside = {
</script>
<style scoped>
/* --- 以下是保持不动的原样部分 --- */
.add-menu-container {
position: relative;
display: inline-block;
}
/* 加号按钮样式 */
.add-btn {
width: 32px;
height: 32px;
@ -87,14 +87,8 @@ const vClickOutside = {
}
.add-btn:hover {
background: #cccbcb;
background: #cccbcb;
}
/*
.add-btn.active {
background: #007aff;
color: white;
}
*/
.plus-icon {
font-size: 22px;
@ -102,20 +96,15 @@ const vClickOutside = {
font-weight: 300;
}
/* 弹出卡片容器 */
.menu-card {
position: absolute;
top: 45px;
right: 0;
width: 100px;
background: white;
border-radius: 10px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
z-index: 100;
transform-origin: top right;
.pop-enter-active, .pop-leave-active {
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
}
.pop-enter-from, .pop-leave-to {
opacity: 0;
transform: scale(0.9) translateY(-10px);
}
/* 小三角 */
.arrow {
position: absolute;
top: -6px;
@ -127,54 +116,65 @@ const vClickOutside = {
border-bottom: 6px solid white;
}
/* 列表样式 */
/* --- 重点优化:仅限菜单列表部分 --- */
.menu-card {
position: absolute;
top: 45px;
right: 0;
width: 120px; /* 稍微加宽,避免局促 */
background: white;
border-radius: 10px;
/* 优化:使用更柔和的复合阴影,提升高级感 */
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 10px 15px -3px rgba(0, 0, 0, 0.1);
z-index: 100;
transform-origin: top right;
padding: 4px; /* 增加内边距,让列表项不贴边 */
border: 1px solid rgba(0, 0, 0, 0.05); /* 增加极细边框,防止白底背景重叠 */
}
.menu-list {
padding: 6px 0;
padding: 0; /* 清除默认,改由父级 padding 控制 */
display: flex;
flex-direction: column;
gap: 2px; /* 增加项与项之间的微小缝隙 */
}
.menu-item {
display: flex;
align-items: center;
padding: 5px 5px;
padding: 8px 10px; /* 增加点击区域和呼吸感 */
cursor: pointer;
transition: background 0.2s;
color: #333;
border-radius: 6px; /* 每一行也给圆角,悬浮时更好看 */
transition: all 0.2s;
color: #4a4a4a;
justify-content: center;
}
.menu-item:hover {
background: #f5f5f5;
}
.menu-item:first-child:hover {
border-top-left-radius: 10px;
border-top-right-radius: 10px;
background: #c6c6c6; /* 换成淡淡的品牌色背景 */
}
/* 移除旧的、生硬的边角覆盖逻辑,改用上面统一的 border-radius */
.menu-item:first-child:hover,
.menu-item:last-child:hover {
border-bottom-left-radius: 10px;
border-bottom-right-radius: 10px;
border-top-left-radius: 6px;
border-top-right-radius: 6px;
border-bottom-left-radius: 6px;
border-bottom-right-radius: 6px;
}
.icon {
margin-right: 5px;
font-size: 12px;
margin-right: 8px; /* 增加图标与文字的距离 */
font-size: 14px; /* 稍微调大一点点 */
display: flex;
align-items: center;
/*width: 20px;*/
justify-content: center;
width: 16px;
}
.menu-item span {
font-size: 12px;
/*font-weight: 400;*/
}
/* 弹出动画 */
.pop-enter-active, .pop-leave-active {
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
}
.pop-enter-from, .pop-leave-to {
opacity: 0;
transform: scale(0.9) translateY(-10px);
font-size: 13px; /* 稍微增大字号,更容易阅读 */
font-weight: 500; /* 增加字重,更有质感 */
}
</style>

View File

@ -0,0 +1,82 @@
<template>
<div v-for="c in props.contacts"
:key="c.id"
class="list-item"
:class="{active: activeContactId === c.id}"
@click="routeUserInfo(c.id)">
<img :src="c.userInfo.avatar" class="avatar-std" />
<div class="info">
<div class="name">{{ c.remarkName }}</div>
</div>
</div>
</template>
<script setup>
import { defineProps, ref } from 'vue';
import { useRouter } from 'vue-router';
const router = useRouter()
const activeContactId = ref(null)
const props = defineProps({
contacts: {
type:String,
required: true
}
})
const routeUserInfo = (id) => {
router.push(`/contacts/info/${id}`);
activeContactId.value = id;
}
</script>
<style scoped>
.list-item {
display: flex;
padding: 10px 12px;
gap: 12px;
align-items: center;
cursor: pointer;
transition: background 0.2s;
text-decoration: none; /* 去除下划线 */
color: inherit; /* 继承父元素的文本颜色 */
outline: none; /* 去除点击时的蓝框 */
-webkit-tap-highlight-color: transparent; /* 移动端点击高亮 */
}
/* 去除 hover、active 等状态的效果 */
a:hover,
a:active,
a:focus {
text-decoration: none;
color: inherit; /* 保持颜色不变 */
cursor: pointer;
}
.list-item:hover { background: #e2e2e2; }
.list-item.active { background: #c6c6c6; }
.avatar-std {
width: 36px;
height: 36px;
border-radius: 4px;
object-fit: cover;
}
.icon-box {
width: 36px;
height: 36px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 16px;
}
.icon-box.orange { background: #faad14; }
.icon-box.green { background: #52c41a; }
.icon-box.blue { background: #1890ff; }
</style>

View File

@ -0,0 +1,98 @@
<script setup>
import { ref, computed } from 'vue';
const props = defineProps({ modelValue: Boolean });
const emit = defineEmits(['update:modelValue', 'create']);
const friends = ref([
{ id: 1, name: '张三', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=1' },
{ id: 2, name: '李四', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=2' },
{ id: 3, name: '王五', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=3' },
]);
const groupName = ref('');
const selected = ref(new Set()); // 使 Set
const toggle = (id) => {
selected.value.has(id) ? selected.value.delete(id) : selected.value.add(id);
};
const submit = () => {
emit('create', { name: groupName.value, members: Array.from(selected.value) });
emit('update:modelValue', false);
};
</script>
<template>
<Teleport to="body">
<div v-if="modelValue" class="overlay" @click.self="$emit('update:modelValue', false)">
<div class="mini-modal">
<header>
<span>发起群聊</span>
<button @click="$emit('update:modelValue', false)"></button>
</header>
<main>
<input v-model="groupName" placeholder="群组名称..." class="mini-input" />
<div class="list">
<div v-for="f in friends" :key="f.id" @click="toggle(f.id)" class="item">
<img :src="f.avatar" class="avatar" />
<span class="name">{{ f.name }}</span>
<input type="checkbox" :checked="selected.has(f.id)" />
</div>
</div>
</main>
<footer>
<button @click="submit" :disabled="!groupName || !selected.size" class="btn">
创建 ({{ selected.size }})
</button>
</footer>
</div>
</div>
</Teleport>
</template>
<style scoped>
.overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.4);
display: flex; align-items: center; justify-content: center; z-index: 999;
}
.mini-modal {
background: white; width: 300px; border-radius: 12px; overflow: hidden;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
header {
padding: 12px 16px; display: flex; justify-content: space-between;
background: #f9f9f9; font-weight: bold; font-size: 14px;
}
header button { background: none; border: none; cursor: pointer; color: #999; }
main { padding: 12px; }
.mini-input {
width: 100%; padding: 8px; margin-bottom: 12px; border: 1px solid #eee;
border-radius: 4px; box-sizing: border-box; outline: none;
}
.list { max-height: 200px; overflow-y: auto; }
.item {
display: flex; align-items: center; padding: 8px; cursor: pointer; border-radius: 6px;
}
.item:hover { background: #f5f5f5; }
.avatar { width: 32px; height: 32px; border-radius: 4px; margin-right: 10px; }
.name { flex: 1; font-size: 14px; }
footer { padding: 12px; }
.btn {
width: 100%; padding: 10px; background: #07c160; color: white;
border: none; border-radius: 6px; font-weight: bold; cursor: pointer;
}
.btn:disabled { background: #e1e1e1; color: #999; cursor: not-allowed; }
</style>

View File

@ -0,0 +1,81 @@
<template>
<div class="pure-group-list">
<div
v-for="group in groups"
:key="group.id"
class="group-item"
:class="{ active: activeId === group.id }"
@click="activeId = group.id; $emit('select', group)"
>
<img :src="group.avatar" class="group-avatar" />
<span class="group-name">{{ group.name }}</span>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
defineProps({
groups: {
type: Array,
default: () => []
// : { id, name, avatar }
}
});
const emit = defineEmits(['select']);
const activeId = ref(null);
</script>
<style scoped>
.pure-group-list {
width: 100%;
background: transparent;
/* 移除内边距,完全由外部容器控制 */
}
.group-item {
display: flex;
align-items: center;
padding: 8px 12px;
margin-bottom: 2px;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease-in-out;
/* 避免文字选中 */
user-select: none;
}
/* 悬停反馈:轻微变暗 */
.group-item:hover {
background-color: rgba(0, 0, 0, 0.05);
}
/* 选中态:建议使用稍微明显的底色 */
.group-item.active {
background-color: rgba(0, 0, 0, 0.1);
}
.group-avatar {
width: 32px; /* 进一步缩小,保持精致感 */
height: 32px;
border-radius: 6px;
object-fit: cover;
flex-shrink: 0;
margin-right: 12px;
background-color: #f0f0f0;
}
.group-name {
flex: 1;
font-size: 14px;
font-weight: 500;
color: #333;
/* 防止名称过长换行 */
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>

View File

@ -1,399 +1,175 @@
<template>
<transition name="fade">
<div v-if="modelValue" class="modal-mask" @click.self="close">
<div class="modal-container">
<div class="modal-header">
<div class="header-title">
<button v-if="step === 2" class="btn-back-icon" @click="step = 1"></button>
<h2>{{ step === 1 ? '添加好友' : '验证信息' }}</h2>
</div>
<button class="icon-close" @click="close">×</button>
</div>
<div v-if="step === 1" class="step-wrapper">
<div class="search-box">
<input
v-model="keyword"
type="text"
placeholder="输入 ID / 手机号"
@keyup.enter="onSearch"
/>
<button class="search-btn" @click="onSearch" :disabled="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 || defaultAvatar" class="avatar" />
<div class="detail">
<span class="name">{{ userResult.nickName }}</span>
<span class="id">ID: {{ userResult.username }}</span>
</div>
</div>
<button class="add-action-btn" @click="goToAddForm">添加好友</button>
</div>
<div v-else-if="hasSearched && !userResult" class="empty-state">
未找到该用户请检查输入
</div>
</transition>
</div>
<div v-if="step === 2" class="step-wrapper form-container">
<div class="form-item">
<label>备注名</label>
<input
v-model="form.remark"
placeholder="为好友起个备注吧"
class="form-input"
/>
</div>
<div class="form-item">
<label>验证信息</label>
<textarea
v-model="form.description"
placeholder="我是..."
rows="3"
class="form-textarea"
></textarea>
</div>
<button
class="btn-submit"
@click="submitAdd"
:disabled="submitting"
>
{{ submitting ? '发送中...' : '提交申请' }}
</button>
</div>
</div>
</div>
</transition>
</template>
<script setup>
import { ref, reactive } from 'vue';
import { friendService } from '@/services/friend';
import { useMessage } from '../messages/useAlert';
const props = defineProps({
modelValue: Boolean
});
const props = defineProps({ modelValue: Boolean });
const emit = defineEmits(['update:modelValue', 'success']);
const message = useMessage();
//
//
const step = ref(1);
const loading = ref(false);
const submitting = ref(false);
const hasSearched = ref(false);
const defaultAvatar = 'https://api.dicebear.com/7.x/adventurer/svg?seed=Lucky';
//
const keyword = ref('');
const userResult = ref(null);
const form = reactive({
remark: '',
description: '你好,想加你为好友'
});
const keyword = ref('');
const form = reactive({ remark: '', description: '你好,想加你为好友' });
const close = () => {
emit('update:modelValue', false);
//
setTimeout(() => {
step.value = 1;
userResult.value = null;
hasSearched.value = false;
keyword.value = '';
form.remark = '';
form.description = '你好,想加你为好友';
hasSearched.value = false;
}, 300);
};
//
const onSearch = async () => {
if (!keyword.value.trim()) return;
loading.value = true;
hasSearched.value = false;
try {
const res = await friendService.findUser(keyword.value);
userResult.value = res.data;
if (res.data) {
form.remark = res.data.nickName; //
}
} catch (err) {
console.error(err);
if (res.data) form.remark = res.data.nickName;
} finally {
loading.value = false;
hasSearched.value = true;
}
};
const goToAddForm = () => {
step.value = 2;
};
//
const submitAdd = async () => {
if (submitting.value) return;
submitting.value = true;
const res = await friendService.requestFriend({
toUserId: userResult.value.id, //
remarkName: form.remark,
description: form.description
});
if(res.code == 0){
message.success('已发送好友请求');
}else{
message.error(res.message);
}
submitting.value = false;
close();
const res = await friendService.requestFriend({
toUserId: userResult.value.id,
remarkName: form.remark,
description: form.description
});
if (res.code == 0) message.success('已发送请求');
else message.error(res.message);
submitting.value = false;
close();
};
</script>
<template>
<Teleport to="body">
<div v-if="modelValue" class="overlay" @click.self="close">
<div class="mini-modal">
<header>
<button v-if="step === 2" @click="step = 1" class="back-btn"></button>
<span class="title">{{ step === 1 ? '添加好友' : '验证信息' }}</span>
<button @click="close" class="close-btn"></button>
</header>
<main>
<div v-if="step === 1">
<div class="search-bar">
<input v-model="keyword" placeholder="搜索 ID / 手机号" @keyup.enter="onSearch" />
<button @click="onSearch" :disabled="loading">
{{ loading ? '...' : '搜索' }}
</button>
</div>
<div v-if="userResult" class="result-card">
<img :src="userResult.avatar" class="mini-avatar" />
<div class="info">
<div class="name">{{ userResult.nickName }}</div>
<div class="id">ID: {{ userResult.username }}</div>
</div>
<button class="next-btn" @click="step = 2">添加</button>
</div>
<div v-else-if="hasSearched" class="empty">未找到用户</div>
</div>
<div v-if="step === 2" class="form">
<div class="f-item">
<label>备注</label>
<input v-model="form.remark" class="f-input" />
</div>
<div class="f-item">
<label>留言</label>
<textarea v-model="form.description" rows="2" class="f-input"></textarea>
</div>
</div>
</main>
<footer v-if="step === 2">
<button class="submit-btn" :disabled="submitting" @click="submitAdd">
{{ submitting ? '发送中...' : '发送申请' }}
</button>
</footer>
</div>
</div>
</Teleport>
</template>
<style scoped>
/* 1. 基础布局与遮罩 */
.modal-mask {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.2);
backdrop-filter: blur(10px);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
.overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.4);
display: flex; align-items: center; justify-content: center; z-index: 1000;
}
.modal-container {
background: #ffffff;
width: 380px;
border-radius: 28px;
padding: 28px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.15);
overflow: hidden;
.mini-modal {
background: white; width: 300px; border-radius: 12px; overflow: hidden;
box-shadow: 0 4px 15px rgba(0,0,0,0.2);
}
/* 2. 头部样式 */
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
header {
padding: 12px; display: flex; align-items: center;
border-bottom: 1px solid #f0f0f0; background: #fafafa;
}
.header-title {
display: flex;
align-items: center;
gap: 12px;
.title { flex: 1; font-size: 14px; font-weight: bold; text-align: center; }
.back-btn, .close-btn { background: none; border: none; cursor: pointer; color: #999; padding: 4px; }
main { padding: 12px; }
/* 搜索条 */
.search-bar {
display: flex; background: #f0f0f0; border-radius: 6px; padding: 4px;
}
.search-bar input {
flex: 1; background: transparent; border: none; outline: none;
padding: 4px 8px; font-size: 13px;
}
.search-bar button {
background: #007aff; color: white; border: none;
padding: 4px 10px; border-radius: 4px; font-size: 12px; cursor: pointer;
}
.modal-header h2 {
font-size: 1.2rem;
font-weight: 700;
color: #1d1d1f;
margin: 0;
/* 结果卡片 */
.result-card {
margin-top: 12px; display: flex; align-items: center;
padding: 10px; background: #f9f9f9; border-radius: 8px;
}
.mini-avatar { width: 40px; height: 40px; border-radius: 50%; margin-right: 10px; }
.info { flex: 1; }
.name { font-size: 14px; font-weight: bold; color: #333; }
.id { font-size: 11px; color: #999; }
.next-btn {
background: #007aff; color: white; border: none;
padding: 5px 12px; border-radius: 15px; font-size: 12px; cursor: pointer;
}
.btn-back-icon {
background: none;
border: none;
font-size: 1.2rem;
cursor: pointer;
color: #007aff;
padding: 0;
/* 表单区 */
.f-item { margin-bottom: 10px; }
.f-item label { display: block; font-size: 12px; color: #999; margin-bottom: 4px; }
.f-input {
width: 100%; border: 1px solid #eee; border-radius: 4px;
padding: 8px; box-sizing: border-box; font-size: 13px; outline: none;
}
.f-input:focus { border-color: #007aff; }
.icon-close {
background: #f5f5f7;
border: none;
width: 28px;
height: 28px;
border-radius: 50%;
color: #86868b;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
footer { padding: 0 12px 12px; }
.submit-btn {
width: 100%; padding: 10px; background: #007aff; color: white;
border: none; border-radius: 6px; font-weight: bold; cursor: pointer;
}
.submit-btn:disabled { opacity: 0.6; }
.icon-close:hover {
background: #e8e8ed;
color: #1d1d1f;
}
/* 3. 搜索区域 */
.search-box {
display: flex;
background: #f5f5f7;
border-radius: 14px;
padding: 6px;
margin-bottom: 20px;
transition: all 0.3s;
border: 1px solid transparent;
}
.search-box:focus-within {
background: #fff;
border-color: #007aff;
box-shadow: 0 0 0 4px rgba(0, 122, 255, 0.1);
}
.search-box input {
flex: 1;
background: transparent;
border: none;
padding: 10px 14px;
outline: none;
font-size: 14px;
color: #1d1d1f;
}
.search-btn {
background: #007aff;
color: white;
border: none;
padding: 0 18px;
border-radius: 10px;
cursor: pointer;
font-weight: 600;
font-size: 13px;
}
.search-btn:disabled {
opacity: 0.7;
}
/* 4. 用户卡片 */
.user-card {
background: #f5f5f7;
border-radius: 20px;
padding: 20px;
text-align: center;
}
.user-info {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 20px;
}
.avatar {
width: 72px;
height: 72px;
border-radius: 50%;
margin-bottom: 12px;
background: #fff;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
}
.name {
font-weight: 700;
font-size: 17px;
color: #1d1d1f;
}
.id {
font-size: 13px;
color: #86868b;
margin-top: 4px;
}
.add-action-btn {
width: 100%;
background: #007aff;
color: white;
border: none;
padding: 12px;
border-radius: 12px;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s;
}
.add-action-btn:active {
transform: scale(0.98);
}
/* 5. 表单样式 */
.form-container {
display: flex;
flex-direction: column;
gap: 18px;
}
.form-item label {
font-size: 13px;
font-weight: 600;
color: #86868b;
margin-bottom: 8px;
display: block;
}
.form-input, .form-textarea {
width: 100%;
background: #f5f5f7;
border: 1px solid transparent;
border-radius: 12px;
padding: 12px 16px;
font-size: 14px;
outline: none;
box-sizing: border-box;
}
.form-input:focus, .form-textarea:focus {
background: #fff;
border-color: #007aff;
}
.btn-submit {
background: #007aff;
color: white;
border: none;
padding: 14px;
border-radius: 14px;
font-weight: 600;
cursor: pointer;
margin-top: 10px;
}
/* 6. 动画与反馈 */
.spinner {
width: 16px;
height: 16px;
border: 2px solid rgba(255,255,255,0.3);
border-top-color: #fff;
border-radius: 50%;
display: inline-block;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.fade-enter-active, .fade-leave-active { transition: opacity 0.3s; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
.fade-slide-enter-active {
transition: all 0.4s cubic-bezier(0.18, 0.89, 0.32, 1.28);
}
.fade-slide-enter-from {
opacity: 0;
transform: translateY(20px);
}
.empty-state {
text-align: center;
color: #86868b;
font-size: 14px;
padding: 30px 0;
}
.empty { text-align: center; padding: 20px; font-size: 12px; color: #ccc; }
</style>

View File

@ -0,0 +1,17 @@
export const FRIEND_ACTIONS = Object.freeze({
/**接受 */
Accept: 'Accept',
/**拒绝 */
Reject: 'Reject'
});
export const FRIEND_REQUEST_STATUS = Object.freeze({
/**待处理 */
Pending: 'Pending',
/**通过 */
Passed: 'Passed',
/**已拒绝 */
Declined: 'Declined',
/**已拉黑 */
Blocked: 'Blocked'
})

View File

@ -0,0 +1,3 @@
export const SYSTEM_BASE_STATUS = Object.freeze({
SUCCESS: 0
});

View File

@ -1,4 +1,5 @@
import { request } from "./api";
import { FRIEND_ACTIONS } from "@/constants/friendAction";
export const friendService = {
@ -28,5 +29,16 @@ export const friendService = {
* @param {*} limit
* @returns
*/
getFriendRequests: (page = 1, limit = 100) => request.get(`/friend/requests?page=${page}&limit=${limit}`)
getFriendRequests: (page = 1, limit = 100) => request.get(`/friend/requests?page=${page}&limit=${limit}`),
/**
* 处理好友请求
* @param {*} friendRequestId
* @param {typeof FRIEND_ACTIONS[keyof typeof FRIEND_ACTIONS]} action
* @returns
*/
handleFriendRequest: (friendRequestId, action, remarkname) => request.post(`/Friend/HandleRequest?id=${friendRequestId}`, {
remarkName: remarkname,
action: action
})
}

View File

@ -5,7 +5,7 @@ const STORE_NAME = 'messages';
const CONVERSARION_STORE_NAME = 'conversations';
const CONTACT_STORE_NAME = 'contacts';
export const dbPromise = openDB(DBNAME, 4, {
export const dbPromise = openDB(DBNAME, 5, {
upgrade(db) {
if (!db.objectStoreNames.contains(STORE_NAME)) {
const store = db.createObjectStore(STORE_NAME, { keyPath: 'msgId' });
@ -21,6 +21,7 @@ export const dbPromise = openDB(DBNAME, 4, {
const store = db.createObjectStore(CONTACT_STORE_NAME, { keyPath: 'id' });
store.createIndex('by-id', 'id');
store.createIndex('by-username', 'username');
store.createIndex('by-friendId', 'friendId', { unique: true });
}
}
})

View File

@ -23,18 +23,13 @@
<div class="name">标签</div>
</div>
</div>
<div class="group-title">我的好友</div>
<div v-for="c in filteredContacts"
:key="c.id"
class="list-item"
:class="{active: activeContactId === c.id}"
@click="routeUserInfo(c.id)">
<img :src="c.userInfo.avatar" class="avatar-std" />
<div class="info">
<div class="name">{{ c.remarkName }}</div>
</div>
<div class="contactTab">
<button class="group-title" :class="{'group-title-active': contactTab === 0}" @click="contactTab = 0">我的好友</button>
<button class="group-title" :class="{'group-title-active': contactTab === 1}" @click="contactTab = 1">群聊</button>
</div>
<contactShow v-if="contactTab == 0" :contacts="filteredContacts"></contactShow>
<groupsShow v-if="contactTab == 1" :groups="myGroups"></groupsShow>
</div>
</aside>
<RouterView></RouterView>
@ -54,14 +49,36 @@ import GroupChatModal from '@/components/groups/GroupChatModal.vue'
import feather from 'feather-icons';
import { useContactStore } from '@/stores/contact';
import { useRouter } from 'vue-router';
import contactShow from '@/components/contacts/contactShow.vue';
import groupsShow from '@/components/groups/groupsShow.vue';
const router = useRouter();
const searchQuery = ref('')
const activeContactId = ref(null)
const contactStore = useContactStore();
const groupModal = ref(false);
const contactTab = ref(0);
const myGroups = ref([
{
id: 1,
name: "产品设计交流群",
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=1",
lastMessage: "那个UI设计的初稿已经发在群文件了大家记得看下。",
lastTime: "14:20",
unread: 3,
online: true
},
{
id: 2,
name: "周五羽毛球小分队",
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=2",
lastMessage: "这周五晚上 8 点,老地方见!",
lastTime: "昨天",
unread: 0,
online: false
}
]);
const filteredContacts = computed(() => {
@ -82,10 +99,7 @@ const filteredContacts = computed(() => {
})
})
const routeUserInfo = (id) => {
router.push(`/contacts/info/${id}`);
activeContactId.value = id;
}
// Tab
const emit = defineEmits(['start-chat'])
@ -95,6 +109,7 @@ const showGroupList = () => {
}
onMounted(async () => {
await contactStore.loadContactList();
})
@ -145,11 +160,38 @@ onMounted(async () => {
}
.group-title {
padding: 8px 12px;
width: 40%;
padding: 5px 14px;
font-size: 12px;
color: #999;
margin: 5px;
border: none;
background-color: #e0e0e0;
border-radius: 4px;
}
.group-title:hover {
color: #8e8e8e;
}
.group-title-active {
background-color: white;
color: rgb(78, 78, 249);
}
.fixed-entries {
margin-bottom: 15px;
border-bottom: 1px solid #dcdcdc;
}
.contactTab {
width: 90%;
margin: 10px auto;
background: #e0e0e0;
display: flex;
align-content: center;
justify-content: center;
border-radius: 4px;
}
.list-item {
display: flex;
padding: 10px 12px;

View File

@ -17,20 +17,20 @@
</div>
<div class="actions">
<template v-if="item.state === 0 && item.requestUser != authStore.userInfo.id">
<button class="btn-text btn-reject" @click="item.status = 2">拒绝</button>
<button class="btn-text btn-accept" @click="item.status = 1">接受</button>
<template v-if="item.state === FRIEND_REQUEST_STATUS.Pending && item.requestUser != authStore.userInfo.id">
<button class="btn-text btn-reject" @click="confirmReject(item)">拒绝</button>
<button class="btn-text btn-accept" @click="handleOpenDialog(item)">接受</button>
</template>
<span v-else-if="item.state === 0" class="status-label">
<span v-else-if="item.state === FRIEND_REQUEST_STATUS.Pending" class="status-label">
待对方同意
</span>
<span v-else-if="item.state === 1" class="status-label">
<span v-else-if="item.state === FRIEND_REQUEST_STATUS.Declined" class="status-label">
{{item.requestUser != authStore.userInfo.id ? '已拒绝' : '对方拒绝'}}
</span>
<span v-else-if="item.state === 2" class="status-label">
<span v-else-if="item.state === FRIEND_REQUEST_STATUS.Passed" class="status-label">
已添加
</span>
<span v-else-if="item.state === 3 && item.requestUser != authStore.userInfo.id" class="status-label">
<span v-else-if="item.state === FRIEND_REQUEST_STATUS.Blocked && item.requestUser != authStore.userInfo.id" class="status-label">
已拉黑
</span>
<span v-else class="status-label">
@ -40,6 +40,16 @@
</div>
</div>
</div>
<div v-if="showDialog" class="modal-mask">
<div class="modal-box">
<div class="modal-header">添加备注</div>
<input v-model="remarkName" class="modal-input" placeholder="备注姓名" focus />
<div class="modal-footer">
<button class="modal-btn-cancel" @click="showDialog = false">取消</button>
<button class="modal-btn-confirm" @click="confirmAccept">确定</button>
</div>
</div>
</div>
</div>
</template>
@ -49,6 +59,8 @@ import { friendService } from '@/services/friend';
import { useMessage } from '@/components/messages/useAlert';
import { formatDate } from '@/utils/formatDate';
import { useAuthStore } from '@/stores/auth';
import { FRIEND_ACTIONS, FRIEND_REQUEST_STATUS } from '@/constants/friendAction';
import { SYSTEM_BASE_STATUS } from '@/constants/systemBaseStatus';
const message = useMessage();
const authStore = useAuthStore();
@ -64,6 +76,50 @@ const loadFriendRequests = async () => {
requests.value = res.data;
}
const showDialog = ref(false);
const remarkName = ref('');
const activeItem = ref(null);
const handleOpenDialog = (item) => {
activeItem.value = item;
remarkName.value = item.nickName; //
showDialog.value = true;
};
const confirmAccept = async () => {
if (!activeItem.value) return;
await handleFriendRequest(FRIEND_ACTIONS.Accept)
activeItem.value.state = FRIEND_REQUEST_STATUS.Passed;
showDialog.value = false;
};
const confirmReject = async (item) => {
if(!item) return;
activeItem.value = item;
await handleFriendRequest(FRIEND_ACTIONS.Reject);
activeItem.value.state = FRIEND_REQUEST_STATUS.Declined;
}
const handleFriendRequest = async (action) => {
const res = await friendService.handleFriendRequest(activeItem.value.id,action,activeItem.value.remarkName);
if(res.code == SYSTEM_BASE_STATUS.SUCCESS){
switch(action){
case FRIEND_ACTIONS.Accept:
message.show('添加好友成功');
break;
case FRIEND_ACTIONS.Reject:
message.show('已拒绝');
break;
default:
message.error('无效的操作');
break;
}
}else{
message.error(res.message);
console.log('好友请求处理异常:', res);
}
}
onMounted(async () => {
await loadFriendRequests();
})
@ -79,6 +135,7 @@ onMounted(async () => {
display: flex;
justify-content: center;
overflow-y: auto;
position: relative;
}
.content-limit {
@ -197,4 +254,70 @@ onMounted(async () => {
color: #d2d2d7;
padding: 0 12px;
}
/* 弹窗遮罩:毛玻璃效果 */
.modal-mask {
position: absolute;
inset: 0;
background: rgba(255, 255, 255, 0.4);
backdrop-filter: blur(10px);
display: flex;
align-items: center;
justify-content: center;
z-index: 999;
}
/* 弹窗主体:延续你的极简白 */
.modal-box {
background: #fff;
width: 280px;
padding: 24px;
border-radius: 20px;
box-shadow: 0 10px 30px rgba(0,0,0,0.05);
text-align: center;
}
.modal-header {
font-size: 16px;
font-weight: 600;
margin-bottom: 16px;
}
/* 输入框:延续你的微灰色调 */
.modal-input {
width: 100%;
padding: 10px;
border: none;
background: #f5f5f7;
border-radius: 8px;
margin-bottom: 20px;
outline: none;
box-sizing: border-box;
}
.modal-footer {
display: flex;
gap: 12px;
}
/* 按钮:完全复用你原本的 btn-text 逻辑 */
.modal-btn-cancel {
flex: 1;
padding: 10px;
border: none;
background: #f5f5f7;
border-radius: 10px;
color: #86868b;
cursor: pointer;
}
.modal-btn-confirm {
flex: 1;
padding: 10px;
border: none;
background: #007aff;
color: white;
border-radius: 10px;
cursor: pointer;
}
</style>

View File

@ -59,6 +59,7 @@ 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';
const props = defineProps({
id:{
@ -142,13 +143,22 @@ async function loadConversation(conversationId) {
conversationInfo.value = conversationStore.conversations.find(x => x.id == Number(conversationId));
}
//
onMounted(async () => {
await loadConversation(props.id);
const initChat = async (newId) => {
await loadConversation(newId);
const sessionid = generateSessionId(conversationInfo.value.userId, conversationInfo.value.targetId)
await chatStore.swtichSession(sessionid,props.id);
await chatStore.swtichSession(sessionid,newId);
scrollToBottom();
});
}
//
watch(
() => props.id,
async (newId) => {
await initChat(newId)
},
{ immediate: true } //
)
</script>
<style scoped>

View File

@ -35,6 +35,7 @@
<RouterView></RouterView>
<SearchUser v-model="searchUserModal"/>
<CreateGroup v-model="createGroupModal"></CreateGroup>
</div>
</template>
@ -47,6 +48,7 @@ import { useConversationStore } from '@/stores/conversation'
import AddMenu from '@/components/addMenu.vue'
import feather from 'feather-icons'
import SearchUser from '@/components/user/SearchUser.vue'
import CreateGroup from '@/components/groups/CreateGroup.vue'
import { useBrowserNotification } from '@/services/useBrowserNotification'
const conversationStore = useConversationStore();
@ -56,25 +58,26 @@ const browserNotification = useBrowserNotification();
const searchQuery = ref('')
const activeId = ref(1)
const searchUserModal = ref(false);
const createGroupModal = ref(false);
const msgTitleShow = ref(false);
const addMenuList = [
{
text: '发起群聊',
action: 'createGroup',
// /
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path></svg>`
icon: feather.icons['message-square'].toSvg()
},
{
text: '添加朋友',
action: 'addFriend',
// +
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="8.5" cy="7" r="4"></circle><line x1="20" y1="8" x2="20" y2="14"></line><line x1="17" y1="11" x2="23" y2="11"></line></svg>`
icon: feather.icons['user-plus'].toSvg()
},
{
text: '新建笔记',
action: 'newNote',
// /
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path></svg>`
icon: feather.icons['book-open'].toSvg()
}
];
@ -90,6 +93,8 @@ function actionHandler(type){
case 'addFriend':
searchUserModal.value = true;
break;
case 'createGroup':
createGroupModal.value = true;
default:
break;
}

View File

@ -1 +1,261 @@
<template></template>
<template>
<div class="im-settings-container">
<section class="menu-sidebar">
<h2 class="sidebar-title">系统设置</h2>
<div class="menu-list">
<button
v-for="item in menuItems"
:key="item.id"
:class="['menu-item', { active: activeTab === item.id }]"
@click="activeTab = item.id"
>
<span class="item-icon" v-html="item.icon"></span>
<span class="item-text">{{ item.name }}</span>
</button>
</div>
<div class="sidebar-footer">版本 v2.4.0</div>
</section>
<main class="main-panel">
<header class="panel-header">
<h1>{{ currentMenuName }}</h1>
<p>配置您的个性化通讯偏好</p>
</header>
<div class="panel-body">
<div v-if="activeTab === 'notifications'" class="setting-group">
<div v-for="(val, key) in notificationSettings" :key="key" class="setting-row">
<div class="row-info">
<div class="row-label">{{ key }}</div>
<div class="row-desc">开启后系统将实时同步您的通知偏好</div>
</div>
<label class="toggle-switch">
<input type="checkbox" v-model="notificationSettings[key]">
<span class="slider"></span>
</label>
</div>
</div>
<div v-else class="empty-placeholder">
<span class="icon"></span>
<p>{{ currentMenuName }} 功能开发中...</p>
</div>
</div>
<footer class="panel-footer">
<button class="btn-cancel" @click="reset">重置</button>
<button class="btn-save" @click="save">保存更改</button>
</footer>
</main>
</div>
</template>
<script setup>
import { ref, reactive, computed } from 'vue'
import feather from 'feather-icons'
const activeTab = ref('notifications')
const menuItems = [
{ id: 'profile', name: '个人资料', icon: feather.icons['user-check'].toSvg() },
{ id: 'notifications', name: '通知设置', icon: feather.icons['bell'].toSvg() },
{ id: 'security', name: '账号安全', icon: feather.icons['shield'].toSvg() },
{ id: 'general', name: '通用设置', icon: feather.icons['settings'].toSvg() }
]
const currentMenuName = computed(() => menuItems.find(i => i.id === activeTab.value)?.name)
const notificationSettings = reactive({
'声音提醒': true,
'桌面弹窗': true,
'仅在免打扰外提醒': false
})
const save = () => alert('设置已生效')
const reset = () => location.reload()
</script>
<style scoped>
/* 变量定义 */
:component {
--active-color: #00a884;
--bg-sidebar: #f7f9fa;
--bg-hover: #f0f2f5;
--text-main: #111b21;
--text-dim: #667781;
--border: #e9edef;
}
/* 核心容器:填满外部 */
.im-settings-container {
display: flex;
width: 100%;
height: 100%; /* 绝对铺满 */
background: #ffffff;
font-family: sans-serif;
overflow: hidden;
}
/* 左侧样式 */
.menu-sidebar {
width: 260px;
background: #eee;
border-right: 1px solid #d6d6d6;
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.sidebar-title {
padding: 30px 24px;
font-size: 20px;
font-weight: 700;
color: var(--text-main);
margin: 0;
}
.menu-list {
flex: 1;
padding: 0 12px;
overflow-y: auto;
}
.menu-item {
width: 100%;
padding: 14px 16px;
display: flex;
align-items: center;
border: none;
background: transparent;
border-radius: 10px;
cursor: pointer;
margin-bottom: 4px;
transition: 0.2s;
color: var(--text-dim);
}
.menu-item:hover { background: #c6c6c6; }
.menu-item.active {
background: #c6c6c6;
color: var(--text-main);
font-weight: 600;
}
.item-icon { margin-right: 12px; font-size: 18px; }
.sidebar-footer {
padding: 20px;
text-align: center;
font-size: 12px;
color: #cbd5e1;
}
/* 右侧内容样式 */
.main-panel {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
background-color: #f5f5f5;
}
.panel-header {
padding: 40px 50px 20px;
}
.panel-header h1 { font-size: 26px; color: var(--text-main); margin: 0 0 8px 0; }
.panel-header p { font-size: 14px; color: var(--text-dim); margin: 0; }
.panel-body {
flex: 1;
padding: 0 50px;
overflow-y: auto;
}
.setting-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24px 0;
border-bottom: 1px solid var(--border);
}
.row-label { font-weight: 500; color: var(--text-main); margin-bottom: 4px; }
.row-desc { font-size: 13px; color: var(--text-dim); }
/* 开关组件 */
.toggle-switch {
position: relative;
width: 46px;
height: 24px;
flex-shrink: 0;
}
.toggle-switch input { opacity: 0; width: 0; height: 0; }
.slider {
position: absolute;
cursor: pointer;
inset: 0;
background-color: #d1d5db;
transition: .3s;
border-radius: 24px;
}
.slider:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background-color: white;
transition: .3s;
border-radius: 50%;
}
input:checked + .slider { background-color: #0075ae; }
input:checked + .slider:before { transform: translateX(22px); }
/* 底部操作 */
.panel-footer {
padding: 30px 50px;
display: flex;
justify-content: flex-end;
gap: 16px;
}
.btn-cancel {
background: none;
border: none;
color: var(--text-dim);
cursor: pointer;
font-weight: 500;
}
.btn-save {
background: #000000;
color: white;
border: none;
padding: 12px 32px;
border-radius: 25px;
font-weight: 600;
cursor: pointer;
box-shadow: 0 4px 10px rgba(0, 168, 132, 0.2);
}
.empty-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 200px;
color: #cbd5e1;
}
/* 响应式:窄容器自动堆叠 */
@media (max-width: 650px) {
.im-settings-container { flex-direction: column; }
.menu-sidebar { width: 100%; border-right: none; border-bottom: 1px solid var(--border); }
.menu-list { display: flex; overflow-x: auto; padding: 10px; }
.menu-item { white-space: nowrap; width: auto; margin-right: 8px; margin-bottom: 0; }
.sidebar-title, .sidebar-footer { display: none; }
}
</style>