后端:

add(GroupNotification):合并群通知表
This commit is contained in:
2026-03-15 14:55:29 +08:00
parent f7223dc590
commit eb8455e141
70 changed files with 3534 additions and 660 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -14,7 +14,7 @@ using System.Reflection;
[assembly: System.Reflection.AssemblyCompanyAttribute("IMTest")] [assembly: System.Reflection.AssemblyCompanyAttribute("IMTest")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")] [assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")] [assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+2ecaa28091b41de707825db3628d380b62fa727f")] [assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+f7223dc5904286fdf8e117b56c37f58dc431d68b")]
[assembly: System.Reflection.AssemblyProductAttribute("IMTest")] [assembly: System.Reflection.AssemblyProductAttribute("IMTest")]
[assembly: System.Reflection.AssemblyTitleAttribute("IMTest")] [assembly: System.Reflection.AssemblyTitleAttribute("IMTest")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")] [assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
@@ -1 +1 @@
ed4980dfc7aff253176b260ed9015f9a80b52e92cbf3095eff3ed06865ea6e0d 3c0a5335719f892c65744a9df7eae9fd7b12de9067131f5c9f4cb65f2b27b8d4
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -15,7 +15,7 @@ namespace IM_API.Application.EventHandlers.GroupInviteActionUpdateHandler
public async Task Consume(ConsumeContext<GroupInviteActionUpdateEvent> context) public async Task Consume(ConsumeContext<GroupInviteActionUpdateEvent> context)
{ {
var @event = context.Message; var @event = context.Message;
if(@event.Action == Models.GroupInviteState.Passed) if(@event.Action == Models.GroupRequestState.Passed)
{ {
await _groupService.MakeGroupRequestAsync(@event.UserId, @event.InviteUserId,@event.GroupId); await _groupService.MakeGroupRequestAsync(@event.UserId, @event.InviteUserId,@event.GroupId);
} }
+14
View File
@@ -229,6 +229,20 @@ namespace IM_API.Configs
.ForMember(dest => dest.AllMembersBanned, opt => opt.MapFrom(src => src.AllMembersBannedEnum)) .ForMember(dest => dest.AllMembersBanned, opt => opt.MapFrom(src => src.AllMembersBannedEnum))
.ForMember(dest => dest.Status, opt => opt.MapFrom(src => src.StatusEnum)) .ForMember(dest => dest.Status, opt => opt.MapFrom(src => src.StatusEnum))
; ;
//群通知模型转换
CreateMap<GroupRequest, GroupNotificationVo>()
.ForMember(dest => dest.UserId, opt => opt.MapFrom(src => src.UserId))
.ForMember(dest => dest.GroupId, opt => opt.MapFrom(src => src.GroupId))
.ForMember(dest => dest.InviteUser, opt => opt.MapFrom(src => src.InviteUserId))
.ForMember(dest => dest.Status, opt => opt.MapFrom(src => src.StateEnum))
.ForMember(dest => dest.Description, opt => opt.MapFrom(src => src.Description))
.ForMember(dest => dest.RequestId, opt => opt.MapFrom(src => src.Id))
//.ForAllMembers(opt => opt.Ignore())
;
} }
} }
} }
@@ -96,5 +96,14 @@ namespace IM_API.Controllers
var group = await _groupService.GetGroupInfoAsync(groupId); var group = await _groupService.GetGroupInfoAsync(groupId);
return Ok(new BaseResponse<GroupInfoVo>(group)); return Ok(new BaseResponse<GroupInfoVo>(group));
} }
[HttpGet]
[ProducesResponseType(typeof(BaseResponse<List<GroupNotificationVo>>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetGroupNotification([FromQuery]int groupId)
{
string userIdStr = User.FindFirstValue(ClaimTypes.NameIdentifier)!;
var data = await _groupService.GetGroupNotificationAsync(int.Parse(userIdStr));
return Ok(new BaseResponse<List<GroupNotificationVo>>(data));
}
} }
} }
@@ -9,6 +9,6 @@ namespace IM_API.Domain.Events
public int InviteUserId { get; set; } public int InviteUserId { get; set; }
public int InviteId { get; set; } public int InviteId { get; set; }
public int GroupId { get; set; } public int GroupId { get; set; }
public GroupInviteState Action { get; set; } public GroupRequestState Action { get; set; }
} }
} }
@@ -5,6 +5,6 @@ namespace IM_API.Dtos.Group
public class HandleGroupInviteDto public class HandleGroupInviteDto
{ {
public int InviteId { get; set; } public int InviteId { get; set; }
public GroupInviteState Action { get; set; } public GroupRequestState Action { get; set; }
} }
} }
@@ -56,5 +56,12 @@ namespace IM_API.Interface.Services
Task<GroupInfoVo> UpdateGroupInfoAsync(int userId, int groupId, GroupUpdateDto updateDto); Task<GroupInfoVo> UpdateGroupInfoAsync(int userId, int groupId, GroupUpdateDto updateDto);
Task<GroupInfoVo> GetGroupInfoAsync(int groupId); Task<GroupInfoVo> GetGroupInfoAsync(int groupId);
/// <summary>
/// 获取群聊通知
/// </summary>
/// <param name="userId"></param>
/// <returns></returns>
Task<List<GroupNotificationVo>> GetGroupNotificationAsync(int userId);
} }
} }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,54 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace IM_API.Migrations
{
/// <inheritdoc />
public partial class updategroupannouncement : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.UpdateData(
table: "groups",
keyColumn: "Announcement",
keyValue: null,
column: "Announcement",
value: "");
migrationBuilder.AlterColumn<string>(
name: "Announcement",
table: "groups",
type: "text",
nullable: false,
comment: "群公告",
collation: "utf8mb4_general_ci",
oldClrType: typeof(string),
oldType: "text",
oldNullable: true,
oldComment: "群公告")
.Annotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("Relational:Collation", "utf8mb4_general_ci");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "Announcement",
table: "groups",
type: "text",
nullable: true,
comment: "群公告",
collation: "utf8mb4_general_ci",
oldClrType: typeof(string),
oldType: "text",
oldComment: "群公告")
.Annotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("Relational:Collation", "utf8mb4_general_ci");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,87 @@
using System;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace IM_API.Migrations
{
/// <inheritdoc />
public partial class groupinviterequestmerge : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "group_invite");
migrationBuilder.AddColumn<int>(
name: "InviteUserId",
table: "group_request",
type: "int",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "InviteUserId",
table: "group_request");
migrationBuilder.RenameIndex(
name: "GroupId",
table: "group_request",
newName: "GroupId1");
migrationBuilder.CreateTable(
name: "group_invite",
columns: table => new
{
ID = table.Column<int>(type: "int(11)", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
GroupId = table.Column<int>(type: "int(11)", nullable: false, comment: "群聊编号"),
InvitedUser = table.Column<int>(type: "int(11)", nullable: true, comment: "被邀请用户"),
InviteUser = table.Column<int>(type: "int(11)", nullable: true, comment: "邀请用户"),
Created = table.Column<DateTimeOffset>(type: "datetime", nullable: true, comment: "创建时间"),
State = table.Column<sbyte>(type: "tinyint(4)", nullable: true, comment: "当前状态(0:待被邀请人同意\r\n1:被邀请人已同意)")
},
constraints: table =>
{
table.PrimaryKey("PRIMARY", x => x.ID);
table.ForeignKey(
name: "group_invite_ibfk_1",
column: x => x.InviteUser,
principalTable: "users",
principalColumn: "ID");
table.ForeignKey(
name: "group_invite_ibfk_2",
column: x => x.GroupId,
principalTable: "groups",
principalColumn: "ID");
table.ForeignKey(
name: "group_invite_ibfk_3",
column: x => x.InvitedUser,
principalTable: "users",
principalColumn: "ID");
})
.Annotation("MySql:CharSet", "utf8mb4")
.Annotation("Relational:Collation", "utf8mb4_general_ci");
migrationBuilder.CreateIndex(
name: "GroupId",
table: "group_invite",
column: "GroupId");
migrationBuilder.CreateIndex(
name: "InvitedUser",
table: "group_invite",
column: "InvitedUser");
migrationBuilder.CreateIndex(
name: "InviteUser",
table: "group_invite",
column: "InviteUser");
}
}
}
@@ -333,6 +333,7 @@ namespace IM_API.Migrations
.HasComment("全员禁言(0允许发言,2全员禁言)"); .HasComment("全员禁言(0允许发言,2全员禁言)");
b.Property<string>("Announcement") b.Property<string>("Announcement")
.IsRequired()
.HasColumnType("text") .HasColumnType("text")
.HasComment("群公告"); .HasComment("群公告");
@@ -392,50 +393,6 @@ namespace IM_API.Migrations
MySqlEntityTypeBuilderExtensions.UseCollation(b, "utf8mb4_general_ci"); MySqlEntityTypeBuilderExtensions.UseCollation(b, "utf8mb4_general_ci");
}); });
modelBuilder.Entity("IM_API.Models.GroupInvite", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int(11)")
.HasColumnName("ID");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset?>("Created")
.HasColumnType("datetime")
.HasComment("创建时间");
b.Property<int>("GroupId")
.HasColumnType("int(11)")
.HasComment("群聊编号");
b.Property<int?>("InviteUser")
.HasColumnType("int(11)")
.HasComment("邀请用户");
b.Property<int?>("InvitedUser")
.HasColumnType("int(11)")
.HasComment("被邀请用户");
b.Property<sbyte?>("State")
.HasColumnType("tinyint(4)")
.HasComment("当前状态(0:待被邀请人同意\r\n1:被邀请人已同意)");
b.HasKey("Id")
.HasName("PRIMARY");
b.HasIndex(new[] { "GroupId" }, "GroupId");
b.HasIndex(new[] { "InviteUser" }, "InviteUser");
b.HasIndex(new[] { "InvitedUser" }, "InvitedUser");
b.ToTable("group_invite", (string)null);
MySqlEntityTypeBuilderExtensions.HasCharSet(b, "utf8mb4");
MySqlEntityTypeBuilderExtensions.UseCollation(b, "utf8mb4_general_ci");
});
modelBuilder.Entity("IM_API.Models.GroupMember", b => modelBuilder.Entity("IM_API.Models.GroupMember", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -502,6 +459,9 @@ namespace IM_API.Migrations
.HasColumnType("int(11)") .HasColumnType("int(11)")
.HasComment("群聊编号\r\n"); .HasComment("群聊编号\r\n");
b.Property<int?>("InviteUserId")
.HasColumnType("int");
b.Property<sbyte>("State") b.Property<sbyte>("State")
.HasColumnType("tinyint(4)") .HasColumnType("tinyint(4)")
.HasComment("申请状态(0:待管理员同意,1:已拒绝,2:已同意)"); .HasComment("申请状态(0:待管理员同意,1:已拒绝,2:已同意)");
@@ -515,8 +475,7 @@ namespace IM_API.Migrations
b.HasIndex("UserId"); b.HasIndex("UserId");
b.HasIndex(new[] { "GroupId" }, "GroupId") b.HasIndex(new[] { "GroupId" }, "GroupId");
.HasDatabaseName("GroupId1");
b.ToTable("group_request", (string)null); b.ToTable("group_request", (string)null);
@@ -991,31 +950,6 @@ namespace IM_API.Migrations
b.Navigation("GroupMasterNavigation"); b.Navigation("GroupMasterNavigation");
}); });
modelBuilder.Entity("IM_API.Models.GroupInvite", b =>
{
b.HasOne("IM_API.Models.Group", "Group")
.WithMany("GroupInvites")
.HasForeignKey("GroupId")
.IsRequired()
.HasConstraintName("group_invite_ibfk_2");
b.HasOne("IM_API.Models.User", "InviteUserNavigation")
.WithMany("GroupInviteInviteUserNavigations")
.HasForeignKey("InviteUser")
.HasConstraintName("group_invite_ibfk_1");
b.HasOne("IM_API.Models.User", "InvitedUserNavigation")
.WithMany("GroupInviteInvitedUserNavigations")
.HasForeignKey("InvitedUser")
.HasConstraintName("group_invite_ibfk_3");
b.Navigation("Group");
b.Navigation("InviteUserNavigation");
b.Navigation("InvitedUserNavigation");
});
modelBuilder.Entity("IM_API.Models.GroupMember", b => modelBuilder.Entity("IM_API.Models.GroupMember", b =>
{ {
b.HasOne("IM_API.Models.Group", "Group") b.HasOne("IM_API.Models.Group", "Group")
@@ -1108,8 +1042,6 @@ namespace IM_API.Migrations
modelBuilder.Entity("IM_API.Models.Group", b => modelBuilder.Entity("IM_API.Models.Group", b =>
{ {
b.Navigation("GroupInvites");
b.Navigation("GroupMembers"); b.Navigation("GroupMembers");
b.Navigation("GroupRequests"); b.Navigation("GroupRequests");
@@ -1148,10 +1080,6 @@ namespace IM_API.Migrations
b.Navigation("FriendUsers"); b.Navigation("FriendUsers");
b.Navigation("GroupInviteInviteUserNavigations");
b.Navigation("GroupInviteInvitedUserNavigations");
b.Navigation("GroupMembers"); b.Navigation("GroupMembers");
b.Navigation("GroupRequests"); b.Navigation("GroupRequests");
+1 -2
View File
@@ -38,7 +38,7 @@ public partial class Group
/// <summary> /// <summary>
/// 群公告 /// 群公告
/// </summary> /// </summary>
public string? Announcement { get; set; } public string Announcement { get; set; } = "暂无群公告,点击编辑添加。";
/// <summary> /// <summary>
/// 群聊创建时间 /// 群聊创建时间
@@ -56,7 +56,6 @@ public partial class Group
public DateTimeOffset LastUpdateTime { get; set; } = DateTime.UtcNow; public DateTimeOffset LastUpdateTime { get; set; } = DateTime.UtcNow;
public virtual ICollection<GroupInvite> GroupInvites { get; set; } = new List<GroupInvite>();
public virtual User GroupMasterNavigation { get; set; } = null!; public virtual User GroupMasterNavigation { get; set; } = null!;
-11
View File
@@ -1,11 +0,0 @@
namespace IM_API.Models
{
public partial class GroupInvite
{
public GroupInviteState StateEnum
{
get => (GroupInviteState)State;
set => State = (sbyte)value;
}
}
}
-17
View File
@@ -1,17 +0,0 @@
namespace IM_API.Models
{
/// <summary>
/// 群邀请状态
/// </summary>
public enum GroupInviteState
{
/// <summary>
/// 待处理
/// </summary>
Pending = 0,
/// <summary>
/// 已同意
/// </summary>
Passed = 1
}
}
+11 -3
View File
@@ -7,12 +7,20 @@
/// </summary> /// </summary>
Pending = 0, Pending = 0,
/// <summary> /// <summary>
/// 已拒绝 /// 管理员已拒绝
/// </summary> /// </summary>
Declined = 1, Declined = 1,
/// <summary> /// <summary>
/// 已同意 /// 管理员已同意
/// </summary> /// </summary>
Passed = 2 Passed = 2,
/// <summary>
/// 待对方同意
/// </summary>
TargetPending = 3,
/// <summary>
/// 对方拒绝
/// </summary>
TargetDeclined = 4
} }
} }
-41
View File
@@ -1,41 +0,0 @@
using System;
using System.Collections.Generic;
namespace IM_API.Models;
public partial class GroupInvite
{
public int Id { get; set; }
/// <summary>
/// 群聊编号
/// </summary>
public int GroupId { get; set; }
/// <summary>
/// 被邀请用户
/// </summary>
public int? InvitedUser { get; set; }
/// <summary>
/// 邀请用户
/// </summary>
public int? InviteUser { get; set; }
/// <summary>
/// 当前状态(0:待被邀请人同意
/// 1:被邀请人已同意)
/// </summary>
public sbyte? State { get; set; }
/// <summary>
/// 创建时间
/// </summary>
public DateTimeOffset? Created { get; set; }
public virtual Group Group { get; set; } = null!;
public virtual User? InviteUserNavigation { get; set; }
public virtual User? InvitedUserNavigation { get; set; }
}
+3 -1
View File
@@ -19,8 +19,10 @@ public partial class GroupRequest
/// </summary> /// </summary>
public int UserId { get; set; } public int UserId { get; set; }
public int? InviteUserId { get; set; }
/// <summary> /// <summary>
/// 申请状态(0:待管理员同意,1:已拒绝,2:已同意 /// 申请状态(0:待管理员同意,1:管理员已拒绝,2管理员已同意,3:待对方同意,4:对方拒绝
/// </summary> /// </summary>
public sbyte State { get; set; } public sbyte State { get; set; }
@@ -49,11 +49,6 @@ namespace IM_API.Models
entity.Ignore(e => e.AuhorityEnum); entity.Ignore(e => e.AuhorityEnum);
}); });
modelBuilder.Entity<GroupInvite>(entity =>
{
entity.Ignore(e => e.StateEnum);
});
modelBuilder.Entity<GroupMember>(entity => modelBuilder.Entity<GroupMember>(entity =>
{ {
entity.Ignore(e => e.RoleEnum); entity.Ignore(e => e.RoleEnum);
-48
View File
@@ -27,7 +27,6 @@ public partial class ImContext : DbContext
public virtual DbSet<Group> Groups { get; set; } public virtual DbSet<Group> Groups { get; set; }
public virtual DbSet<GroupInvite> GroupInvites { get; set; }
public virtual DbSet<GroupMember> GroupMembers { get; set; } public virtual DbSet<GroupMember> GroupMembers { get; set; }
@@ -362,53 +361,6 @@ public partial class ImContext : DbContext
.HasConstraintName("groups_ibfk_1"); .HasConstraintName("groups_ibfk_1");
}); });
modelBuilder.Entity<GroupInvite>(entity =>
{
entity.HasKey(e => e.Id).HasName("PRIMARY");
entity
.ToTable("group_invite")
.HasCharSet("utf8mb4")
.UseCollation("utf8mb4_general_ci");
entity.HasIndex(e => e.GroupId, "GroupId");
entity.HasIndex(e => e.InviteUser, "InviteUser");
entity.HasIndex(e => e.InvitedUser, "InvitedUser");
entity.Property(e => e.Id)
.HasColumnType("int(11)")
.HasColumnName("ID");
entity.Property(e => e.Created)
.HasComment("创建时间")
.HasColumnType("datetime");
entity.Property(e => e.GroupId)
.HasComment("群聊编号")
.HasColumnType("int(11)");
entity.Property(e => e.InviteUser)
.HasComment("邀请用户")
.HasColumnType("int(11)");
entity.Property(e => e.InvitedUser)
.HasComment("被邀请用户")
.HasColumnType("int(11)");
entity.Property(e => e.State)
.HasComment("当前状态(0:待被邀请人同意\r\n1:被邀请人已同意)")
.HasColumnType("tinyint(4)");
entity.HasOne(d => d.Group).WithMany(p => p.GroupInvites)
.HasForeignKey(d => d.GroupId)
.OnDelete(DeleteBehavior.ClientSetNull)
.HasConstraintName("group_invite_ibfk_2");
entity.HasOne(d => d.InviteUserNavigation).WithMany(p => p.GroupInviteInviteUserNavigations)
.HasForeignKey(d => d.InviteUser)
.HasConstraintName("group_invite_ibfk_1");
entity.HasOne(d => d.InvitedUserNavigation).WithMany(p => p.GroupInviteInvitedUserNavigations)
.HasForeignKey(d => d.InvitedUser)
.HasConstraintName("group_invite_ibfk_3");
});
modelBuilder.Entity<GroupMember>(entity => modelBuilder.Entity<GroupMember>(entity =>
{ {
-4
View File
@@ -73,10 +73,6 @@ public partial class User
[JsonIgnore] [JsonIgnore]
public virtual ICollection<Friend> FriendUsers { get; set; } = new List<Friend>(); public virtual ICollection<Friend> FriendUsers { get; set; } = new List<Friend>();
[JsonIgnore] [JsonIgnore]
public virtual ICollection<GroupInvite> GroupInviteInviteUserNavigations { get; set; } = new List<GroupInvite>();
[JsonIgnore]
public virtual ICollection<GroupInvite> GroupInviteInvitedUserNavigations { get; set; } = new List<GroupInvite>();
[JsonIgnore]
public virtual ICollection<GroupMember> GroupMembers { get; set; } = new List<GroupMember>(); public virtual ICollection<GroupMember> GroupMembers { get; set; } = new List<GroupMember>();
[JsonIgnore] [JsonIgnore]
public virtual ICollection<GroupRequest> GroupRequests { get; set; } = new List<GroupRequest>(); public virtual ICollection<GroupRequest> GroupRequests { get; set; } = new List<GroupRequest>();
+79 -10
View File
@@ -79,6 +79,7 @@ namespace IM_API.Services
} }
} }
public Task DeleteGroupAsync(int userId, int groupId) public Task DeleteGroupAsync(int userId, int groupId)
{ {
throw new NotImplementedException(); throw new NotImplementedException();
@@ -90,15 +91,15 @@ namespace IM_API.Services
x => x.Id == groupId) ?? throw new BaseException(CodeDefine.GROUP_NOT_FOUND); x => x.Id == groupId) ?? throw new BaseException(CodeDefine.GROUP_NOT_FOUND);
//过滤非好友 //过滤非好友
var groupInviteIds = await validFriendshipAsync(userId, userIds); var groupInviteIds = await validFriendshipAsync(userId, userIds);
var inviteList = groupInviteIds.Select(id => new GroupInvite var inviteList = groupInviteIds.Select(id => new GroupRequest
{ {
Created = DateTime.UtcNow, Created = DateTime.UtcNow,
GroupId = group.Id, GroupId = group.Id,
InviteUser = userId, UserId = id,
InvitedUser = id, InviteUserId = userId,
StateEnum = GroupInviteState.Pending StateEnum = GroupRequestState.TargetPending
}).ToList(); }).ToList();
_context.GroupInvites.AddRange(inviteList); _context.GroupRequests.AddRange(inviteList);
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
await _endPoint.Publish(new GroupInviteEvent await _endPoint.Publish(new GroupInviteEvent
{ {
@@ -132,7 +133,7 @@ namespace IM_API.Services
throw new NotImplementedException(); throw new NotImplementedException();
} }
public async Task<List<GroupInfoDto>> GetGroupListAsync(int userId, int page, int limit, bool desc) public async Task<List<GroupInfoDto>> GetGroupListAsync(int userId, int page = 1, int limit = 50, bool desc = false)
{ {
var query = _context.GroupMembers var query = _context.GroupMembers
.Where(x => x.UserId == userId) .Where(x => x.UserId == userId)
@@ -159,14 +160,18 @@ namespace IM_API.Services
_context.Groups.Update(group); _context.Groups.Update(group);
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
} }
public async Task HandleGroupInviteAsync(int userid, HandleGroupInviteDto dto) public async Task HandleGroupInviteAsync(int userid, HandleGroupInviteDto dto)
{ {
var user = _userService.GetUserInfoAsync(userid); var user = _userService.GetUserInfoAsync(userid);
var inviteInfo = await _context.GroupInvites.FirstOrDefaultAsync(x => x.Id == dto.InviteId) var inviteInfo = await _context.GroupRequests
.FirstOrDefaultAsync(x => x.UserId == userid && x.StateEnum == GroupRequestState.TargetPending)
?? throw new BaseException(CodeDefine.INVALID_ACTION); ?? throw new BaseException(CodeDefine.INVALID_ACTION);
if (inviteInfo.InvitedUser != userid) throw new BaseException(CodeDefine.AUTH_FAILED); if (!(dto.Action == GroupRequestState.TargetPending ||
dto.Action == GroupRequestState.TargetDeclined))
return;
inviteInfo.StateEnum = dto.Action; inviteInfo.StateEnum = dto.Action;
_context.GroupInvites.Update(inviteInfo); _context.GroupRequests.Update(inviteInfo);
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
await _endPoint.Publish(new GroupInviteActionUpdateEvent await _endPoint.Publish(new GroupInviteActionUpdateEvent
{ {
@@ -176,12 +181,13 @@ namespace IM_API.Services
EventId = Guid.NewGuid(), EventId = Guid.NewGuid(),
GroupId = inviteInfo.GroupId, GroupId = inviteInfo.GroupId,
InviteId = inviteInfo.Id, InviteId = inviteInfo.Id,
InviteUserId = inviteInfo.InviteUser.Value, InviteUserId = inviteInfo.InviteUserId!.Value,
OperatorId = userid, OperatorId = userid,
UserId = userid UserId = userid
}); });
} }
public async Task HandleGroupRequestAsync(int userid, HandleGroupRequestDto dto) public async Task HandleGroupRequestAsync(int userid, HandleGroupRequestDto dto)
{ {
var user = _userService.GetUserInfoAsync(userid); var user = _userService.GetUserInfoAsync(userid);
@@ -302,5 +308,68 @@ namespace IM_API.Services
return _mapper.Map<GroupInfoVo>(groupInfo); return _mapper.Map<GroupInfoVo>(groupInfo);
} }
public async Task<List<GroupNotificationVo>> GetGroupNotificationAsync(int userId)
{
// 1. 查询群请求记录
var groupList = await _context.GroupMembers
.Where(x => x.UserId == userId &&
(x.Role == (sbyte)GroupMemberRole.Master || x.Role == (sbyte)GroupMemberRole.Administrator))
.Select(s => s.GroupId)
.ToListAsync();
var groupRequest = await _context.GroupRequests
.Where(x => groupList.Contains(x.GroupId) || x.UserId == userId || x.InviteUserId == userId)
.OrderByDescending(o => o.Id)
.ToListAsync();
if (!groupRequest.Any()) return new List<GroupNotificationVo>();
// 2. 收集所有需要的 ID 并去重
var userIds = groupRequest.Select(s => s.UserId).Distinct().ToList();
var inviteUserIds = groupRequest.Where(x => x.InviteUserId != null).Select(s => s.InviteUserId.Value).Distinct().ToList();
var groupIds = groupRequest.Select(s => s.GroupId).Distinct().ToList();
var userList = await _userService.GetUserInfoListAsync(userIds);
var inviteUserList = await _userService.GetUserInfoListAsync(inviteUserIds);
var groupInfoList = await _context.Groups
.Where(x => groupIds.Contains(x.Id))
.ToListAsync();
// 2. 转换为字典
var userDict = userList.ToDictionary(u => u.Id);
var inviteUserDict = inviteUserList.ToDictionary(u => u.Id);
var groupDict = groupInfoList.ToDictionary(g => g.Id);
// 3. 组装数据 (Select 逻辑不变)
return groupRequest.Select(g =>
{
var gnv = _mapper.Map<GroupNotificationVo>(g);
// 匹配用户信息
if (userDict.TryGetValue(g.UserId, out var u))
{
gnv.UserAvatar = u.Avatar;
gnv.NickName = u.NickName;
}
// 匹配邀请人信息
if (g.InviteUserId.HasValue && inviteUserDict.TryGetValue(g.InviteUserId.Value, out var i))
{
gnv.InviteUserAvatar = i.Avatar;
gnv.InviteUserNickname = i.NickName;
}
// 匹配群信息
if (groupDict.TryGetValue(g.GroupId, out var gi))
{
gnv.GroupAvatar = gi.Avatar;
gnv.GroupName = gi.Name;
}
return gnv;
}).ToList();
}
} }
} }
@@ -8,6 +8,6 @@ namespace IM_API.VOs.Group
public int InviteUserId { get; set; } public int InviteUserId { get; set; }
public int InvitedUserId { get; set; } public int InvitedUserId { get; set; }
public int InviteId { get; set; } public int InviteId { get; set; }
public GroupInviteState Action { get; set; } public GroupRequestState Action { get; set; }
} }
} }
@@ -0,0 +1,21 @@
using IM_API.Models;
namespace IM_API.VOs.Group
{
public class GroupNotificationVo
{
public int RequestId { get; set; }
public int? UserId { get; set; }
public string? NickName { get; set; }
public string? UserAvatar { get; set; }
public int GroupId { get; set; }
public string? GroupAvatar { get; set; }
public string? GroupName { get; set; }
public GroupRequestState Status { get; set; }
public string Description { get; set; }
public int? InviteUser { get; set; }
public string? InviteUserNickname { get; set; }
public string? InviteUserAvatar { get; set; }
}
}
+4 -4
View File
@@ -1,8 +1,8 @@
# VITE_API_BASE_URL = http://localhost:5202/api VITE_API_BASE_URL = http://localhost:5202/api
# VITE_SIGNALR_BASE_URL = http://localhost:5202/chat/ VITE_SIGNALR_BASE_URL = http://localhost:5202/chat/
# VITE_API_BASE_URL = https://im.test.nxsir.cn/api # VITE_API_BASE_URL = https://im.test.nxsir.cn/api
# VITE_SIGNALR_BASE_URL = https://im.test.nxsir.cn/chat/ # VITE_SIGNALR_BASE_URL = https://im.test.nxsir.cn/chat/
VITE_API_BASE_URL = http://192.168.5.116:7070/api # VITE_API_BASE_URL = http://192.168.5.116:7070/api
VITE_SIGNALR_BASE_URL = http://192.168.5.116:7070/chat/ # VITE_SIGNALR_BASE_URL = http://192.168.5.116:7070/chat/
+8
View File
@@ -16,6 +16,7 @@
"@vuelidate/core": "^2.0.3", "@vuelidate/core": "^2.0.3",
"@vuelidate/validators": "^2.0.4", "@vuelidate/validators": "^2.0.4",
"axios": "^1.13.2", "axios": "^1.13.2",
"crypto": "^1.0.1",
"electron-updater": "^6.3.9", "electron-updater": "^6.3.9",
"feather-icons": "^4.29.2", "feather-icons": "^4.29.2",
"hevue-img-preview": "^7.1.3", "hevue-img-preview": "^7.1.3",
@@ -3993,6 +3994,13 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/crypto": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/crypto/-/crypto-1.0.1.tgz",
"integrity": "sha512-VxBKmeNcqQdiUQUW2Tzq0t377b54N2bMtXO/qiLa+6eRRmmC4qT3D4OnTGoT/U6O9aklQ/jTwbOtRMTTY8G0Ig==",
"deprecated": "This package is no longer supported. It's now a built-in Node module. If you've depended on crypto, you should switch to the one that's built-in.",
"license": "ISC"
},
"node_modules/cssesc": { "node_modules/cssesc": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmmirror.com/cssesc/-/cssesc-3.0.0.tgz", "resolved": "https://registry.npmmirror.com/cssesc/-/cssesc-3.0.0.tgz",
+1
View File
@@ -25,6 +25,7 @@
"@vuelidate/core": "^2.0.3", "@vuelidate/core": "^2.0.3",
"@vuelidate/validators": "^2.0.4", "@vuelidate/validators": "^2.0.4",
"axios": "^1.13.2", "axios": "^1.13.2",
"crypto": "^1.0.1",
"electron-updater": "^6.3.9", "electron-updater": "^6.3.9",
"feather-icons": "^4.29.2", "feather-icons": "^4.29.2",
"hevue-img-preview": "^7.1.3", "hevue-img-preview": "^7.1.3",
+13
View File
@@ -0,0 +1,13 @@
import { app } from "electron";
import path from 'path';
export const CACHE_ROOT = path.join(app.getPath('userData'), 'resource_cache')
export const PROTOCOL_HEAD = 'ql-im://'
export const DIRS = {
Image: 'images',
Video: 'videos',
Voice: 'voices',
File: 'files',
}
+35
View File
@@ -0,0 +1,35 @@
import path from "path";
import { FILE_TYPE } from "../renderer/src/constants/fileTypeDefine";
import { DIRS, CACHE_ROOT, PROTOCOL_HEAD } from "./cacheDir";
import crypto from 'crypto'
import fs from 'fs-extra'
import axios from "axios";
export const getCacheResorce = async (url, type = FILE_TYPE.Image) => {
const hash = crypto.createHash('md5').update(url).digest('hex')
const subDir = hash.substring(0,2);
const targetPath = path.join(DIRS[type], subDir)
const filePath = path.join(targetPath, hash)
if(await fs.pathExists(path.join(CACHE_ROOT, filePath))){
return PROTOCOL_HEAD + filePath.replaceAll('\\', '/')
}
await fs.ensureDir(path.join(CACHE_ROOT, targetPath))
const writer = fs.createWriteStream(path.join(CACHE_ROOT, filePath))
const response = await axios({
url,
method: 'GET',
responseType: 'stream'
})
response.data.pipe(writer)
return new Promise((resolve, reject) => {
writer.on('finish', () => resolve(PROTOCOL_HEAD + filePath.replaceAll('\\', '/')));
writer.on('error', reject);
});
}
+13
View File
@@ -0,0 +1,13 @@
import { net, protocol } from 'electron'
import { CACHE_ROOT } from './cacheDir'
import path from 'path'
export const addProtocolHandler = () => {
protocol.handle('ql-im', (request) => {
const url = request.url.replace('ql-im://', '')
const filePath = path.join(CACHE_ROOT, url.replaceAll('/', '\\'))
return net.fetch(`file://${filePath}`)
})
}
+6 -1
View File
@@ -4,6 +4,8 @@ import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import icon from '../../resources/icon.png?asset' import icon from '../../resources/icon.png?asset'
import { registerWindowHandler } from './ipcHandlers/window' import { registerWindowHandler } from './ipcHandlers/window'
import { createTry } from './trayHandler' import { createTry } from './trayHandler'
import { registerCacheHandler } from './ipcHandlers/cache'
import { addProtocolHandler } from '../cache/protocolReg'
function createWindow() { function createWindow() {
// Create the browser window. // Create the browser window.
@@ -49,6 +51,8 @@ app.whenReady().then(() => {
// Set app user model id for windows // Set app user model id for windows
electronApp.setAppUserModelId('com.electron') electronApp.setAppUserModelId('com.electron')
addProtocolHandler()
// Default open or close DevTools by F12 in development // Default open or close DevTools by F12 in development
// and ignore CommandOrControl + R in production. // and ignore CommandOrControl + R in production.
// see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils // see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
@@ -59,7 +63,8 @@ app.whenReady().then(() => {
// IPC test // IPC test
ipcMain.on('ping', () => console.log('pong')) ipcMain.on('ping', () => console.log('pong'))
registerWindowHandler() registerWindowHandler()
registerCacheHandler()
createWindow() createWindow()
@@ -0,0 +1,8 @@
import { ipcMain } from "electron";
import { getCacheResorce } from "../../cache/cacheHandler";
export function registerCacheHandler(){
ipcMain.handle('cache-get', (event, url, type) => {
return getCacheResorce(url, type)
})
}
+3
View File
@@ -11,6 +11,9 @@ const api = {
isMaximized: () => ipcRenderer.send('window-action', 'isMaximized'), isMaximized: () => ipcRenderer.send('window-action', 'isMaximized'),
newWindow: (route, data) => ipcRenderer.send('window-new', { route, data }), newWindow: (route, data) => ipcRenderer.send('window-new', { route, data }),
getWindowData: (winId) => ipcRenderer.invoke('get-window-data', winId) getWindowData: (winId) => ipcRenderer.invoke('get-window-data', winId)
},
cache: {
getCache: (url, type) => ipcRenderer.invoke('cache-get', url, type)
} }
} }
+3 -3
View File
@@ -5,13 +5,13 @@
<title>Electron</title> <title>Electron</title>
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP --> <!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<meta http-equiv="Content-Security-Policy" <meta http-equiv="Content-Security-Policy"
content="default-src 'self'; content="default-src 'self' http://192.168.5.116:7070 ql-im:;
script-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline';
style-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline';
connect-src 'self' http://localhost:5202 ws://localhost:5202 http://192.168.5.116:7070 ws://192.168.5.116:7070; connect-src 'self' http://localhost:5202 ws://localhost:5202 http://192.168.5.116:7070 ws://192.168.5.116:7070;
img-src 'self' data: blob: https: http:; img-src 'self' data: blob: https: http: ql-im:;
font-src 'self' data:; font-src 'self' data:;
media-src 'self' blob:;"> media-src 'self' blob: http://192.168.5.116:7070; ql-im:">
</head> </head>
<body> <body>
Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

@@ -0,0 +1,40 @@
<template>
<div class="img-container">
<img :src="finalUrl" alt="" v-bind="$attrs" @error="imgLoadErrHandler">
</div>
</template>
<script setup>
import { onMounted, ref } from 'vue';
import { useCacheStore } from '../stores/cache';
import { FILE_TYPE } from '../constants/fileTypeDefine';
import default_avatar from '@/assets/default_avatar.png'
import loading_img from '@/assets/loading_img.png'
const cacheStore = useCacheStore()
const props = defineProps({
rawUrl: {
type: String,
required: true
},
noAvatar: {
type: Boolean,
default: false
}
})
const finalUrl = ref(props.noAvatar ? loading_img : default_avatar)
const imgLoadErrHandler = (e) => {
e.target.src = loading_img
}
onMounted(async () => {
if (!props.rawUrl || props.rawUrl == '') return
finalUrl.value = await cacheStore.getCache(props.rawUrl, FILE_TYPE.Image)
})
</script>
@@ -27,11 +27,13 @@
<script setup> <script setup>
import { ref } from 'vue' import { ref, defineEmits } from 'vue'
import { isElectron } from '../utils/electronHelper' import { isElectron } from '../utils/electronHelper'
const isMaximized = ref(false) const isMaximized = ref(false)
const emits = defineEmits(['close'])
function minimize() { function minimize() {
window.api.window.minimize(); window.api.window.minimize();
} }
@@ -41,6 +43,7 @@ function toggleMaximize() {
} }
function close() { function close() {
window.api.window.close() window.api.window.close()
emits('close')
} }
</script> </script>
@@ -1,12 +1,12 @@
<template> <template>
<WindowControls/> <div></div>
</template> </template>
<script setup> <script setup>
import { previewImages } from 'hevue-img-preview/v3' import { previewImages } from 'hevue-img-preview/v3'
import {onMounted, ref } from 'vue'; import {onMounted, ref } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import WindowControls from '../WindowControls.vue'; // import WindowControls from '../WindowControls.vue';
const route = useRoute(); const route = useRoute();
@@ -0,0 +1,148 @@
<template>
<Teleport to="body">
<div class="video-overlay">
<div class="video-dialog" :style="isElectron() ? 'width:100vw;height:100vh' : ''">
<WindowControls v-if="isElectron()" @close="windowCloseHandler"/>
<div class="close-bar" v-if="!isElectron()">
<span>正在播放视频</span>
<button class="close-btn" @click="webCloseHandler">&times;</button>
</div>
<div class="player-wrapper" :class="{'electron-play-container': isElectron()}">
<vue3-video-player
v-if="videoLoaded"
:src="videoInfo"
poster="https://xxx.jpg"
:controls="true"
:autoplay="true"
/>
</div>
</div>
</div>
</Teleport>
</template>
<script setup>
import { onMounted, ref, defineEmits } from 'vue';
import { useRoute } from 'vue-router';
import { isElectron } from '../../utils/electronHelper';
import WindowControls from '../WindowControls.vue';
const props = defineProps({
videoData: {
type: String
}
})
const emits = defineEmits(['close'])
const route = useRoute();
const videoInfo = ref(null);
const videoLoaded = ref(false)
const winId = ref(null)
const windowCloseHandler = () => {
window.api.window.closeThis()
}
const webCloseHandler = () => {
emits('close')
}
onMounted(async () => {
if (isElectron()) {
winId.value = route.query.winId;
const data = await window.api.window.getWindowData(winId.value);
videoInfo.value = data;
videoLoaded.value = true;
}else{
videoInfo.value = props.videoData
videoLoaded.value = true;
}
});
</script>
<style scoped>
/* 遮罩层:全屏、黑色半透明、固定定位 */
.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;
}
.electron-play-container {
height: calc(100vh - 30px);
}
</style>
@@ -1,14 +1,33 @@
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue'; import { ref, onMounted, defineEmits } from 'vue';
import { useContactStore } from '@/stores/contact'; import { friendService } from '../../services/friend';
import { groupService } from '@/services/group'; import { groupService } from '@/services/group';
import { SYSTEM_BASE_STATUS } from '@/constants/systemBaseStatus'; import { SYSTEM_BASE_STATUS } from '@/constants/systemBaseStatus';
import { useMessage } from '../messages/useAlert'; import { useMessage } from '../messages/useAlert';
const contactStore = useContactStore();
const message = useMessage(); const message = useMessage();
const props = defineProps({ modelValue: Boolean }); const isLoaded = ref(false);
const isError = ref(false)
const props = defineProps({
modelValue: Boolean,
type: {
/**@type {"CreateGroup" | "InviteUser"} */
type: String,
default: 'CreateGroup',
validator: (value) => {
return ['CreateGroup', 'InviteUser'].includes(value)
}
},
title: {
type: String,
default: '创建群聊'
}
});
const emits = defineEmits(['submit'])
const friends = ref([]) const friends = ref([])
@@ -20,21 +39,12 @@ const toggle = (id) => {
}; };
const submit = async () => { const submit = async () => {
const res = await groupService.createGroup({
name: groupName.value,
avatar: "https://baidu.com",
userIDs: [...selected.value]
});
if(res.code == SYSTEM_BASE_STATUS.SUCCESS){ emits('submit', selected.value, groupName.value)
message.show('群聊创建成功。');
}else{
message.error(res.message);
}
}; };
onMounted(async () =>{ onMounted(async () =>{
friends.value = contactStore.contacts; friends.value = (await friendService.getFriendList()).data;
}) })
</script> </script>
@@ -43,12 +53,12 @@ onMounted(async () =>{
<div v-if="modelValue" class="overlay" @click.self="$emit('update:modelValue', false)"> <div v-if="modelValue" class="overlay" @click.self="$emit('update:modelValue', false)">
<div class="mini-modal"> <div class="mini-modal">
<header> <header>
<span>发起群聊</span> <span>{{ props.title }}</span>
<button @click="$emit('update:modelValue', false)"></button> <button @click="$emit('update:modelValue', false)"></button>
</header> </header>
<main> <main>
<input v-model="groupName" placeholder="群组名称..." class="mini-input" /> <input v-if="props.type == 'CreateGroup'" v-model="groupName" placeholder="群组名称..." class="mini-input" />
<div class="list"> <div class="list">
<div v-for="f in friends" :key="f.friendId" @click="toggle(f.friendId)" class="item"> <div v-for="f in friends" :key="f.friendId" @click="toggle(f.friendId)" class="item">
@@ -60,8 +70,8 @@ onMounted(async () =>{
</main> </main>
<footer> <footer>
<button @click="submit" :disabled="!groupName || !selected.size" class="btn"> <button @click="submit" :disabled="(!groupName&& props.type == 'CreateGroup') || !selected.size" class="btn">
创建 ({{ selected.size }}) {{ props.type == 'CreateGroup' ? '创建' : '确定' }} ({{ selected.size }})
</button> </button>
</footer> </footer>
</div> </div>
@@ -19,26 +19,26 @@
<button v-if="isAdmin" class="text-link">编辑</button> <button v-if="isAdmin" class="text-link">编辑</button>
</div> </div>
<div class="announcement-box"> <div class="announcement-box">
{{ groupData.announcement || '暂无群公告,点击编辑添加。' }} {{ groupInfo ? groupInfo.announcement : '暂无群公告,点击编辑添加。' }}
</div> </div>
</section> </section>
<section v-if="chatType == MESSAGE_TYPE.GROUP" class="info-card"> <section v-if="chatType == MESSAGE_TYPE.GROUP" class="info-card">
<div class="section-header"> <div class="section-header">
<h3 class="section-label">群成员 <span class="count-tag">{{ groupData.members?.length || 0 }}</span></h3> <h3 class="section-label">群成员 <span class="count-tag">{{ groupInfo.members?.length || 0 }}</span></h3>
<button class="text-link" @click="$emit('viewAll')">查看全部</button> <button class="text-link" @click="$emit('viewAll')">查看全部</button>
</div> </div>
<div class="member-grid"> <div class="member-grid">
<div class="member-item add-btn"> <div class="member-item add-btn">
<div class="member-avatar-box dashed"> <div class="member-avatar-box dashed" @click="inviteHandler">
<span>+</span> <span>+</span>
</div> </div>
<span class="member-nick">邀请</span> <span class="member-nick">邀请</span>
</div> </div>
<div <div
v-for="member in groupData.members?.slice(0, 11)" v-for="member in groupInfo.members?.slice(0, 11)"
:key="member.id" :key="member.id"
class="member-item" class="member-item"
> >
@@ -69,12 +69,13 @@
<button class="danger-btn">删除并退出</button> <button class="danger-btn">删除并退出</button>
</div> </div>
</div> </div>
<create-group v-model="groupInviteModal" type="InviteUser" title="邀请好友" @submit="inviteUserHandler"/>
</aside> </aside>
</transition> </transition>
</template> </template>
<script setup> <script setup>
import { computed, useTemplateRef } from 'vue'; import { computed, onMounted, ref, useTemplateRef, watch } from 'vue';
import { MESSAGE_TYPE } from '../../constants/MessageType'; import { MESSAGE_TYPE } from '../../constants/MessageType';
import feather from 'feather-icons'; import feather from 'feather-icons';
import { GROUP_MEMBER_ROLE } from '../../constants/GroupDefine'; import { GROUP_MEMBER_ROLE } from '../../constants/GroupDefine';
@@ -83,6 +84,7 @@ import { groupService } from '../../services/group';
import { SYSTEM_BASE_STATUS } from '../../constants/systemBaseStatus'; import { SYSTEM_BASE_STATUS } from '../../constants/systemBaseStatus';
import { useMessage } from './useAlert'; import { useMessage } from './useAlert';
import { getFileHash } from '../../utils/uploadTools'; import { getFileHash } from '../../utils/uploadTools';
import CreateGroup from '../groups/CreateGroup.vue';
const props = defineProps({ const props = defineProps({
chatType: { chatType: {
@@ -111,6 +113,12 @@ const input = useTemplateRef('input')
const message = useMessage(); const message = useMessage();
const groupInfo = ref({
members: []
})
const groupInviteModal = ref(false)
defineEmits(['close', 'viewAll']); defineEmits(['close', 'viewAll']);
const uploadGroupAvatar = () => { const uploadGroupAvatar = () => {
@@ -119,7 +127,7 @@ const uploadGroupAvatar = () => {
const fileUploadHandler = async (e) => { const fileUploadHandler = async (e) => {
const file = e.target.files[0]; const file = e.target.files[0];
const hash = getFileHash(file) const hash = await getFileHash(file)
const { data } = await uploadService.uploadSmallFile(file, hash); const { data } = await uploadService.uploadSmallFile(file, hash);
const res = await groupService.updateGroupInfo(props.groupData.targetId, { const res = await groupService.updateGroupInfo(props.groupData.targetId, {
avatar: data.url avatar: data.url
@@ -132,11 +140,38 @@ const fileUploadHandler = async (e) => {
} }
} }
const inviteHandler = () => {
groupInviteModal.value = true
}
const inviteUserHandler = async (selectedUsers) => {
const res = await groupService.inviteUser(props.groupData.targetId, [...selectedUsers])
if (res.code != SYSTEM_BASE_STATUS.SUCCESS) return message.error(res.message)
message.success('成功')
}
// 判断当前用户是否为管理员 // 判断当前用户是否为管理员
const isAdmin = computed(() => { const isAdmin = computed(() => {
// 逻辑:在 members 数组中找到当前用户并检查 role // 逻辑:在 members 数组中找到当前用户并检查 role
return true; // 演示用,默认 true return true; // 演示用,默认 true
}); });
watch(
() => props.groupData.id,
async (newVal, oldVal) => {
if (props.chatType == MESSAGE_TYPE.GROUP && newVal != oldVal) {
groupInfo.value = (await groupService.getGroupInfo(props.groupData.targetId)).data
groupInfo.value.members = (await groupService.getGroupMember(props.groupData.targetId)).data
}
},
{ immediate: true }
)
onMounted(async () => {
})
</script> </script>
<style scoped> <style scoped>
@@ -148,7 +183,7 @@ const isAdmin = computed(() => {
right: 0; right: 0;
width: 320px; width: 320px;
background-color: #f5f5f5; /* 背景色改为浅灰,突出白色卡片 */ background-color: #f5f5f5; /* 背景色改为浅灰,突出白色卡片 */
z-index: 1000; z-index: 100;
box-shadow: -8px 0 24px rgba(0, 0, 0, 0.05); box-shadow: -8px 0 24px rgba(0, 0, 0, 0.05);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -3,3 +3,19 @@ export const GROUP_MEMBER_ROLE = Object.freeze({
ADMIN: 'Administrator', ADMIN: 'Administrator',
MASTER: 'Master' MASTER: 'Master'
}) })
/**
* 群请求状态枚举 (对应后端 String 输出)
*/
export const GROUP_REQUEST_STATUS = Object.freeze({
/** 待管理员处理 */
PENDING: 'Pending',
/** 管理员已拒绝 */
DECLINED: 'Declined',
/** 管理员已同意 */
PASSED: 'Passed',
/** 待对方同意 */
TARGET_PENDING: 'TargetPending',
/** 对方拒绝 */
TARGET_DECLINED: 'TargetDeclined'
});
@@ -36,5 +36,6 @@ export const FILE_TYPE = Object.freeze({
Image: 'Image', Image: 'Image',
Video: 'Video', Video: 'Video',
Voice: 'Voice', Voice: 'Voice',
File: 'File' File: 'File',
TEXT: 'Text'
}); });
@@ -0,0 +1,10 @@
export const GROUP_REQUEST_TYPE = Object.freeze({
//**邀请对方 */
INVITE: 'invite',
/**被邀请 */
INVITED: 'invited',
/**入群请求 */
IS_GROUP: 'is-group',
//**我的申请入群 */
IS_USER: 'is-user',
})
@@ -57,6 +57,11 @@ const routes = [
path: '/contacts/requests', path: '/contacts/requests',
name: 'friendRequests', name: 'friendRequests',
component: () => import('@/views/contact/FriendRequestList.vue') component: () => import('@/views/contact/FriendRequestList.vue')
},
{
path: '/contacts/grouphandle',
name: 'grouphandle',
component: () => import('@/views/contact/GroupRequest.vue')
} }
] ]
}, },
@@ -72,6 +77,9 @@ const routes = [
{ path: '/test', component: TestView }, { path: '/test', component: TestView },
{ {
path: '/imgpre', component: () => import('@/components/electron/ImagePreview.vue') path: '/imgpre', component: () => import('@/components/electron/ImagePreview.vue')
},
{
path: '/videopre', component: () => import('@/components/electron/VideoPreview.vue')
} }
] ]
@@ -21,5 +21,26 @@ export const groupService = {
* @returns * @returns
*/ */
updateGroupInfo: (groupId, params) => request.post(`/Group/UpdateGroup?groupId=${groupId}`, params) updateGroupInfo: (groupId, params) => request.post(`/Group/UpdateGroup?groupId=${groupId}`, params),
/**
* 查询群组信息
* @param {*} groupId
* @returns
*/
getGroupInfo: (groupId) => request.get(`/Group/GetGroupInfo?groupId=${groupId}`),
/**
* 邀请入群
* @param {*} groupId
* @param {*} users
* @returns
*/
inviteUser: (groupId, users) =>
request.post('/Group/InviteUser', { groupId: groupId, ids: users }),
/**
* 获取群聊通知
* @returns
*/
getGroupNotification: () => request.get('/Group/GetGroupNotification')
} }
@@ -0,0 +1,29 @@
import { defineStore } from 'pinia'
import { isElectron } from '../utils/electronHelper'
export const useCacheStore = defineStore('cache', {
state: () => ({
cacheMap: new Map()
}),
actions: {
/**
* 获取文件地址
* @param {String} url 网络路径
* @param {String} type 文件类型
* @returns {Promise} 本地路径
*/
async getCache(url, type) {
if (!isElectron()) return url
if (this.cacheMap.has(url)) {
return this.cacheMap.get(url)
}
const localPath = await window.api.cache.getCache(url, type)
this.cacheMap.set(url, localPath)
return localPath
}
}
})
@@ -1,7 +1,6 @@
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { messagesDb } from "@/utils/db/messageDB"; import { messagesDb } from "@/utils/db/messageDB";
import { messageService } from "@/services/message"; import { messageService } from "@/services/message";
import { useConversationStore } from "./conversation";
export const useChatStore = defineStore('chat', { export const useChatStore = defineStore('chat', {
state: () => ({ state: () => ({
@@ -0,0 +1,27 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { groupService } from '../services/group'
import { useMessage } from '../components/messages/useAlert'
import { SYSTEM_BASE_STATUS } from '../constants/systemBaseStatus'
import { groupRequestDb } from '../utils/db/groupRequestDb'
export const useGroupRequestStore = defineStore('groupRequest', {
state: () => ({
groupRequest: []
}),
actions: {
async loadGroupRequest() {
this.groupRequest = await groupRequestDb.getAll()
const message = useMessage()
const res = await groupService.getGroupNotification()
if (res.code != SYSTEM_BASE_STATUS.SUCCESS) return message.error(res.message)
this.groupRequest = res.data
res.data.forEach((element) => {
groupRequestDb.save(element)
})
}
}
})
@@ -1,29 +1,36 @@
import { openDB } from "idb"; import { openDB } from 'idb'
const DBNAME = 'IM_DB'; const DBNAME = 'IM_DB'
const STORE_NAME = 'messages'; const STORE_NAME = 'messages'
const CONVERSARION_STORE_NAME = 'conversations'; const CONVERSARION_STORE_NAME = 'conversations'
const CONTACT_STORE_NAME = 'contacts'; const CONTACT_STORE_NAME = 'contacts'
const GROUP_REQUEST_STORE_NAME = 'groupRequests'
export const dbPromise = openDB(DBNAME, 7, { export const dbPromise = openDB(DBNAME, 7, {
upgrade(db) { upgrade(db) {
if (!db.objectStoreNames.contains(STORE_NAME)) { if (!db.objectStoreNames.contains(STORE_NAME)) {
const store = db.createObjectStore(STORE_NAME, { keyPath: 'msgId' }); const store = db.createObjectStore(STORE_NAME, { keyPath: 'msgId' })
store.createIndex('by-sessionId', 'sessionId'); store.createIndex('by-sessionId', 'sessionId')
store.createIndex('by-time', 'timeStamp'); store.createIndex('by-time', 'timeStamp')
store.createIndex('by-sequenceId', 'sequenceId'); store.createIndex('by-sequenceId', 'sequenceId')
store.createIndex('by-session-sequenceId', ['sessionId', 'sequenceId']); store.createIndex('by-session-sequenceId', ['sessionId', 'sequenceId'])
}
if (!db.objectStoreNames.contains(CONVERSARION_STORE_NAME)) {
const store = db.createObjectStore(CONVERSARION_STORE_NAME, { keyPath: 'id' });
store.createIndex('by-id', 'id');
}
if (!db.objectStoreNames.contains(CONTACT_STORE_NAME)) {
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 });
}
} }
if (!db.objectStoreNames.contains(CONVERSARION_STORE_NAME)) {
const store = db.createObjectStore(CONVERSARION_STORE_NAME, { keyPath: 'id' })
store.createIndex('by-id', 'id')
}
if (!db.objectStoreNames.contains(CONTACT_STORE_NAME)) {
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 })
}
if (!db.objectStoreNames.contains(GROUP_REQUEST_STORE_NAME)) {
const store = db.createObjectStore(GROUP_REQUEST_STORE_NAME, { keyPath: 'requestId' })
store.createIndex('by-id', 'requestId')
store.createIndex('by-userid', 'userId')
store.createIndex('by-groupid', 'groupId')
}
}
}) })
@@ -0,0 +1,12 @@
import { dbPromise } from "./baseDb";
const STORE_NAME = 'groupRequests'
export const groupRequestDb = {
async save(request){
(await dbPromise).put(STORE_NAME, request)
},
async getAll(){
(await dbPromise).getAll(STORE_NAME)
}
}
@@ -2,7 +2,8 @@
<div class="im-container"> <div class="im-container">
<nav class="nav-sidebar"> <nav class="nav-sidebar">
<div class="user-self"> <div class="user-self">
<img :src="myInfo?.avatar ?? defaultAvatar" class="avatar-std" /> <async-image :raw-url="myInfo?.avatar" class="avatar-std"/>
<!-- <img :src="myInfo?.avatar ?? defaultAvatar" class="avatar-std" /> -->
</div> </div>
<router-link class="nav-item" to="/messages" active-class="active"> <router-link class="nav-item" to="/messages" active-class="active">
<i class="menuIcon" v-html="feather.icons['message-square'].toSvg()"></i> <i class="menuIcon" v-html="feather.icons['message-square'].toSvg()"></i>
@@ -20,11 +21,11 @@
</template> </template>
<script setup> <script setup>
import { ref, watch, onMounted } from 'vue' import { watch, onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import defaultAvatar from '@/assets/default_avatar.png'
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import feather from 'feather-icons'; import feather from 'feather-icons';
import AsyncImage from '../components/AsyncImage.vue';
const router = useRouter(); const router = useRouter();
const authStore = useAuthStore(); const authStore = useAuthStore();
@@ -195,7 +196,7 @@ onMounted(async () => {
} }
/* 头像样式统一 */ /* 头像样式统一 */
.avatar-std { width: 40px; height: 40px; border-radius: 4px; object-fit: cover; } :deep(.avatar-std) { width: 40px; height: 40px; border-radius: 4px; object-fit: cover; }
.avatar-chat { width: 38px; height: 38px; border-radius: 4px; object-fit: cover; flex-shrink: 0; } .avatar-chat { width: 38px; height: 38px; border-radius: 4px; object-fit: cover; flex-shrink: 0; }
/* 未读气泡 */ /* 未读气泡 */
+12 -192
View File
@@ -1,206 +1,26 @@
<template> <template>
<Teleport to="body">
<div v-if="visible" class="mask" @click.self="close">
<!-- 工具栏 --> <AsyncImage raw-url="http://192.168.5.116:7070/uploads/files/IM/2026/03/2/e6c407f60c68.jpg" :type="FILE_TYPE.Image"/>
<div class="toolbar" @click.stop> <button @click="test">click</button>
<button @click="prev" title="上一张" v-html="feather.icons['arrow-left'].toSvg({width:15,height15})"></button>
<button @click="next" title="下一张"></button>
<button @click="zoomOut" title="缩小"></button>
<button @click="zoomIn" title="放大"></button>
<button @click="rotate" title="旋转"></button>
<button @click="download" title="下载"></button>
<button @click="close" title="关闭"></button>
</div>
<!-- 图片 -->
<img
:src="current"
class="img"
:style="style"
@mousedown="onDown"
@wheel.prevent="onWheel"
@dblclick="toggleZoom"
draggable="false"
/>
<!-- 索引 -->
<div class="indicator">{{ index + 1 }} / {{ list.length }}</div>
<!-- 左右翻页大按钮 -->
<div class="nav left" @click.stop="prev"></div>
<div class="nav right" @click.stop="next"></div>
</div>
</Teleport>
</template> </template>
<script setup> <script setup>
import { ref, computed, watch, onMounted, onUnmounted } from 'vue' import { ref } from 'vue';
import feather from 'feather-icons' import { useCacheStore } from '../stores/cache';
import { FILE_TYPE } from '../constants/fileTypeDefine';
import AsyncImage from '../components/AsyncImage.vue';
const props = defineProps({
modelValue: Boolean,
list: { type: Array, default: ['http://localhost:5202/uploads/files/IM/2026/02/2/b92f0a4ba0f0.jpg', 'http://localhost:5202/uploads/files/IM/2026/02/2/b92f0a4ba0f0.jpg'] },
start: { type: Number, default: 0 }
})
const emit = defineEmits(['update:modelValue']) const url = ref('')
const visible = ref(true) const test = async () => {
const index = ref(0)
const scale = ref(1)
const rotateDeg = ref(0)
const offset = ref({ x: 0, y: 0 })
let dragging = false const cacheStore = useCacheStore();
let startPos = { x: 0, y: 0 }
watch(() => props.modelValue, v => { const localPath = await cacheStore.getCache('http://192.168.5.116:7070/uploads/files/IM/2026/03/2/e6c407f60c68.jpg', FILE_TYPE.Image)
visible.value = v console.log(localPath)
if (v) { url.value = localPath
index.value = props.start
reset()
}
})
const current = computed(() => props.list[index.value] || '')
const style = computed(() => ({
transform: `translate(${offset.value.x}px, ${offset.value.y}px) scale(${scale.value}) rotate(${rotateDeg.value}deg)`
}))
function reset() {
scale.value = 1
rotateDeg.value = 0
offset.value = { x: 0, y: 0 }
} }
function close() { emit('update:modelValue', false) }
function prev() { if(index.value>0){ index.value--; reset() } }
function next() { if(index.value<props.list.length-1){ index.value++; reset() } }
function zoomIn() { scale.value = Math.min(scale.value + 0.2, 5) }
function zoomOut() { scale.value = Math.max(scale.value - 0.2, 0.3) }
function toggleZoom() { scale.value = scale.value === 1 ? 2 : 1 }
function rotate() { rotateDeg.value = (rotateDeg.value + 90) % 360 }
function onWheel(e){
if(e.ctrlKey){ e.deltaY>0?zoomOut():zoomIn() }
else { e.deltaY>0?next():prev() }
}
function onDown(e){
dragging = true
startPos = { x: e.clientX - offset.value.x, y: e.clientY - offset.value.y }
window.addEventListener('mousemove', onMove)
window.addEventListener('mouseup', onUp)
}
function onMove(e){ if(!dragging) return; offset.value={ x:e.clientX-startPos.x, y:e.clientY-startPos.y } }
function onUp(){ dragging=false; window.removeEventListener('mousemove',onMove); window.removeEventListener('mouseup',onUp) }
async function download(){
try{
const res = await fetch(current.value)
const blob = await res.blob()
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = current.value.split('/').pop()
a.click()
URL.revokeObjectURL(a.href)
}catch(e){ console.error(e) }
}
function onKey(e){
if(!visible.value) return
if(e.key==='Escape') close()
if(e.key==='ArrowLeft') prev()
if(e.key==='ArrowRight') next()
if(e.key==='ArrowUp') zoomIn()
if(e.key==='ArrowDown') zoomOut()
if(e.key==='r'||e.key==='R') rotate()
}
onMounted(()=> window.addEventListener('keydown',onKey))
onUnmounted(()=> window.removeEventListener('keydown',onKey))
</script> </script>
<style scoped>
.mask {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.95);
z-index: 9999;
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
}
/* 图片 */
.img {
max-width: 90vw;
max-height: 90vh;
cursor: grab;
user-select: none;
transition: transform 0.15s ease;
}
/* 工具栏 */
.toolbar {
position: absolute;
top: 24px;
display: flex;
gap: 14px;
padding: 12px 16px;
background: rgba(20,20,20,0.85);
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
}
.toolbar button {
width: 48px;
height: 48px;
font-size: 22px;
border-radius: 10px;
background: rgba(255,255,255,0.08);
color: #fff;
border: none;
cursor: pointer;
transition: all .15s ease;
}
.toolbar button:hover { background: rgba(255,255,255,.2); transform: scale(1.05);}
.toolbar button:active { transform: scale(0.95); }
/* 左右翻页按钮 */
.nav {
position: absolute;
top: 50%;
width: 64px;
height: 64px;
margin-top: -32px;
border-radius: 50%;
background: rgba(0,0,0,.6);
color: #fff;
font-size: 36px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
user-select: none;
}
.nav.left { left: 32px; }
.nav.right { right: 32px; }
.nav:hover { background: rgba(255,255,255,.2); }
/* 底部索引 */
.indicator {
position: absolute;
bottom: 28px;
font-size: 16px;
color: #ddd;
background: rgba(0,0,0,.5);
padding: 6px 12px;
border-radius: 20px;
}
</style>
@@ -14,10 +14,10 @@
<div class="icon-box orange" v-html="feather.icons['user-plus'].toSvg()"></div> <div class="icon-box orange" v-html="feather.icons['user-plus'].toSvg()"></div>
<div class="name">新的朋友</div> <div class="name">新的朋友</div>
</RouterLink> </RouterLink>
<div class="list-item mini" @click="showGroupList"> <RouterLink class="list-item mini" to="/contacts/grouphandle">
<div class="icon-box green" v-html="feather.icons['users'].toSvg()"></div> <div class="icon-box green" v-html="feather.icons['users'].toSvg()"></div>
<div class="name">群聊</div> <div class="name">群聊通知</div>
</div> </RouterLink>
<div class="list-item mini"> <div class="list-item mini">
<div class="icon-box blue" v-html="feather.icons['tag'].toSvg()"></div> <div class="icon-box blue" v-html="feather.icons['tag'].toSvg()"></div>
<div class="name">标签</div> <div class="name">标签</div>
@@ -104,9 +104,8 @@ const filteredContacts = computed(() => {
// 发送事件给父组件(用于切换回聊天Tab并打开会话) // 发送事件给父组件(用于切换回聊天Tab并打开会话)
const emit = defineEmits(['start-chat']) const emit = defineEmits(['start-chat'])
const showGroupList = () => { // const showGroupList = () => {
groupModal.value = true; // }
}
@@ -1,6 +1,10 @@
<template> <template>
<div class="minimal-page"> <div class="minimal-page">
<div class="content-limit"> <WindowControls/>
<div class="request-container">
<div class="content-limit">
<div class="section-title">申请列表</div> <div class="section-title">申请列表</div>
<div class="request-group"> <div class="request-group">
@@ -50,6 +54,9 @@
</div> </div>
</div> </div>
</div> </div>
</div>
</div> </div>
</template> </template>
@@ -61,6 +68,7 @@ import { formatDate } from '@/utils/formatDate';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import { FRIEND_ACTIONS, FRIEND_REQUEST_STATUS } from '@/constants/friendAction'; import { FRIEND_ACTIONS, FRIEND_REQUEST_STATUS } from '@/constants/friendAction';
import { SYSTEM_BASE_STATUS } from '@/constants/systemBaseStatus'; import { SYSTEM_BASE_STATUS } from '@/constants/systemBaseStatus';
import WindowControls from '../../components/WindowControls.vue';
const message = useMessage(); const message = useMessage();
const authStore = useAuthStore(); const authStore = useAuthStore();
@@ -132,6 +140,11 @@ onMounted(async () => {
width: 100%; width: 100%;
height: 100%; height: 100%;
background-color: #f5f5f5; /* 极致白 */ background-color: #f5f5f5; /* 极致白 */
}
.request-container {
width: 100%;
height: 100%;
background-color: #f5f5f5; /* 极致白 */
display: flex; display: flex;
justify-content: center; justify-content: center;
overflow-y: auto; overflow-y: auto;
@@ -0,0 +1,289 @@
<template>
<div class="minimal-page">
<WindowControls />
<div class="request-container">
<div class="content-limit">
<div class="section-title">群聊通知</div>
<div class="request-group">
<div v-for="item in groupRequestStore.groupRequest" :key="item.requestId" class="minimal-item">
<div class="avatar-wrapper">
<img :src="item.avatar" :class="['avatar', item.type === 'invite' ? 'is-group' : 'is-user']" />
</div>
<div class="info">
<div class="title-row">
<span class="name">{{ item.name }}</span>
<span :class="['type-tag', item.type === 'apply' ? 'tag-orange' : 'tag-green']">
{{ item.type === 'apply' ? '申请加入' : '邀请你加入' }}
</span>
<span class="date">14:20</span>
</div>
<div class="group-info">
<span class="label">目标群聊</span>
<span class="group-name">微信产品经理交流群</span>
</div>
<p class="sub-text">{{ item.desc }}</p>
</div>
<div class="actions">
<button class="btn-text btn-reject">忽略</button>
<button class="btn-text btn-accept">去处理</button>
</div>
</div>
</div>
<div class="footer-hint">仅保留最近 30 天的通知记录</div>
</div>
</div>
</div>
</template>
<script setup>
import { onMounted } from 'vue';
import WindowControls from '../../components/WindowControls.vue';
import { useGroupRequestStore } from '../../stores/groupRequest';
import { useAuthStore } from '../../stores/auth';
import { GROUP_REQUEST_TYPE } from '../../constants/groupRequestTypeDefine';
import { GROUP_REQUEST_STATUS } from '../../constants/GroupDefine';
const groupRequestStore = useGroupRequestStore()
const myInfo = useAuthStore().userInfo
// 纯样式模拟数据
const mockRequests = [
{ id: 1, name: '张小龙', type: 'apply', desc: '久仰大名,请求入群交流产品心法。', avatar: 'https://i.pravatar.cc/100?img=1' },
{ id: 2, name: '李彦宏', type: 'invite', desc: '诚邀您加入百度大模型技术研讨组。', avatar: 'https://i.pravatar.cc/100?img=2' },
{ id: 3, name: '马斯克', type: 'apply', desc: 'I want to talk about Mars.', avatar: 'https://i.pravatar.cc/100?img=3' }
];
const getRequestType = (request) => {
if (
request.inviteUser &&
request.inviteUser == myInfo.id &&
[GROUP_REQUEST_STATUS.TARGET_DECLINED, GROUP_REQUEST_STATUS.TARGET_PENDING].includes(
request.status
)
) {
return GROUP_REQUEST_TYPE.INVITE;
}
if (
request.inviteUser &&
request.userId == myInfo.id &&
[GROUP_REQUEST_STATUS.TARGET_DECLINED, GROUP_REQUEST_STATUS.TARGET_PENDING].includes(
request.status
)
) {
return GROUP_REQUEST_TYPE.INVITED;
}
if (request.userId == myInfo.id) {
return GROUP_REQUEST_TYPE.IS_USER;
}
if (request.inviteUser != myInfo.id && request.userId != myInfo.id) {
return GROUP_REQUEST_TYPE.IS_GROUP;
}
}
onMounted(async () => {
await groupRequestStore.loadGroupRequest()
console.log(groupRequestStore.groupRequest)
})
</script>
<style scoped>
/* 容器基础环境 */
.minimal-page {
width: 100%;
height: 100vh;
background-color: #f5f5f7;
/* Apple 官网经典的背景灰 */
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
.request-container {
display: flex;
justify-content: center;
padding-top: 40px;
}
.content-limit {
width: 100%;
max-width: 600px;
padding: 0 20px;
}
.section-title {
font-size: 12px;
font-weight: 600;
color: #86868b;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 24px;
padding-left: 10px;
}
/* 列表项设计 */
.minimal-item {
display: flex;
background: #ffffff;
padding: 20px;
border-radius: 18px;
/* 较圆润的倒角 */
margin-bottom: 12px;
transition: transform 0.2s ease;
border: 1px solid rgba(0, 0, 0, 0.02);
}
.minimal-item:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.04);
}
/* 头像差异化处理 */
.avatar-wrapper {
margin-right: 16px;
}
.avatar {
width: 52px;
height: 52px;
object-fit: cover;
background: #f2f2f7;
}
.is-user {
border-radius: 50%;
}
/* 用户是圆的 */
.is-group {
border-radius: 12px;
}
/* 群组是方圆的 */
/* 信息排版 */
.info {
flex: 1;
min-width: 0;
}
.title-row {
display: flex;
align-items: center;
margin-bottom: 6px;
}
.name {
font-size: 16px;
font-weight: 600;
color: #1d1d1f;
margin-right: 8px;
}
/* 状态标签 */
.type-tag {
font-size: 10px;
font-weight: 600;
padding: 2px 8px;
border-radius: 6px;
}
.tag-orange {
background: #fff4e5;
color: #ff9500;
}
.tag-green {
background: #e8f7ed;
color: #34c759;
}
.date {
font-size: 11px;
color: #c1c1c6;
margin-left: auto;
}
/* 关键信息:群聊名称展示 */
.group-info {
margin-bottom: 6px;
}
.label {
font-size: 12px;
color: #86868b;
}
.group-name {
font-size: 13px;
font-weight: 500;
color: #007aff;
/* 链接蓝,暗示可点击 */
}
.sub-text {
font-size: 13px;
color: #86868b;
line-height: 1.5;
margin: 0;
/* 文字截断 */
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* 按钮样式:去掉了生硬的边框,采用色块感 */
.actions {
display: flex;
flex-direction: column;
/* 垂直排列,更具操作仪式感 */
justify-content: center;
gap: 8px;
margin-left: 20px;
}
.btn-text {
padding: 8px 16px;
border-radius: 10px;
font-size: 13px;
font-weight: 600;
border: none;
cursor: pointer;
white-space: nowrap;
transition: all 0.2s;
}
.btn-accept {
background-color: #007aff;
color: #ffffff;
}
.btn-accept:hover {
background-color: #0063cc;
}
.btn-reject {
background-color: #f2f2f7;
color: #86868b;
}
.btn-reject:hover {
background-color: #e5e5ea;
color: #1d1d1f;
}
.footer-hint {
text-align: center;
font-size: 12px;
color: #c1c1c6;
margin-top: 32px;
}
</style>
@@ -19,7 +19,8 @@
<div v-for="s in filteredSessions" :key="s.id" <div v-for="s in filteredSessions" :key="s.id"
class="list-item" :class="{active: activeId == s.id}" @click="selectSession(s)"> class="list-item" :class="{active: activeId == s.id}" @click="selectSession(s)">
<div class="avatar-container"> <div class="avatar-container">
<img :src="s.targetAvatar ? s.targetAvatar : defaultAvatar" class="avatar-std" /> <AsyncImage :raw-url="s.targetAvatar" class="avatar-std"/>
<!-- <img :src="s.targetAvatar ? s.targetAvatar : defaultAvatar" class="avatar-std" /> -->
<span v-if="s.unreadCount > 0" class="unread-badge">{{ s.unreadCount ?? 0 }}</span> <span v-if="s.unreadCount > 0" class="unread-badge">{{ s.unreadCount ?? 0 }}</span>
</div> </div>
<div class="info"> <div class="info">
@@ -35,7 +36,7 @@
<RouterView></RouterView> <RouterView></RouterView>
<SearchUser v-model="searchUserModal"/> <SearchUser v-model="searchUserModal"/>
<CreateGroup v-model="createGroupModal"></CreateGroup> <CreateGroup v-model="createGroupModal" @submit="createGroupSubmitHandler"></CreateGroup>
</div> </div>
</template> </template>
@@ -51,10 +52,17 @@ import SearchUser from '@/components/user/SearchUser.vue'
import CreateGroup from '@/components/groups/CreateGroup.vue' import CreateGroup from '@/components/groups/CreateGroup.vue'
import { useBrowserNotification } from '@/services/useBrowserNotification' import { useBrowserNotification } from '@/services/useBrowserNotification'
import { useChatStore } from '@/stores/chat' import { useChatStore } from '@/stores/chat'
import { groupService } from '../../services/group'
import { SYSTEM_BASE_STATUS } from '../../constants/systemBaseStatus'
import { useMessage } from '../../components/messages/useAlert'
import { useCacheStore } from '../../stores/cache'
import AsyncImage from '../../components/AsyncImage.vue'
const conversationStore = useConversationStore(); const conversationStore = useConversationStore();
const router = useRouter(); const router = useRouter();
const browserNotification = useBrowserNotification(); const browserNotification = useBrowserNotification();
const message = useMessage()
const cacheStore = useCacheStore()
const searchQuery = ref('') const searchQuery = ref('')
const activeId = ref(0) const activeId = ref(0)
@@ -82,6 +90,25 @@ const addMenuList = [
} }
]; ];
const createGroupSubmitHandler = async (selectedUsers, groupName) => {
const res = await groupService.createGroup({
name: groupName,
avatar: "http://192.168.5.116:7070/uploads/files/IM/2026/03/2/bf1a0f691220.jpg",
userIDs: [...selectedUsers]
})
if(res.code == SYSTEM_BASE_STATUS.SUCCESS){
message.success('群聊创建成功')
}else{
message.error(res.message)
}
}
const urlHandler = async () => {
}
const filteredSessions = computed(() => conversationStore.sortedConversations.filter(s => s.targetName.includes(searchQuery.value))) const filteredSessions = computed(() => conversationStore.sortedConversations.filter(s => s.targetName.includes(searchQuery.value)))
function selectSession(s) { function selectSession(s) {
@@ -272,7 +299,7 @@ onMounted(async () => {
} }
/* 头像样式统一 */ /* 头像样式统一 */
.avatar-std { width: 40px; height: 40px; border-radius: 4px; object-fit: cover; } :deep(.avatar-std) { width: 40px; height: 40px; border-radius: 4px; object-fit: cover; }
.avatar-chat { width: 38px; height: 38px; border-radius: 4px; object-fit: cover; flex-shrink: 0; } .avatar-chat { width: 38px; height: 38px; border-radius: 4px; object-fit: cover; flex-shrink: 0; }
/* 未读气泡 */ /* 未读气泡 */
@@ -18,29 +18,19 @@
@retry="loadHistoryMsg" /> @retry="loadHistoryMsg" />
<UserHoverCard ref="userHoverCardRef" /> <UserHoverCard ref="userHoverCardRef" />
<ContextMenu ref="menuRef" /> <ContextMenu ref="menuRef" />
<Teleport to="body">
<Transition name="fade">
<div v-if="videoOpen" class="video-overlay" @click.self="videoOpen = false">
<div class="video-dialog"> <VideoPreview v-if="videoOpen" :videoData="videoUrl" @close="videoClose" />
<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']"> <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" <!-- <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" :src="(m.senderId == myInfo.id ? myInfo?.avatar : m.chatType == MESSAGE_TYPE.GROUP ? m.senderAvatar : conversationInfo?.targetAvatar)"
class="avatar-chat" /> class="avatar-chat" /> -->
<AsyncImage :raw-url="m.senderId == myInfo.id
? myInfo?.avatar
: m.chatType == MESSAGE_TYPE.GROUP
? m.senderAvatar
: conversationInfo?.targetAvatar
" @mouseenter="(e) => handleHoverCard(e, m)" class="avatar-chat" @mouseleave="closeHoverCard" />
<div class="msg-content"> <div class="msg-content">
<div class="group-sendername" v-if="m.chatType == MESSAGE_TYPE.GROUP && m.senderId != myInfo.id">{{ <div class="group-sendername" v-if="m.chatType == MESSAGE_TYPE.GROUP && m.senderId != myInfo.id">{{
@@ -50,8 +40,11 @@
<div v-if="m.type === 'Text'">{{ m.content }}</div> <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 === 'emoji'" class="emoji-msg">{{ m.content }}</div>
<div v-else-if="m.type === FILE_TYPE.Image" class="image-msg-container" :style="getImageStyle(m.content)"> <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" <!-- <img class="image-msg-content" :src="m.isImgLoading || m.isLoading ? m.localUrl : m.content.thumb"
alt="图片消息" @click="imagePreview(m)"> alt="图片消息" @click="imagePreview(m)"> -->
<AsyncImage class="image-msg-content" :noAvatar="true"
:rawUrl="m.isImgLoading || m.isLoading ? m.localUrl : m.content.thumb" alt="图片消息"
@click="imagePreview($event, m)" />
<div v-if="m.isImgLoading || m.isError" class="image-overlay"> <div v-if="m.isImgLoading || m.isError" class="image-overlay">
<div v-if="m.isImgLoading" class="progress-box"> <div v-if="m.isImgLoading" class="progress-box">
@@ -89,7 +82,8 @@
<footer class="chat-footer"> <footer class="chat-footer">
<div class="toolbar"> <div class="toolbar">
<button class="tool-btn" @click="toggleEmoji" v-html="feather.icons['smile'].toSvg({ width: 25, height: 25 })"> <button class="tool-btn" @click="toggleEmoji"
v-html="feather.icons['smile'].toSvg({ width: 25, height: 25 })">
</button> </button>
<label class="tool-btn"> <label class="tool-btn">
<i v-html="feather.icons['file'].toSvg({ width: 25, height: 25 })"></i> <i v-html="feather.icons['file'].toSvg({ width: 25, height: 25 })"></i>
@@ -136,6 +130,9 @@ import WindowControls from '../../../components/WindowControls.vue';
import InfoSidebar from '../../../components/messages/InfoSidebar.vue'; import InfoSidebar from '../../../components/messages/InfoSidebar.vue';
import { isElectron } from '../../../utils/electronHelper'; import { isElectron } from '../../../utils/electronHelper';
import { groupService } from '../../../services/group'; import { groupService } from '../../../services/group';
import VideoPreview from '../../../components/electron/VideoPreview.vue';
import { useRightClickHandler } from './hooks/useRightClickHandler';
import AsyncImage from '../../../components/AsyncImage.vue';
const props = defineProps({ const props = defineProps({
@@ -177,16 +174,11 @@ const videoUrl = ref(null);
const videoOpen = ref(false) const videoOpen = ref(false)
const infoShowHandler = async () => { const infoShowHandler = async () => {
if (infoSideBarShow.value){ if (infoSideBarShow.value) {
infoSideBarShow.value = false; infoSideBarShow.value = false;
return; return;
} }
groupData = conversationInfo.value groupData = conversationInfo.value
if (conversationInfo.value.chatType == MESSAGE_TYPE.GROUP) {
const { data } = await groupService.getGroupMember(groupData.targetId)
groupData.members = data
}
infoSideBarShow.value = true infoSideBarShow.value = true
} }
@@ -217,17 +209,18 @@ const getImageStyle = (content) => {
}; };
}; };
const imagePreview = (m) => { const imagePreview = (e, m) => {
e.stopPropagation();
const imageList = chatStore.messages const imageList = chatStore.messages
.filter(x => x.type == 'Image') .filter(x => x.type == 'Image')
; ;
const index = imageList.indexOf(m); const index = imageList.indexOf(m);
if (isElectron()) { if (isElectron()) {
const safeData = JSON.parse(JSON.stringify( { const safeData = JSON.parse(JSON.stringify({
imageList, imageList,
index index
})); }));
window.api.window.newWindow('imgpre',safeData); window.api.window.newWindow('imgpre', safeData);
} else { } else {
previewImages({ previewImages({
imgList: imageList.map(m => m.content.url), imgList: imageList.map(m => m.content.url),
@@ -279,9 +272,17 @@ const stopRecord = async () => {
const playHandler = (m) => { const playHandler = (m) => {
videoUrl.value = m.content.url videoUrl.value = m.content.url
if (isElectron()) {
window.api.window.newWindow('videopre', videoUrl.value)
return
}
videoOpen.value = true videoOpen.value = true
} }
const videoClose = () => {
videoOpen.value = false
}
const loadHistoryMsg = async () => { const loadHistoryMsg = async () => {
// 1. 如果正在加载,或者已经彻底没数据了,才拦截 // 1. 如果正在加载,或者已经彻底没数据了,才拦截
if (isLoading.value || isFinished.value) return; if (isLoading.value || isFinished.value) return;
@@ -326,34 +327,7 @@ const closeHoverCard = () => {
const handleRightClick = (e, m) => { const handleRightClick = (e, m) => {
e.stopPropagation(); e.stopPropagation();
const items = [ menuRef.value.show(e, useRightClickHandler(e, m));
{
label: '复制',
action: () => console.log('打开之前的悬浮卡片', user)
},
{
label: '转发',
action: () => console.log('进入私聊', user.id)
},
{
label: '多选',
action: () => { }
},
{
label: '翻译',
action: () => { }
},
{
label: '引用',
action: () => { }
},
{
label: '删除',
type: 'danger',
action: () => alert('删除成功')
}
];
menuRef.value.show(e, items);
} }
watch( watch(
@@ -593,75 +567,7 @@ onUnmounted(() => {
-webkit-app-region: drag; -webkit-app-region: drag;
} }
/* 遮罩层:全屏、黑色半透明、固定定位 */
.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 { .tool-btn {
/* 允许拖动整个窗口 */ /* 允许拖动整个窗口 */
@@ -724,7 +630,7 @@ onUnmounted(() => {
} }
/* 图片:填满容器但不拉伸 */ /* 图片:填满容器但不拉伸 */
.image-msg-content { :deep(.image-msg-content) {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
@@ -875,7 +781,7 @@ onUnmounted(() => {
/* background: #95ec69; */ /* background: #95ec69; */
} }
.avatar-chat { :deep(.avatar-chat) {
width: 38px; width: 38px;
height: 38px; height: 38px;
border-radius: 4px; border-radius: 4px;
@@ -1,21 +1,41 @@
export function useRightClickHandler() { import { FILE_TYPE } from '../../../../constants/fileTypeDefine'
const items = [
{ export function useRightClickHandler(e, m) {
label: '查看资料', const textRightItem = [
action: () => console.log('打开之前的悬浮卡片', user) {
}, label: '复制',
{ action: async () => {
label: '发送消息', await navigator.clipboard.writeText(e.target.innerText)
action: () => console.log('进入私聊', user.id) }
}, },
{ {
label: '修改备注', label: '引用',
action: () => { } action: () => console.log('进入私聊')
}, },
{ {
label: '删除好友', label: '转发',
type: 'danger', action: () => {}
action: () => alert('删除成功') },
} {
]; label: '删除',
type: 'danger',
action: () => alert('删除成功')
}
]
const imgRightItem = [
{
label: '复制',
action: () => {
console.log(e.target)
}
}
]
switch (m.type) {
case FILE_TYPE.TEXT:
return textRightItem
case FILE_TYPE.Image:
case FILE_TYPE.Video:
return imgRightItem;
}
} }