后端:

完善注册页面
This commit is contained in:
2026-03-15 22:27:34 +08:00
parent f7223dc590
commit d1db5e6490
77 changed files with 5431 additions and 1190 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.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+2ecaa28091b41de707825db3628d380b62fa727f")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+eb8455e141ea496a2134ad7c7d9b759b6029dd75")]
[assembly: System.Reflection.AssemblyProductAttribute("IMTest")]
[assembly: System.Reflection.AssemblyTitleAttribute("IMTest")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
@@ -1 +1 @@
ed4980dfc7aff253176b260ed9015f9a80b52e92cbf3095eff3ed06865ea6e0d
546570633bb9288fc2957cbb29a807a45f3e48ba127ec13bc3956d28f5e6ed5b
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)
{
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);
}
+15
View File
@@ -29,6 +29,7 @@ namespace IM_API.Configs
CreateMap<RegisterRequestDto, User>()
.ForMember(dest => dest.Username,opt => opt.MapFrom(src => src.Username))
.ForMember(dest => dest.Password,opt => opt.MapFrom(src => src.Password))
.ForMember(dest => dest.Email, opt => opt.MapFrom(src => src.Email))
.ForMember(dest => dest.Avatar,opt => opt.MapFrom(src => "https://ts1.tc.mm.bing.net/th/id/OIP-C.dl0WpkTP6E2J4FnhDC_jHwAAAA?rs=1&pid=ImgDetMain&o=7&rm=3"))
.ForMember(dest => dest.StatusEnum,opt => opt.MapFrom(src => UserStatus.Normal))
.ForMember(dest => dest.OnlineStatusEnum,opt => opt.MapFrom(src => UserOnlineStatus.Offline))
@@ -229,6 +230,20 @@ namespace IM_API.Configs
.ForMember(dest => dest.AllMembersBanned, opt => opt.MapFrom(src => src.AllMembersBannedEnum))
.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);
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 InviteId { get; set; }
public int GroupId { get; set; }
public GroupInviteState Action { get; set; }
public GroupRequestState Action { get; set; }
}
}
@@ -4,6 +4,8 @@ namespace IM_API.Dtos.Auth
{
public class RegisterRequestDto
{
[EmailAddress(ErrorMessage = "邮箱格式错误")]
public string Email { get; set; }
[Required(ErrorMessage = "用户名不能为空")]
[MaxLength(20, ErrorMessage = "用户名不能超过20字符")]
[RegularExpression(@"^[A-Za-z0-9]+$", ErrorMessage = "")]
@@ -5,6 +5,6 @@ namespace IM_API.Dtos.Group
public class HandleGroupInviteDto
{
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> 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");
}
}
}
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 updateuser : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Description",
table: "users",
type: "longtext",
nullable: true,
collation: "utf8mb4_general_ci")
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.AddColumn<string>(
name: "Email",
table: "users",
type: "longtext",
nullable: true,
collation: "utf8mb4_general_ci")
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.AddColumn<string>(
name: "Region",
table: "users",
type: "longtext",
nullable: true,
collation: "utf8mb4_general_ci")
.Annotation("MySql:CharSet", "utf8mb4");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Description",
table: "users");
migrationBuilder.DropColumn(
name: "Email",
table: "users");
migrationBuilder.DropColumn(
name: "Region",
table: "users");
}
}
}
@@ -333,6 +333,7 @@ namespace IM_API.Migrations
.HasComment("全员禁言(0允许发言,2全员禁言)");
b.Property<string>("Announcement")
.IsRequired()
.HasColumnType("text")
.HasComment("群公告");
@@ -392,50 +393,6 @@ namespace IM_API.Migrations
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 =>
{
b.Property<int>("Id")
@@ -502,6 +459,9 @@ namespace IM_API.Migrations
.HasColumnType("int(11)")
.HasComment("群聊编号\r\n");
b.Property<int?>("InviteUserId")
.HasColumnType("int");
b.Property<sbyte>("State")
.HasColumnType("tinyint(4)")
.HasComment("申请状态(0:待管理员同意,1:已拒绝,2:已同意)");
@@ -515,8 +475,7 @@ namespace IM_API.Migrations
b.HasIndex("UserId");
b.HasIndex(new[] { "GroupId" }, "GroupId")
.HasDatabaseName("GroupId1");
b.HasIndex(new[] { "GroupId" }, "GroupId");
b.ToTable("group_request", (string)null);
@@ -844,6 +803,12 @@ namespace IM_API.Migrations
.HasDefaultValueSql("'1970-01-01 00:00:00'")
.HasComment("创建时间");
b.Property<string>("Description")
.HasColumnType("longtext");
b.Property<string>("Email")
.HasColumnType("longtext");
b.Property<sbyte>("IsDeleted")
.HasColumnType("tinyint(4)")
.HasComment("软删除标识\r\n0:账号正常\r\n1:账号已删除");
@@ -863,6 +828,9 @@ namespace IM_API.Migrations
.HasColumnType("varchar(50)")
.HasComment("密码");
b.Property<string>("Region")
.HasColumnType("longtext");
b.Property<sbyte>("Status")
.ValueGeneratedOnAdd()
.HasColumnType("tinyint(4)")
@@ -991,31 +959,6 @@ namespace IM_API.Migrations
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 =>
{
b.HasOne("IM_API.Models.Group", "Group")
@@ -1108,8 +1051,6 @@ namespace IM_API.Migrations
modelBuilder.Entity("IM_API.Models.Group", b =>
{
b.Navigation("GroupInvites");
b.Navigation("GroupMembers");
b.Navigation("GroupRequests");
@@ -1148,10 +1089,6 @@ namespace IM_API.Migrations
b.Navigation("FriendUsers");
b.Navigation("GroupInviteInviteUserNavigations");
b.Navigation("GroupInviteInvitedUserNavigations");
b.Navigation("GroupMembers");
b.Navigation("GroupRequests");
+1 -2
View File
@@ -38,7 +38,7 @@ public partial class Group
/// <summary>
/// 群公告
/// </summary>
public string? Announcement { get; set; }
public string Announcement { get; set; } = "暂无群公告,点击编辑添加。";
/// <summary>
/// 群聊创建时间
@@ -56,7 +56,6 @@ public partial class Group
public DateTimeOffset LastUpdateTime { get; set; } = DateTime.UtcNow;
public virtual ICollection<GroupInvite> GroupInvites { get; set; } = new List<GroupInvite>();
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>
Pending = 0,
/// <summary>
/// 已拒绝
/// 管理员已拒绝
/// </summary>
Declined = 1,
/// <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>
public int UserId { get; set; }
public int? InviteUserId { get; set; }
/// <summary>
/// 申请状态(0:待管理员同意,1:已拒绝,2:已同意
/// 申请状态(0:待管理员同意,1:管理员已拒绝,2管理员已同意,3:待对方同意,4:对方拒绝
/// </summary>
public sbyte State { get; set; }
@@ -49,11 +49,6 @@ namespace IM_API.Models
entity.Ignore(e => e.AuhorityEnum);
});
modelBuilder.Entity<GroupInvite>(entity =>
{
entity.Ignore(e => e.StateEnum);
});
modelBuilder.Entity<GroupMember>(entity =>
{
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<GroupInvite> GroupInvites { get; set; }
public virtual DbSet<GroupMember> GroupMembers { get; set; }
@@ -362,53 +361,6 @@ public partial class ImContext : DbContext
.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 =>
{
+12 -4
View File
@@ -23,6 +23,18 @@ public partial class User
/// 用户昵称
/// </summary>
public string? NickName { get; set; }
/// <summary>
/// 用户邮箱
/// </summary>
public string? Email { get; set; }
/// <summary>
/// 用户签名
/// </summary>
public string? Description { get; set; } = "";
/// <summary>
/// 地区
/// </summary>
public string? Region { get; set; } = "未知地区";
/// <summary>
/// 用户在线状态
@@ -73,10 +85,6 @@ public partial class User
[JsonIgnore]
public virtual ICollection<Friend> FriendUsers { get; set; } = new List<Friend>();
[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>();
[JsonIgnore]
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)
{
throw new NotImplementedException();
@@ -90,15 +91,15 @@ namespace IM_API.Services
x => x.Id == groupId) ?? throw new BaseException(CodeDefine.GROUP_NOT_FOUND);
//过滤非好友
var groupInviteIds = await validFriendshipAsync(userId, userIds);
var inviteList = groupInviteIds.Select(id => new GroupInvite
var inviteList = groupInviteIds.Select(id => new GroupRequest
{
Created = DateTime.UtcNow,
GroupId = group.Id,
InviteUser = userId,
InvitedUser = id,
StateEnum = GroupInviteState.Pending
UserId = id,
InviteUserId = userId,
StateEnum = GroupRequestState.TargetPending
}).ToList();
_context.GroupInvites.AddRange(inviteList);
_context.GroupRequests.AddRange(inviteList);
await _context.SaveChangesAsync();
await _endPoint.Publish(new GroupInviteEvent
{
@@ -132,7 +133,7 @@ namespace IM_API.Services
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
.Where(x => x.UserId == userId)
@@ -159,14 +160,18 @@ namespace IM_API.Services
_context.Groups.Update(group);
await _context.SaveChangesAsync();
}
public async Task HandleGroupInviteAsync(int userid, HandleGroupInviteDto dto)
{
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);
if (inviteInfo.InvitedUser != userid) throw new BaseException(CodeDefine.AUTH_FAILED);
if (!(dto.Action == GroupRequestState.TargetPending ||
dto.Action == GroupRequestState.TargetDeclined))
return;
inviteInfo.StateEnum = dto.Action;
_context.GroupInvites.Update(inviteInfo);
_context.GroupRequests.Update(inviteInfo);
await _context.SaveChangesAsync();
await _endPoint.Publish(new GroupInviteActionUpdateEvent
{
@@ -176,12 +181,13 @@ namespace IM_API.Services
EventId = Guid.NewGuid(),
GroupId = inviteInfo.GroupId,
InviteId = inviteInfo.Id,
InviteUserId = inviteInfo.InviteUser.Value,
InviteUserId = inviteInfo.InviteUserId!.Value,
OperatorId = userid,
UserId = userid
});
}
public async Task HandleGroupRequestAsync(int userid, HandleGroupRequestDto dto)
{
var user = _userService.GetUserInfoAsync(userid);
@@ -302,5 +308,68 @@ namespace IM_API.Services
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 InvitedUserId { 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; }
}
}
+8
View File
@@ -16,6 +16,7 @@
"@vuelidate/core": "^2.0.3",
"@vuelidate/validators": "^2.0.4",
"axios": "^1.13.2",
"crypto": "^1.0.1",
"electron-updater": "^6.3.9",
"feather-icons": "^4.29.2",
"hevue-img-preview": "^7.1.3",
@@ -3993,6 +3994,13 @@
"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": {
"version": "3.0.0",
"resolved": "https://registry.npmmirror.com/cssesc/-/cssesc-3.0.0.tgz",
+1
View File
@@ -25,6 +25,7 @@
"@vuelidate/core": "^2.0.3",
"@vuelidate/validators": "^2.0.4",
"axios": "^1.13.2",
"crypto": "^1.0.1",
"electron-updater": "^6.3.9",
"feather-icons": "^4.29.2",
"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}`)
})
}
+5
View File
@@ -4,6 +4,8 @@ import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import icon from '../../resources/icon.png?asset'
import { registerWindowHandler } from './ipcHandlers/window'
import { createTry } from './trayHandler'
import { registerCacheHandler } from './ipcHandlers/cache'
import { addProtocolHandler } from '../cache/protocolReg'
function createWindow() {
// Create the browser window.
@@ -49,6 +51,8 @@ app.whenReady().then(() => {
// Set app user model id for windows
electronApp.setAppUserModelId('com.electron')
addProtocolHandler()
// Default open or close DevTools by F12 in development
// and ignore CommandOrControl + R in production.
// see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
@@ -60,6 +64,7 @@ app.whenReady().then(() => {
ipcMain.on('ping', () => console.log('pong'))
registerWindowHandler()
registerCacheHandler()
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)
})
}
+11 -5
View File
@@ -6,7 +6,8 @@ import { is } from '@electron-toolkit/utils'
export function registerWindowHandler() {
const windowMapData = new Map()
ipcMain.on('window-action', (event, action) => {
//**窗口控件操作 */
ipcMain.on('window-action', (event, action, data) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (!win) return
const actions = {
@@ -20,14 +21,19 @@ export function registerWindowHandler() {
win.destroy()
}
},
isMaximized: () => win.isMaximized()
isMaximized: () => win.isMaximized(),
changeSize: () => {
win.setSize(data.width, data.height, true)
win.setResizable(data.resizable)
}
}
actions[action]?.()
})
ipcMain.on('window-new', (event, { route, data }) => {
/**新开窗口 */
ipcMain.on('window-new', (event, { route, data, width = 900, height=670 }) => {
const win = new BrowserWindow({
width: 900,
height: 670,
width: width,
height: height,
show: true,
autoHideMenuBar: true,
frame: false,
+7 -2
View File
@@ -9,8 +9,13 @@ const api = {
close: () => ipcRenderer.send('window-action', 'close'),
closeThis: () => ipcRenderer.send('window-action', 'closeThis'),
isMaximized: () => ipcRenderer.send('window-action', 'isMaximized'),
newWindow: (route, data) => ipcRenderer.send('window-new', { route, data }),
getWindowData: (winId) => ipcRenderer.invoke('get-window-data', winId)
newWindow: (route, data, width, height) => ipcRenderer.send('window-new', { route, data, width, height }),
getWindowData: (winId) => ipcRenderer.invoke('get-window-data', winId),
setMainSize: (width, height, resizable = true) =>
ipcRenderer.send('window-action', 'changeSize', { width, height, resizable })
},
cache: {
getCache: (url, type) => ipcRenderer.invoke('cache-get', url, type)
}
}
+3 -3
View File
@@ -5,13 +5,13 @@
<title>Electron</title>
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<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';
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;
img-src 'self' data: blob: https: http:;
img-src 'self' data: blob: https: http: ql-im:;
font-src 'self' data:;
media-src 'self' blob:;">
media-src 'self' blob: http://192.168.5.116:7070; ql-im:">
</head>
<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>
@@ -5,7 +5,7 @@
<rect width="10" height="1" fill="currentColor" />
</svg>
</button>
<button class="control-btn maximize" @click="toggleMaximize" title="最大化/还原">
<button class="control-btn maximize" @click="toggleMaximize" title="最大化/还原" :disabled="!props.resizable">
<svg v-if="!isMaximized" width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.2">
<rect x="1.5" y="1.5" width="7" height="7" />
</svg>
@@ -14,7 +14,7 @@
<rect x="4" y="5" width="6.5" height="6.5" stroke="currentColor" stroke-width="1.4" />
</svg>
</button>
<button class="control-btn close" @click="close" title="关闭">
<button class="control-btn close" @click="close" title="关闭" >
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.2"
stroke-linecap="round">
<path d="M1 1L9 9M9 1L1 9" />
@@ -27,11 +27,19 @@
<script setup>
import { ref } from 'vue'
import { ref, defineEmits, defineProps } from 'vue'
import { isElectron } from '../utils/electronHelper'
const isMaximized = ref(false)
const emits = defineEmits(['close'])
const props = defineProps({
resizable: {
type: Boolean,
default: true
}
})
function minimize() {
window.api.window.minimize();
}
@@ -41,6 +49,7 @@ function toggleMaximize() {
}
function close() {
window.api.window.close()
emits('close')
}
</script>
@@ -1,12 +1,12 @@
<template>
<WindowControls/>
<div></div>
</template>
<script setup>
import { previewImages } from 'hevue-img-preview/v3'
import {onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import WindowControls from '../WindowControls.vue';
// import WindowControls from '../WindowControls.vue';
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>
import { ref, computed, onMounted } from 'vue';
import { useContactStore } from '@/stores/contact';
import { ref, onMounted, defineEmits } from 'vue';
import { friendService } from '../../services/friend';
import { groupService } from '@/services/group';
import { SYSTEM_BASE_STATUS } from '@/constants/systemBaseStatus';
import { useMessage } from '../messages/useAlert';
const contactStore = useContactStore();
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([])
@@ -20,21 +39,12 @@ const toggle = (id) => {
};
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){
message.show('群聊创建成功。');
}else{
message.error(res.message);
}
emits('submit', selected.value, groupName.value)
};
onMounted(async () =>{
friends.value = contactStore.contacts;
friends.value = (await friendService.getFriendList()).data;
})
</script>
@@ -43,12 +53,12 @@ onMounted(async () =>{
<div v-if="modelValue" class="overlay" @click.self="$emit('update:modelValue', false)">
<div class="mini-modal">
<header>
<span>发起群聊</span>
<span>{{ props.title }}</span>
<button @click="$emit('update:modelValue', false)"></button>
</header>
<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 v-for="f in friends" :key="f.friendId" @click="toggle(f.friendId)" class="item">
@@ -60,8 +70,8 @@ onMounted(async () =>{
</main>
<footer>
<button @click="submit" :disabled="!groupName || !selected.size" class="btn">
创建 ({{ selected.size }})
<button @click="submit" :disabled="(!groupName&& props.type == 'CreateGroup') || !selected.size" class="btn">
{{ props.type == 'CreateGroup' ? '创建' : '确定' }} ({{ selected.size }})
</button>
</footer>
</div>
@@ -19,26 +19,26 @@
<button v-if="isAdmin" class="text-link">编辑</button>
</div>
<div class="announcement-box">
{{ groupData.announcement || '暂无群公告,点击编辑添加。' }}
{{ groupInfo ? groupInfo.announcement : '暂无群公告,点击编辑添加。' }}
</div>
</section>
<section v-if="chatType == MESSAGE_TYPE.GROUP" class="info-card">
<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>
</div>
<div class="member-grid">
<div class="member-item add-btn">
<div class="member-avatar-box dashed">
<div class="member-avatar-box dashed" @click="inviteHandler">
<span>+</span>
</div>
<span class="member-nick">邀请</span>
</div>
<div
v-for="member in groupData.members?.slice(0, 11)"
v-for="member in groupInfo.members?.slice(0, 11)"
:key="member.id"
class="member-item"
>
@@ -69,12 +69,13 @@
<button class="danger-btn">删除并退出</button>
</div>
</div>
<create-group v-model="groupInviteModal" type="InviteUser" title="邀请好友" @submit="inviteUserHandler"/>
</aside>
</transition>
</template>
<script setup>
import { computed, useTemplateRef } from 'vue';
import { computed, onMounted, ref, useTemplateRef, watch } from 'vue';
import { MESSAGE_TYPE } from '../../constants/MessageType';
import feather from 'feather-icons';
import { GROUP_MEMBER_ROLE } from '../../constants/GroupDefine';
@@ -83,6 +84,7 @@ import { groupService } from '../../services/group';
import { SYSTEM_BASE_STATUS } from '../../constants/systemBaseStatus';
import { useMessage } from './useAlert';
import { getFileHash } from '../../utils/uploadTools';
import CreateGroup from '../groups/CreateGroup.vue';
const props = defineProps({
chatType: {
@@ -111,6 +113,12 @@ const input = useTemplateRef('input')
const message = useMessage();
const groupInfo = ref({
members: []
})
const groupInviteModal = ref(false)
defineEmits(['close', 'viewAll']);
const uploadGroupAvatar = () => {
@@ -119,7 +127,7 @@ const uploadGroupAvatar = () => {
const fileUploadHandler = async (e) => {
const file = e.target.files[0];
const hash = getFileHash(file)
const hash = await getFileHash(file)
const { data } = await uploadService.uploadSmallFile(file, hash);
const res = await groupService.updateGroupInfo(props.groupData.targetId, {
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(() => {
// 逻辑:在 members 数组中找到当前用户并检查 role
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>
<style scoped>
@@ -148,7 +183,7 @@ const isAdmin = computed(() => {
right: 0;
width: 320px;
background-color: #f5f5f5; /* 背景色改为浅灰,突出白色卡片 */
z-index: 1000;
z-index: 100;
box-shadow: -8px 0 24px rgba(0, 0, 0, 0.05);
display: flex;
flex-direction: column;
@@ -3,3 +3,37 @@ export const GROUP_MEMBER_ROLE = Object.freeze({
ADMIN: 'Administrator',
MASTER: 'Master'
})
/**
* 群请求状态枚举 (对应后端 String 输出)
*/
export const GROUP_REQUEST_STATUS = Object.freeze({
/** 待管理员处理 */
PENDING: 'Pending',
/** 管理员已拒绝 */
DECLINED: 'Declined',
/** 管理员已同意 */
PASSED: 'Passed',
/** 待对方同意 */
TARGET_PENDING: 'TargetPending',
/** 对方拒绝 */
TARGET_DECLINED: 'TargetDeclined'
});
export const getGroupRequestStatusTxt = (status) => {
switch (status) {
case GROUP_REQUEST_STATUS.PENDING:
return '待管理员处理';
case GROUP_REQUEST_STATUS.DECLINED:
return '管理员已拒绝';
case GROUP_REQUEST_STATUS.PASSED:
return '管理员已同意';
case GROUP_REQUEST_STATUS.TARGET_PENDING:
return '待对方同意';
case GROUP_REQUEST_STATUS.TARGET_DECLINED:
return '对方拒绝';
default:
return '未知状态';
}
};
@@ -36,5 +36,6 @@ export const FILE_TYPE = Object.freeze({
Image: 'Image',
Video: 'Video',
Voice: 'Voice',
File: 'File'
File: 'File',
TEXT: 'Text'
});
@@ -0,0 +1,26 @@
export const GROUP_REQUEST_TYPE = Object.freeze({
//**邀请对方 */
INVITE: 'invite',
/**被邀请 */
INVITED: 'invited',
/**入群请求 */
IS_GROUP: 'is-group',
//**我的申请入群 */
IS_USER: 'is-user',
})
export const getTypeText = (type) => {
switch(type){
case GROUP_REQUEST_TYPE.INVITE:
return '邀请好友入群';
case GROUP_REQUEST_TYPE.INVITED:
return '邀请你入群';
case GROUP_REQUEST_TYPE.IS_GROUP:
return '申请入群';
case GROUP_REQUEST_TYPE.IS_USER:
return '我的申请入群';
default:
'未知状态'
}
}
@@ -11,6 +11,7 @@ const message = useMessage();
const routes = [
{ path: '/auth/login', component: () => import('@/views/auth/Login.vue') },
{ path: '/auth/register', component: () => import('@/views/auth/Register.vue') },
{
path: '/',
component: MainView,
@@ -57,6 +58,11 @@ const routes = [
path: '/contacts/requests',
name: 'friendRequests',
component: () => import('@/views/contact/FriendRequestList.vue')
},
{
path: '/contacts/grouphandle',
name: 'grouphandle',
component: () => import('@/views/contact/GroupRequest.vue')
}
]
},
@@ -72,6 +78,9 @@ const routes = [
{ path: '/test', component: TestView },
{
path: '/imgpre', component: () => import('@/components/electron/ImagePreview.vue')
},
{
path: '/videopre', component: () => import('@/components/electron/VideoPreview.vue')
}
]
+50 -48
View File
@@ -1,97 +1,99 @@
import axios from 'axios'
import { useMessage } from '@/components/messages/useAlert';
import router from '@/router';
import { useAuthStore } from '@/stores/auth';
import { authService } from './auth';
import { useMessage } from '@/components/messages/useAlert'
import router from '@/router'
import { useAuthStore } from '@/stores/auth'
import { authService } from './auth'
const message = useMessage();
const message = useMessage()
let waitqueue = [];
let isRefreshing = false;
const authURL = ['/auth/login', '/auth/register', '/auth/refresh'];
let waitqueue = []
let isRefreshing = false
const authURL = ['/auth/login', '/auth/register', '/auth/refresh']
const pushLoginElectron = () => {
window.api.window.close()
window.api.window.newWindow('/auth/login', null, 420, 540)
}
const api = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api', // 从环境变量中读取基础 URL
timeout: 10000,
headers: {
}
headers: {}
})
api.interceptors.request.use(
config => {
const authStore = useAuthStore();
const token = authStore.token;
(config) => {
const authStore = useAuthStore()
const token = authStore.token
if (token) {
config.headers.Authorization = `Bearer ${token}`;
config.headers.Authorization = `Bearer ${token}`
}
return config;
return config
},
err => {
return Promise.reject(err);
(err) => {
return Promise.reject(err)
}
)
api.interceptors.response.use(
response => {
return response.data;
(response) => {
return response.data
},
async err => {
const authStore = useAuthStore();
const { config, response } = err;
async (err) => {
const authStore = useAuthStore()
const { config, response } = err
if (response) {
switch (response.status) {
case 401:
if (authURL.some(x => config.url.includes(x))) {
authStore.logout();
message.error('未登录,请登录后操作。');
if (authURL.some((x) => config.url.includes(x))) {
authStore.logout()
message.error('未登录,请登录后操作。')
router.push('/auth/login')
break;
break
}
if (config._retry) {
break;
break
}
config._retry = true;
config._retry = true
// 已经在刷新 → 排队
if (isRefreshing) {
return new Promise(resolve => {
waitqueue.push(token => {
return new Promise((resolve) => {
waitqueue.push((token) => {
config.headers.Authorization = `Bearer ${token}`
resolve(api(config))
})
})
}
isRefreshing = true;
const refreshToken = authStore.refreshToken;
isRefreshing = true
const refreshToken = authStore.refreshToken
if (refreshToken != null && refreshToken != '') {
const res = await authService.refresh(refreshToken)
authStore.setLoginInfo(res.data.token, res.data.refreshToken, res.data.userInfo)
waitqueue.forEach(cb => cb(authStore.token));
waitqueue = [];
waitqueue.forEach((cb) => cb(authStore.token))
waitqueue = []
config.headers.Authorization = `Bearer ${authStore.token}`
return api(config)
}
authStore.logout();
message.error('未登录,请登录后操作。');
authStore.logout()
message.error('未登录,请登录后操作。')
router.push('/auth/login')
break;
break
case 400:
if (response.data && response.data.code == 1003) {
message.error(response.data.message);
break;
message.error(response.data.message)
break
}
default:
message.error('请求错误,请检查网络。');
break;
message.error('请求错误,请检查网络。')
break
}
return Promise.reject(err);
return Promise.reject(err)
} else {
message.error('请求错误,请检查网络。');
return Promise.reject(err);
message.error('请求错误,请检查网络。')
return Promise.reject(err)
}
}
)
@@ -100,5 +102,5 @@ export const request = {
post: (url, data, config) => api.post(url, data, config),
put: (url, data, config) => api.put(url, data, config),
delete: (url, config) => api.delete(url, config),
instance: api,
};
instance: api
}
@@ -21,5 +21,26 @@ export const groupService = {
* @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 { messagesDb } from "@/utils/db/messageDB";
import { messageService } from "@/services/message";
import { useConversationStore } from "./conversation";
export const useChatStore = defineStore('chat', {
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 STORE_NAME = 'messages';
const CONVERSARION_STORE_NAME = 'conversations';
const CONTACT_STORE_NAME = 'contacts';
const DBNAME = 'IM_DB'
const STORE_NAME = 'messages'
const CONVERSARION_STORE_NAME = 'conversations'
const CONTACT_STORE_NAME = 'contacts'
const GROUP_REQUEST_STORE_NAME = 'groupRequests'
export const dbPromise = openDB(DBNAME, 7, {
upgrade(db) {
if (!db.objectStoreNames.contains(STORE_NAME)) {
const store = db.createObjectStore(STORE_NAME, { keyPath: 'msgId' });
store.createIndex('by-sessionId', 'sessionId');
store.createIndex('by-time', 'timeStamp');
store.createIndex('by-sequenceId', 'sequenceId');
store.createIndex('by-session-sequenceId', ['sessionId', 'sequenceId']);
const store = db.createObjectStore(STORE_NAME, { keyPath: 'msgId' })
store.createIndex('by-sessionId', 'sessionId')
store.createIndex('by-time', 'timeStamp')
store.createIndex('by-sequenceId', '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');
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 });
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">
<nav class="nav-sidebar">
<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>
<router-link class="nav-item" to="/messages" active-class="active">
<i class="menuIcon" v-html="feather.icons['message-square'].toSvg()"></i>
@@ -20,11 +21,11 @@
</template>
<script setup>
import { ref, watch, onMounted } from 'vue'
import { watch, onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth';
import defaultAvatar from '@/assets/default_avatar.png'
import { useRouter } from 'vue-router';
import feather from 'feather-icons';
import AsyncImage from '../components/AsyncImage.vue';
const router = useRouter();
const authStore = useAuthStore();
@@ -45,6 +46,7 @@ function handleStartChat(contact) {
}
onMounted(async () => {
window.api.window.setMainSize(900, 670)
const { useSignalRStore } = await import('../stores/signalr');
const authStore = useAuthStore();
const signalRStore = useSignalRStore();
@@ -195,7 +197,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; }
/* 未读气泡 */
+12 -192
View File
@@ -1,206 +1,26 @@
<template>
<Teleport to="body">
<div v-if="visible" class="mask" @click.self="close">
<!-- 工具栏 -->
<div class="toolbar" @click.stop>
<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>
<AsyncImage raw-url="http://192.168.5.116:7070/uploads/files/IM/2026/03/2/e6c407f60c68.jpg" :type="FILE_TYPE.Image"/>
<button @click="test">click</button>
</template>
<script setup>
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import feather from 'feather-icons'
import { ref } from 'vue';
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 index = ref(0)
const scale = ref(1)
const rotateDeg = ref(0)
const offset = ref({ x: 0, y: 0 })
const test = async () => {
let dragging = false
let startPos = { x: 0, y: 0 }
const cacheStore = useCacheStore();
watch(() => props.modelValue, v => {
visible.value = v
if (v) {
index.value = props.start
reset()
}
})
const localPath = await cacheStore.getCache('http://192.168.5.116:7070/uploads/files/IM/2026/03/2/e6c407f60c68.jpg', FILE_TYPE.Image)
console.log(localPath)
url.value = localPath
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>
<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>
@@ -1,49 +1,59 @@
<template>
<div class="login-layout">
<div class="login-container">
<div class="login-card">
<div class="side-visual">
<div class="brand-container">
<h1 class="hero-title">Work<br>Together.</h1>
<p class="hero-subtitle">下一代企业级即时通讯平台让沟通无距离</p>
</div>
<div class="visual-footer">
<span>© 2025 IM System</span>
<div class="dots">
<span></span><span></span><span></span>
</div>
<div class="login-box">
<WindowControls :resizable="false"/>
<div class="window-header">
<div class="brand">
<span class="logo-dot"></span>
IM System
</div>
</div>
<div class="side-form">
<div class="form-wrapper">
<div class="welcome-header">
<h2>账号登录</h2>
<p>请输入您的工作账号以继续</p>
<div class="login-content">
<div class="avatar-wrapper">
<div class="avatar">
<i data-feather="message-circle"></i>
</div>
<h1 class="web-title">Work Together</h1>
</div>
<form @submit.prevent="handleLogin">
<IconInput class="input"
placeholder="请输入用户名" lab="用户名 / 邮箱" type="text" icon-name="user" v-model="form.username"/>
<IconInput class="input"
placeholder="请输入密码" lab="密码" type="password" icon-name="lock" v-model="form.password"/>
<div class="login-btn-wrapper">
<MyButton variant="pill" class="login-btn" :loading="loading">
登录
</MyButton>
<form @submit.prevent="handleLogin" class="form-body">
<div class="input-area">
<input
type="text"
v-model="form.username"
placeholder="账号/邮箱"
class="classic-input"
/>
<div class="divider"></div>
<input
type="password"
v-model="form.password"
placeholder="密码"
class="classic-input"
/>
</div>
<div class="options">
<label class="checkbox-label">
<input type="checkbox"> 自动登录
</label>
<router-link to="/forget" class="link">找回密码</router-link>
</div>
<button type="submit" class="login-btn" :disabled="loading">
{{ loading ? '正在登录...' : '登录' }}
</button>
</form>
<div class="register-hint">
还没有账号? <router-link to="/auth/register">立即注册</router-link>
<div class="footer-links">
<span>还没有账号?</span>
<router-link to="/auth/register" class="link">立即注册</router-link>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
@@ -52,12 +62,11 @@ import { useMessage } from '@/components/messages/useAlert'
import { authService } from '@/services/auth'
import { useRouter } from 'vue-router'
import feather from 'feather-icons'
import IconInput from '@/components/IconInput.vue'
import MyButton from '@/components/MyButton.vue'
import { required, maxLength, helpers } from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
import { useAuthStore } from '@/stores/auth'
import { useSignalRStore } from '@/stores/signalr'
import WindowControls from '../../components/WindowControls.vue'
const message = useMessage();
const router = useRouter();
@@ -111,219 +120,190 @@ const handleLogin = async () => {
}
onMounted(() => {
window.api.window.setMainSize(360, 532, false)
feather.replace()
})
</script>
<style scoped>
/* Soft Mesh Gradient Background */
.login-layout {
/* 1. 外层容器:Web 端负责背景铺满,客户端负责承载 */
.login-container {
width: 100%;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
width: 100vw;
height: 100vh;
min-height: 100vh;
background-color: #f8fafc;
background-image:
radial-gradient(at 0% 0%, hsla(190, 100%, 95%, 1) 0, transparent 50%),
radial-gradient(at 50% 0%, hsla(160, 100%, 96%, 1) 0, transparent 50%),
radial-gradient(at 100% 0%, hsla(210, 100%, 96%, 1) 0, transparent 50%);
overflow: hidden;
position: relative;
/* 适配 Web 端的淡雅背景,客户端如果是透明窗口则会透过去 */
background-color: #f5f7f9;
}
/* Very subtle grid overlay */
.login-layout::before {
content: "";
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
background-image: radial-gradient(rgba(0,0,0,0.02) 1px, transparent 1px);
background-size: 20px 20px;
z-index: 0;
}
.login-card {
display: flex;
width: 1000px;
height: 600px;
background: rgba(255, 255, 255, 0.82);
backdrop-filter: blur(30px);
border-radius: 32px;
box-shadow:
0 10px 25px -5px rgba(0, 0, 0, 0.02),
0 40px 100px -20px rgba(0, 0, 0, 0.08);
overflow: hidden;
z-index: 10;
position: relative;
border: 1px solid rgba(255, 255, 255, 0.7);
}
.side-visual {
flex: 1;
/* Soft connectivity gradient */
background: linear-gradient(135deg, #4f46e5 0%, #06b6d4 100%);
position: relative;
/* 2. 核心登录框:双端适配的关键 */
.login-box {
width: 100%;
max-width: 420px; /* Web 端限制最大宽度 */
height: 100%;
max-height: 540px; /* Web 端限制最大高度 */
background: #ffffff;
display: flex;
flex-direction: column;
justify-content: center;
padding: 60px;
color: white;
overflow: hidden;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.05); /* Web 端浮动感 */
transition: all 0.3s ease;
}
/* Abstract "Connection" Circles */
.side-visual::before {
content: '';
position: absolute;
top: -20%; left: -20%;
width: 400px; height: 400px;
border-radius: 50%;
background: radial-gradient(circle, rgba(255,255,255,0.1) 0%, transparent 60%);
z-index: 1;
}
.side-visual::after {
content: '';
position: absolute;
bottom: -10%; right: -10%;
width: 300px; height: 300px;
border-radius: 50%;
background: radial-gradient(circle, rgba(255,255,255,0.15) 0%, transparent 60%);
z-index: 1;
/* 3. 桌面端(Electron)适配:当窗口较小时(即客户端模式) */
@media (max-width: 480px) or (max-height: 580px) {
.login-container {
background-color: #fff; /* 客户端通常不需要容器背景 */
}
.login-box {
max-width: 100%;
max-height: 100%;
box-shadow: none; /* 客户端全屏模式不需要投影 */
border-radius: 0;
}
.window-header {
display: flex; /* 客户端显示拖拽区 */
}
}
.brand-container {
position: relative;
z-index: 2;
}
.hero-title {
font-size: 44px;
font-weight: 800;
line-height: 1.2;
margin-bottom: 20px;
text-shadow: 0 4px 10px rgba(0,0,0,0.1);
}
.hero-subtitle {
font-size: 16px;
opacity: 0.95;
line-height: 1.6;
max-width: 340px;
font-weight: 400;
}
.visual-footer {
position: absolute;
bottom: 40px;
left: 60px;
right: 60px;
display: flex;
justify-content: space-between;
/* 顶部拖拽区 */
.window-header {
height: 40px;
padding: 0 16px;
display: none; /* Web 端默认隐藏 */
align-items: center;
-webkit-app-region: drag;
}
.brand {
font-size: 12px;
opacity: 0.8;
z-index: 2;
}
.dots span {
display: inline-block;
width: 6px; height: 6px;
background: white;
border-radius: 50%;
margin-left: 6px;
opacity: 0.7;
}
.side-form {
flex: 1;
color: #999;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.logo-dot {
width: 8px;
height: 8px;
background: #0099ff;
border-radius: 50%;
}
.login-content {
flex: 1;
padding: 40px;
background: #fff;
display: flex;
flex-direction: column;
align-items: center;
}
.form-wrapper {
width: 100%;
max-width: 340px;
}
.welcome-header {
margin-bottom: 30px;
.avatar-wrapper {
margin-bottom: 32px;
text-align: center;
}
.welcome-header h2 {
font-size: 26px;
font-weight: 700;
color: #1e293b;
margin-bottom: 8px;
.avatar {
width: 72px;
height: 72px;
background: #f0f7ff;
color: #0099ff;
border-radius: 20px; /* 稍微方圆一点更现代 */
display: inline-flex;
align-items: center;
justify-content: center;
margin-bottom: 12px;
}
.welcome-header p {
color: #64748b;
.web-title {
font-size: 20px;
font-weight: 600;
color: #333;
margin: 0;
}
.form-body {
width: 100%;
-webkit-app-region: no-drag;
}
.input-area {
border: 1px solid #e5e7eb;
border-radius: 8px;
overflow: hidden;
transition: border-color 0.2s;
}
.input-area:focus-within {
border-color: #0099ff;
}
.classic-input {
width: 100%;
height: 48px;
border: none;
padding: 0 16px;
outline: none;
font-size: 14px;
}
.input {
width: 100%;
margin-bottom: 20px;
.divider {
height: 1px;
background: #f3f4f6;
margin: 0 12px;
}
.login-btn-wrapper {
.options {
display: flex;
justify-content: center;
width: 100%;
margin-top: 32px;
}
.register-hint {
margin-top: 24px;
text-align: center;
justify-content: space-between;
margin-top: 16px;
font-size: 13px;
color: #64748b;
color: #6b7280;
}
.register-hint a {
color: #2563eb;
.checkbox-label {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
}
.login-btn {
width: 100%;
height: 46px;
background: #0099ff;
border: none;
border-radius: 8px;
color: white;
font-size: 15px;
font-weight: 600;
text-decoration: none;
margin-top: 32px;
cursor: pointer;
transition: all 0.2s;
}
.register-hint a:hover {
.login-btn:hover {
background: #0088ee;
transform: translateY(-1px);
}
.login-btn:active {
transform: translateY(0);
}
.footer-links {
margin-top: auto;
font-size: 13px;
color: #9ca3af;
}
.link {
color: #0099ff;
text-decoration: none;
margin-left: 4px;
}
.link:hover {
text-decoration: underline;
}
/* Response Design */
@media (max-width: 960px) {
.login-card {
flex-direction: column;
width: 90%;
margin: 20px;
height: auto;
border-radius: 16px;
}
.side-visual {
padding: 30px;
min-height: 160px;
background: linear-gradient(135deg, #2563eb 0%, #06b6d4 100%);
}
.hero-title { font-size: 28px; }
.hero-subtitle, .visual-footer { display: none; }
.side-form { padding: 40px 20px; }
}
</style>
<style>
body {
margin: 0;
padding: 0;
overflow: hidden;
font-family: 'Inter', system-ui, -apple-system, sans-serif;
background-color: #f0f4f8; /* Fallback */
}
</style>
@@ -1,49 +1,76 @@
<template>
<div class="login-layout">
<div class="login-card">
<div class="side-visual">
<div class="brand-container">
<h1 class="hero-title">Join<br>Us.</h1>
<p class="hero-subtitle">创建一个新账号开启您的沟通之旅</p>
</div>
<div class="visual-footer">
<span>© 2025 IM System</span>
<div class="dots">
<span></span><span></span><span></span>
</div>
<div class="login-container">
<div class="login-box">
<WindowControls/>
<div class="window-header">
<div class="brand">
<span class="logo-dot"></span>
IM System
</div>
</div>
<div class="side-form">
<div class="form-wrapper">
<div class="welcome-header">
<h2>注册账号</h2>
<p>请填写以下信息以完成注册</p>
<div class="login-content">
<div class="avatar-wrapper">
<div class="avatar">
<i data-feather="user-plus"></i>
</div>
<h1 class="web-title">创建账号</h1>
</div>
<form @submit.prevent="handleRegister">
<IconInput class="input"
placeholder="请输入用户名" lab="用户名" type="text" icon-name="user" v-model="form.username"/>
<IconInput class="input"
placeholder="请输入昵称" lab="昵称" type="text" icon-name="smile" v-model="form.nickname"/>
<IconInput class="input"
placeholder="请输入密码" lab="密码" type="password" icon-name="lock" v-model="form.password"/>
<IconInput class="input"
placeholder="再次输入密码" lab="确认密码" type="password" icon-name="check-circle" v-model="form.confirmPassword"/>
<div class="login-btn-wrapper">
<MyButton variant="pill-green" class="login-btn" :loading="loading">
立即注册
</MyButton>
<form @submit.prevent="handleRegister" class="form-body">
<div class="input-area">
<input
type="text"
v-model="form.nickName"
placeholder="昵称"
class="classic-input"
/>
<div class="divider"></div>
<input
type="text"
v-model="form.username"
placeholder="用户名"
class="classic-input"
/>
<div class="divider"></div>
<input
type="email"
v-model="form.email"
placeholder="邮箱地址"
class="classic-input"
/>
<div class="divider"></div>
<input
type="password"
v-model="form.password"
placeholder="设置密码"
class="classic-input"
/>
<div class="divider"></div>
<input
type="password"
v-model="form.confirmPassword"
placeholder="确认密码"
class="classic-input"
/>
</div>
<div class="options">
<label class="checkbox-label">
<input type="checkbox" v-model="form.agree"> 我已阅读并同意
<a href="#" class="link" @click.prevent>服务条款</a>
</label>
</div>
<button type="submit" class="login-btn" :disabled="loading">
{{ loading ? '正在注册...' : '立即注册' }}
</button>
</form>
<div class="register-hint">
已有账号? <router-link to="/auth/login">直接登录</router-link>
</div>
<div class="footer-links">
<span>已有账号?</span>
<router-link to="/auth/login" class="link">立即登录</router-link>
</div>
</div>
</div>
@@ -51,15 +78,15 @@
</template>
<script setup>
import { reactive, ref, onMounted } from 'vue'
import { reactive, ref, onMounted, computed } from 'vue'
import { useMessage } from '@/components/messages/useAlert'
import { authService } from '@/services/auth'
import { useRouter } from 'vue-router'
import feather from 'feather-icons'
import IconInput from '@/components/IconInput.vue'
import MyButton from '@/components/MyButton.vue'
import { required, maxLength, minLength, sameAs, helpers } from '@vuelidate/validators'
// 引入了 email, minLength 和 sameAs 校验器
import { required, maxLength, minLength, email, sameAs, helpers } from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
import WindowControls from '../../components/WindowControls.vue'
const message = useMessage();
const router = useRouter();
@@ -67,289 +94,267 @@ const router = useRouter();
const loading = ref(false)
const form = reactive({
username: '',
nickname: '',
email: '',
password: '',
confirmPassword: ''
confirmPassword: '',
nickName: ''
})
const rules = {
username:{
required:helpers.withMessage('用户名不能为空', required),
maxLength:helpers.withMessage('用户名最大20字符', maxLength(20)),
minLength:helpers.withMessage('用户名至少3字符', minLength(3))
},
nickname: {
// 使用 computed 包裹 rules,以便 sameAs 能够响应式追踪 form.password 的变化
const rules = computed(() => ({
nickName: {
required: helpers.withMessage('昵称不能为空', required),
maxLength: helpers.withMessage('昵称最大20字符', maxLength(20))
},
password:{
required:helpers.withMessage('密码不能为空', required),
minLength:helpers.withMessage('密码至少6字符', minLength(6)),
maxLength:helpers.withMessage('密码最大50字符', maxLength(50))
username: {
required: helpers.withMessage('用户名不能为空', required),
maxLength: helpers.withMessage('用户名最大20字符', maxLength(20))
},
email: {
required: helpers.withMessage('邮箱不能为空', required),
email: helpers.withMessage('请输入有效的邮箱地址', email)
},
password: {
required: helpers.withMessage('密码不能为空', required),
minLength: helpers.withMessage('密码至少6个字符', minLength(6)),
maxLength: helpers.withMessage('密码最大50字符', maxLength(50))
},
confirmPassword: {
required: helpers.withMessage('请确认密码', required),
sameAs: helpers.withMessage('两次输入的密码不一致', sameAs(ref(form).password)) // This might fail if using reactive directly without computed ref binding for sameAs. Let's fix sameAs usage.
// Actually sameAs(form.password) won't work reactively in Vuelidate 2 sometimes if not ref.
// Just utilize a simpler computed or validator.
sameAs: helpers.withMessage('两次输入的密码不一致', sameAs(form.password))
},
agree: {
sameAs: helpers.withMessage('请先同意服务条款', sameAs(true))
}
};
}));
// Vuelidate sameAs expects a generic or a ref.
// Let's use computed for the target or just fix the rule.
// In composition API, use computed(() => form.password)
const rulesWithComputed = {
...rules,
confirmPassword: {
required: helpers.withMessage('请确认密码', required),
sameAs: helpers.withMessage('两次输入的密码不一致', sameAs(ref(form).value?.password || form.password)) // Trickier with reactive.
}
}
// Actually, standard way:
// const rules = computed(() => ({ ... }))
const v$ = useVuelidate(rules, form); // Vuelidate supports reactive object directly.
// The problem is `sameAs` needs a locator.
// Correct usage: sameAs(computed(() => form.password))
const v$ = useVuelidate(rules, form);
const handleRegister = async () => {
// Manual check for confirm password if Vuelidate sameAs is tricky without computed rules
if (form.password !== form.confirmPassword) {
message.error('两次输入的密码不一致');
return;
}
const isFormCorrect = await v$.value.$validate()
if (!isFormCorrect) {
if (v$.value.$errors.length > 0) {
// Skip confirmPassword error if we manually checked it or if it's the only one
message.error(v$.value.$errors[0].$message)
}
return
}
try{
try {
loading.value = true;
// Prepare data (exclude confirmPassword)
const { confirmPassword, ...registerData } = form;
// 调用注册接口,过滤掉本地状态如 confirmPassword 和 agree
const res = await authService.register({
username: form.username,
email: form.email,
password: form.password,
nickName: form.nickName
});
const res = await authService.register(registerData);
if(res.code === 0){
message.success('注册成功,请登录')
router.push('/auth/login')
}else{
} else {
message.error(res.message || '注册失败')
}
} catch(e) {
console.error(e);
message.error('注册请求异常');
} finally{
} catch (e) {
console.error(e)
message.error('网络请求异常')
} finally {
loading.value = false;
}
}
onMounted(() => {
if (window.api && window.api.window) {
window.api.window.setMainSize(360, 600)
}
feather.replace()
})
</script>
<style scoped>
/* Green Soft Mesh Gradient Background */
.login-layout {
/* 1. 外层容器:Web 端负责背景铺满,客户端负责承载 */
.login-container {
width: 100%;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
width: 100vw;
height: 100vh;
min-height: 100vh;
background-color: #f0fdf4; /* Very light green fallback */
background-image:
radial-gradient(at 0% 0%, hsla(150, 100%, 95%, 1) 0, transparent 50%),
radial-gradient(at 50% 0%, hsla(165, 100%, 96%, 1) 0, transparent 50%),
radial-gradient(at 100% 0%, hsla(140, 100%, 96%, 1) 0, transparent 50%);
overflow: hidden;
position: relative;
background-color: #f5f7f9;
}
/* Very subtle grid overlay */
.login-layout::before {
content: "";
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
background-image: radial-gradient(rgba(0,0,0,0.02) 1px, transparent 1px);
background-size: 20px 20px;
z-index: 0;
}
.login-card {
display: flex;
width: 1000px;
height: 600px;
background: rgba(255, 255, 255, 0.82);
backdrop-filter: blur(30px);
border-radius: 32px;
box-shadow:
0 10px 25px -5px rgba(0, 0, 0, 0.02),
0 40px 100px -20px rgba(0, 0, 0, 0.08);
overflow: hidden;
z-index: 10;
position: relative;
border: 1px solid rgba(255, 255, 255, 0.7);
}
.side-visual {
flex: 1;
/* Green connectivity gradient */
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
position: relative;
/* 2. 核心登录框 */
.login-box {
width: 100%;
max-width: 420px;
height: 100%;
/* 为注册页增加了最大高度容纳更多输入框 */
max-height: 620px;
background: #ffffff;
display: flex;
flex-direction: column;
justify-content: center;
padding: 60px;
color: white;
overflow: hidden;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.05);
transition: all 0.3s ease;
}
/* Abstract Decorations */
.side-visual::before {
content: '';
position: absolute;
top: -20%; left: -20%;
width: 400px; height: 400px;
border-radius: 50%;
background: radial-gradient(circle, rgba(255,255,255,0.1) 0%, transparent 60%);
z-index: 1;
}
.side-visual::after {
content: '';
position: absolute;
bottom: -10%; right: -10%;
width: 300px; height: 300px;
border-radius: 50%;
background: radial-gradient(circle, rgba(255,255,255,0.15) 0%, transparent 60%);
z-index: 1;
}
.brand-container {
position: relative;
z-index: 2;
}
.hero-title {
font-size: 44px;
font-weight: 800;
line-height: 1.2;
margin-bottom: 20px;
text-shadow: 0 4px 10px rgba(0,0,0,0.1);
}
.hero-subtitle {
font-size: 16px;
opacity: 0.95;
line-height: 1.6;
max-width: 340px;
font-weight: 400;
}
.visual-footer {
position: absolute;
bottom: 40px;
left: 60px;
right: 60px;
/* 3. 桌面端(Electron)适配 */
@media (max-width: 480px) or (max-height: 640px) {
.login-container {
background-color: #fff;
}
.login-box {
max-width: 100%;
max-height: 100%;
box-shadow: none;
border-radius: 0;
}
.window-header {
display: flex;
justify-content: space-between;
}
}
/* 顶部拖拽区 */
.window-header {
height: 40px;
padding: 0 16px;
display: none;
align-items: center;
-webkit-app-region: drag;
}
.brand {
font-size: 12px;
opacity: 0.8;
z-index: 2;
}
.dots span {
display: inline-block;
width: 6px; height: 6px;
background: white;
border-radius: 50%;
margin-left: 6px;
opacity: 0.7;
}
.side-form {
flex: 1;
color: #999;
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
background: #fff;
gap: 6px;
}
.form-wrapper {
width: 100%;
max-width: 340px;
.logo-dot {
width: 8px;
height: 8px;
background: #0099ff;
border-radius: 50%;
}
.welcome-header {
margin-bottom: 30px;
.login-content {
flex: 1;
padding: 30px 40px; /* 稍微调小上下 padding 留给表单 */
display: flex;
flex-direction: column;
align-items: center;
}
.avatar-wrapper {
margin-bottom: 24px;
text-align: center;
}
.welcome-header h2 {
font-size: 26px;
font-weight: 700;
color: #1e293b;
margin-bottom: 8px;
.avatar {
width: 72px;
height: 72px;
background: #f0f7ff;
color: #0099ff;
border-radius: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
margin-bottom: 12px;
}
.welcome-header p {
color: #64748b;
.web-title {
font-size: 20px;
font-weight: 600;
color: #333;
margin: 0;
}
.form-body {
width: 100%;
-webkit-app-region: no-drag;
}
.input-area {
border: 1px solid #e5e7eb;
border-radius: 8px;
overflow: hidden;
transition: border-color 0.2s;
}
.input-area:focus-within {
border-color: #0099ff;
}
.classic-input {
width: 100%;
height: 44px; /* 从 48px 调小到 44px,为了不显得太拥挤 */
border: none;
padding: 0 16px;
outline: none;
font-size: 14px;
}
.input {
width: 100%;
margin-bottom: 16px;
.divider {
height: 1px;
background: #f3f4f6;
margin: 0 12px;
}
.login-btn-wrapper {
.options {
display: flex;
justify-content: center;
width: 100%;
margin-top: 24px;
}
.register-hint {
margin-top: 24px;
text-align: center;
justify-content: space-between;
margin-top: 16px;
font-size: 13px;
color: #64748b;
color: #6b7280;
}
.register-hint a {
color: #10b981;
.checkbox-label {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
}
.login-btn {
width: 100%;
height: 46px;
background: #0099ff;
border: none;
border-radius: 8px;
color: white;
font-size: 15px;
font-weight: 600;
text-decoration: none;
margin-top: 24px;
cursor: pointer;
transition: all 0.2s;
}
.register-hint a:hover {
.login-btn:hover:not(:disabled) {
background: #0088ee;
transform: translateY(-1px);
}
.login-btn:active:not(:disabled) {
transform: translateY(0);
}
.login-btn:disabled {
background: #93c5fd;
cursor: not-allowed;
}
.footer-links {
margin-top: auto;
font-size: 13px;
color: #9ca3af;
}
.link {
color: #0099ff;
text-decoration: none;
margin-left: 4px;
}
.link:hover {
text-decoration: underline;
}
/* Responsive Design */
@media (max-width: 960px) {
.login-card {
flex-direction: column;
width: 90%;
margin: 20px;
height: auto;
border-radius: 16px;
}
.side-visual {
padding: 30px;
min-height: 160px;
}
.hero-title { font-size: 28px; }
.hero-subtitle, .visual-footer { display: none; }
.side-form { padding: 40px 20px; }
}
</style>
@@ -14,10 +14,10 @@
<div class="icon-box orange" v-html="feather.icons['user-plus'].toSvg()"></div>
<div class="name">新的朋友</div>
</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="name">群聊</div>
</div>
<div class="name">群聊通知</div>
</RouterLink>
<div class="list-item mini">
<div class="icon-box blue" v-html="feather.icons['tag'].toSvg()"></div>
<div class="name">标签</div>
@@ -104,9 +104,8 @@ const filteredContacts = computed(() => {
// 发送事件给父组件(用于切换回聊天Tab并打开会话)
const emit = defineEmits(['start-chat'])
const showGroupList = () => {
groupModal.value = true;
}
// const showGroupList = () => {
// }
@@ -1,6 +1,10 @@
<template>
<div class="minimal-page">
<WindowControls/>
<div class="request-container">
<div class="content-limit">
<div class="section-title">申请列表</div>
<div class="request-group">
@@ -51,6 +55,9 @@
</div>
</div>
</div>
</div>
</template>
<script setup>
@@ -61,6 +68,7 @@ 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';
import WindowControls from '../../components/WindowControls.vue';
const message = useMessage();
const authStore = useAuthStore();
@@ -132,6 +140,11 @@ onMounted(async () => {
width: 100%;
height: 100%;
background-color: #f5f5f5; /* 极致白 */
}
.request-container {
width: 100%;
height: 100%;
background-color: #f5f5f5; /* 极致白 */
display: flex;
justify-content: center;
overflow-y: auto;
@@ -0,0 +1,393 @@
<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 groupRequest" :key="item.requestId" class="minimal-item">
<div class="avatar-wrapper">
<img
:src="avatarHandle(item)"
:class="[
'avatar',
item.type === GROUP_REQUEST_STATUS.IS_GROUP ||
item.type === GROUP_REQUEST_STATUS.IS_USER
? 'is-group'
: 'is-user'
]"
/>
</div>
<div class="info">
<div class="title-row">
<span class="name">{{ item.name }}</span>
<span
:class="[
'type-tag',
item.type === GROUP_REQUEST_TYPE.INVITE ||
item.type === GROUP_REQUEST_TYPE.IS_USER
? 'tag-orange'
: 'tag-green'
]"
>
{{ getTypeText(item.type) }}
</span>
<span class="date">14:20</span>
</div>
<div class="group-info">
<span class="label">目标群聊</span>
<span class="group-name">{{ item.groupName }}</span>
</div>
<div
v-if="[GROUP_REQUEST_TYPE.INVITE, GROUP_REQUEST_TYPE.INVITED].includes(item.type)"
class="group-info"
>
<span class="label">目标用户</span>
<span class="group-name">{{
myInfo.id === item.userId ? item.inviteUserNickname : item.nickName
}}</span>
</div>
<p class="sub-text">入群描述{{ item.description }}</p>
</div>
<div
v-if="![GROUP_REQUEST_TYPE.INVITE, GROUP_REQUEST_TYPE.IS_USER].includes(item.type)"
class="actions"
>
<button class="btn-text btn-reject">忽略</button>
<button class="btn-text btn-accept">去处理</button>
</div>
<div class="actions" v-else>
<div class="status-tag" :class="getGroupRequestStatusClass(item.status)">
<span>{{ getGroupRequestStatusTxt(item.status) }}</span>
</div>
</div>
</div>
</div>
<div class="footer-hint">仅保留最近 30 天的通知记录</div>
</div>
</div>
</div>
</template>
<script setup>
import { onMounted, computed } from 'vue';
import WindowControls from '../../components/WindowControls.vue';
import { useGroupRequestStore } from '../../stores/groupRequest';
import { useAuthStore } from '../../stores/auth';
import { GROUP_REQUEST_TYPE, getTypeText } from '../../constants/groupRequestTypeDefine';
import { GROUP_REQUEST_STATUS, getGroupRequestStatusTxt } from '../../constants/GroupDefine';
const groupRequestStore = useGroupRequestStore()
const myInfo = useAuthStore().userInfo
const groupRequest = computed(() => {
if(!groupRequestStore.groupRequest){
return [];
}
return groupRequestStore.groupRequest.map((item) => {
return { ...item, type: getRequestType(item) }
})
})
const getGroupRequestStatusClass = (status) => {
const classMap = {
[GROUP_REQUEST_STATUS.PENDING]: 'status-pending',
[GROUP_REQUEST_STATUS.TARGET_PENDING]: 'status-pending',
[GROUP_REQUEST_STATUS.PASSED]: 'status-passed',
[GROUP_REQUEST_STATUS.DECLINED]: 'status-declined',
[GROUP_REQUEST_STATUS.TARGET_DECLINED]: 'status-declined',
};
return classMap[status] || '';
};
const avatarHandle = (request) => {
switch(request.type){
case GROUP_REQUEST_STATUS.IS_GROUP:
case GROUP_REQUEST_STATUS.IS_USER:
return request.groupAvatar;
case GROUP_REQUEST_STATUS.INVITE:
return request.userAvatar;
case GROUP_REQUEST_STATUS.INVITED:
return request.inviteUserAvatar;
default:
return request.groupAvatar;
}
}
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;
}
/* 基础容器 */
.status-tag {
display: inline-flex;
align-items: center;
padding: 4px 12px;
border-radius: 10px;
font-size: 13px;
font-weight: 500;
line-height: 1.2;
width: fit-content;
}
/* 待处理:蓝色或橙色 */
.status-pending {
background-color: #fff7e6;
color: #fa8c16;
border: 1px solid #ffd591;
}
/* 已通过:绿色 */
.status-passed {
background-color: #f6ffed;
color: #52c41a;
border: 1px solid #b7eb8f;
}
/* 已拒绝 / 失败:灰色或红色 */
.status-declined {
background-color: #fff1f0;
color: #f5222d;
border: 1px solid #ffa39e;
}
/* 默认 / 禁用状态 */
.action-disabled {
opacity: 0.8;
cursor: not-allowed;
}
</style>
@@ -19,7 +19,8 @@
<div v-for="s in filteredSessions" :key="s.id"
class="list-item" :class="{active: activeId == s.id}" @click="selectSession(s)">
<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>
</div>
<div class="info">
@@ -35,7 +36,7 @@
<RouterView></RouterView>
<SearchUser v-model="searchUserModal"/>
<CreateGroup v-model="createGroupModal"></CreateGroup>
<CreateGroup v-model="createGroupModal" @submit="createGroupSubmitHandler"></CreateGroup>
</div>
</template>
@@ -51,10 +52,17 @@ import SearchUser from '@/components/user/SearchUser.vue'
import CreateGroup from '@/components/groups/CreateGroup.vue'
import { useBrowserNotification } from '@/services/useBrowserNotification'
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 router = useRouter();
const browserNotification = useBrowserNotification();
const message = useMessage()
const cacheStore = useCacheStore()
const searchQuery = ref('')
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)))
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; }
/* 未读气泡 */
@@ -18,29 +18,19 @@
@retry="loadHistoryMsg" />
<UserHoverCard ref="userHoverCardRef" />
<ContextMenu ref="menuRef" />
<Teleport to="body">
<Transition name="fade">
<div v-if="videoOpen" class="video-overlay" @click.self="videoOpen = false">
<div class="video-dialog">
<div class="close-bar" @click="videoOpen = false">
<span>正在播放视频</span>
<button class="close-btn">&times;</button>
</div>
<div class="player-wrapper">
<vue3-video-player :src="videoUrl" poster="https://xxx.jpg" :controls="true" :autoplay="true" />
</div>
</div>
</div>
</Transition>
</Teleport>
<VideoPreview v-if="videoOpen" :videoData="videoUrl" @close="videoClose" />
<div v-for="m in chatStore.messages" :key="m.id" :class="['msg', m.senderId == myInfo.id ? 'mine' : 'other']">
<img @mouseenter="(e) => handleHoverCard(e, m)" @mouseleave="closeHoverCard"
:src="(m.senderId == myInfo.id ? (myInfo?.avatar || defaultAvatar) : m.chatType == MESSAGE_TYPE.GROUP ? m.senderAvatar : conversationInfo?.targetAvatar) ?? defaultAvatar"
class="avatar-chat" />
<!-- <img @mouseenter="(e) => handleHoverCard(e, m)" @mouseleave="closeHoverCard"
:src="(m.senderId == myInfo.id ? myInfo?.avatar : m.chatType == MESSAGE_TYPE.GROUP ? m.senderAvatar : conversationInfo?.targetAvatar)"
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="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-else-if="m.type === 'emoji'" class="emoji-msg">{{ m.content }}</div>
<div v-else-if="m.type === FILE_TYPE.Image" class="image-msg-container" :style="getImageStyle(m.content)">
<img class="image-msg-content" :src="m.isImgLoading || m.isLoading ? m.localUrl : m.content.thumb"
alt="图片消息" @click="imagePreview(m)">
<!-- <img class="image-msg-content" :src="m.isImgLoading || m.isLoading ? m.localUrl : m.content.thumb"
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" class="progress-box">
@@ -89,7 +82,8 @@
<footer class="chat-footer">
<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>
<label class="tool-btn">
<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 { isElectron } from '../../../utils/electronHelper';
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({
@@ -177,16 +174,11 @@ const videoUrl = ref(null);
const videoOpen = ref(false)
const infoShowHandler = async () => {
if (infoSideBarShow.value){
if (infoSideBarShow.value) {
infoSideBarShow.value = false;
return;
}
groupData = conversationInfo.value
if (conversationInfo.value.chatType == MESSAGE_TYPE.GROUP) {
const { data } = await groupService.getGroupMember(groupData.targetId)
groupData.members = data
}
infoSideBarShow.value = true
}
@@ -217,17 +209,18 @@ const getImageStyle = (content) => {
};
};
const imagePreview = (m) => {
const imagePreview = (e, m) => {
e.stopPropagation();
const imageList = chatStore.messages
.filter(x => x.type == 'Image')
;
const index = imageList.indexOf(m);
if (isElectron()) {
const safeData = JSON.parse(JSON.stringify( {
const safeData = JSON.parse(JSON.stringify({
imageList,
index
}));
window.api.window.newWindow('imgpre',safeData);
window.api.window.newWindow('imgpre', safeData);
} else {
previewImages({
imgList: imageList.map(m => m.content.url),
@@ -279,9 +272,17 @@ const stopRecord = async () => {
const playHandler = (m) => {
videoUrl.value = m.content.url
if (isElectron()) {
window.api.window.newWindow('videopre', videoUrl.value)
return
}
videoOpen.value = true
}
const videoClose = () => {
videoOpen.value = false
}
const loadHistoryMsg = async () => {
// 1. 如果正在加载,或者已经彻底没数据了,才拦截
if (isLoading.value || isFinished.value) return;
@@ -326,34 +327,7 @@ const closeHoverCard = () => {
const handleRightClick = (e, m) => {
e.stopPropagation();
const items = [
{
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);
menuRef.value.show(e, useRightClickHandler(e, m));
}
watch(
@@ -593,75 +567,7 @@ onUnmounted(() => {
-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 {
/* 允许拖动整个窗口 */
@@ -724,7 +630,7 @@ onUnmounted(() => {
}
/* 图片:填满容器但不拉伸 */
.image-msg-content {
:deep(.image-msg-content) {
width: 100%;
height: 100%;
object-fit: cover;
@@ -875,7 +781,7 @@ onUnmounted(() => {
/* background: #95ec69; */
}
.avatar-chat {
:deep(.avatar-chat) {
width: 38px;
height: 38px;
border-radius: 4px;
@@ -1,21 +1,41 @@
export function useRightClickHandler() {
const items = [
import { FILE_TYPE } from '../../../../constants/fileTypeDefine'
export function useRightClickHandler(e, m) {
const textRightItem = [
{
label: '查看资料',
action: () => console.log('打开之前的悬浮卡片', user)
label: '复制',
action: async () => {
await navigator.clipboard.writeText(e.target.innerText)
}
},
{
label: '发送消息',
action: () => console.log('进入私聊', user.id)
label: '引用',
action: () => console.log('进入私聊')
},
{
label: '修改备注',
action: () => { }
label: '转发',
action: () => {}
},
{
label: '删除好友',
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;
}
}
@@ -62,7 +62,17 @@
</div>
</div>
</div>
<div class="settings-group">
<h3 class="group-title">账号管理</h3>
<div class="setting-item">
<div class="info">
<span class="label">退出账号</span>
</div>
<button class="warn-btn" @click="logout">
退出
</button>
</div>
</div>
<div class="settings-group">
<h3 class="group-title">存储与缓存</h3>
<div class="setting-item">
@@ -80,9 +90,13 @@
<script setup>
import { reactive, ref } from 'vue'
import { useAuthStore } from '../../stores/auth'
import { useRouter } from 'vue-router'
const cacheSize = ref(124.5)
const isClearing = ref(false)
const authStore = useAuthStore()
const router = useRouter()
const settings = reactive({
theme: 'system',
@@ -91,6 +105,11 @@ const settings = reactive({
closeBehavior: 'tray'
})
const logout = () => {
authStore.logout()
router.push('/auth/login')
}
const clearCache = () => {
isClearing.value = true
setTimeout(() => {
@@ -190,6 +209,16 @@ const clearCache = () => {
transition: 0.2s;
}
.warn-btn {
background: #fff;
border: 1px solid #ff4d4f;
color: #ff4d4f;
padding: 6px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
}
.ghost-btn:hover:not(:disabled) {
border-color: #007bff;
color: #007bff;
@@ -202,7 +231,11 @@ const clearCache = () => {
height: 20px;
}
.switch input { opacity: 0; width: 0; height: 0; }
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
@@ -225,6 +258,11 @@ const clearCache = () => {
border-radius: 50%;
}
input:checked + .slider { background-color: #007bff; }
input:checked + .slider:before { transform: translateX(16px); }
input:checked+.slider {
background-color: #007bff;
}
input:checked+.slider:before {
transform: translateX(16px);
}
</style>