后端:

完善注册页面
This commit is contained in:
西街长安 2026-03-15 14:55:29 +08:00
parent f7223dc590
commit d1db5e6490
77 changed files with 5431 additions and 1190 deletions

View File

@ -14,7 +14,7 @@ using System.Reflection;
[assembly: System.Reflection.AssemblyCompanyAttribute("IMTest")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+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")]

View File

@ -1 +1 @@
ed4980dfc7aff253176b260ed9015f9a80b52e92cbf3095eff3ed06865ea6e0d
546570633bb9288fc2957cbb29a807a45f3e48ba127ec13bc3956d28f5e6ed5b

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@ -1,11 +0,0 @@
namespace IM_API.Models
{
public partial class GroupInvite
{
public GroupInviteState StateEnum
{
get => (GroupInviteState)State;
set => State = (sbyte)value;
}
}
}

View File

@ -1,17 +0,0 @@
namespace IM_API.Models
{
/// <summary>
/// 群邀请状态
/// </summary>
public enum GroupInviteState
{
/// <summary>
/// 待处理
/// </summary>
Pending = 0,
/// <summary>
/// 已同意
/// </summary>
Passed = 1
}
}

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

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

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

View File

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

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

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

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

View File

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

View File

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

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

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
frontend/pc/IM/src/cache/cacheDir.js vendored Normal file
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',
}

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
frontend/pc/IM/src/cache/protocolReg.js vendored Normal file
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}`)
})
}

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
@ -59,7 +63,8 @@ app.whenReady().then(() => {
// IPC test
ipcMain.on('ping', () => console.log('pong'))
registerWindowHandler()
registerWindowHandler()
registerCacheHandler()
createWindow()

View File

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

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,

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

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,13 +53,13 @@ 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">
<img :src="f.userInfo.avatar" class="avatar" />
@ -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>
@ -80,8 +90,8 @@ onMounted(async () =>{
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
header {
padding: 12px 16px; display: flex; justify-content: space-between;
header {
padding: 12px 16px; display: flex; justify-content: space-between;
background: #f9f9f9; font-weight: bold; font-size: 14px;
}
@ -110,4 +120,4 @@ footer { padding: 12px; }
border: none; border-radius: 6px; font-weight: bold; cursor: pointer;
}
.btn:disabled { background: #e1e1e1; color: #999; cursor: not-allowed; }
</style>
</style>

View File

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

View File

@ -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 '未知状态';
}
};

View File

@ -36,5 +36,6 @@ export const FILE_TYPE = Object.freeze({
Image: 'Image',
Video: 'Video',
Voice: 'Voice',
File: 'File'
File: 'File',
TEXT: 'Text'
});

View File

@ -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:
'未知状态'
}
}

View File

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

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
}

View File

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

View File

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

View File

@ -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: () => ({

View File

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

View File

@ -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']);
}
if (!db.objectStoreNames.contains(CONVERSARION_STORE_NAME)) {
const store = db.createObjectStore(CONVERSARION_STORE_NAME, { keyPath: 'id' });
store.createIndex('by-id', 'id');
}
if (!db.objectStoreNames.contains(CONTACT_STORE_NAME)) {
const store = db.createObjectStore(CONTACT_STORE_NAME, { keyPath: 'id' });
store.createIndex('by-id', 'id');
store.createIndex('by-username', 'username');
store.createIndex('by-friendId', 'friendId', { unique: true });
}
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'])
}
})
if (!db.objectStoreNames.contains(CONVERSARION_STORE_NAME)) {
const store = db.createObjectStore(CONVERSARION_STORE_NAME, { keyPath: 'id' })
store.createIndex('by-id', 'id')
}
if (!db.objectStoreNames.contains(CONTACT_STORE_NAME)) {
const store = db.createObjectStore(CONTACT_STORE_NAME, { keyPath: 'id' })
store.createIndex('by-id', 'id')
store.createIndex('by-username', 'username')
store.createIndex('by-friendId', 'friendId', { unique: true })
}
if (!db.objectStoreNames.contains(GROUP_REQUEST_STORE_NAME)) {
const store = db.createObjectStore(GROUP_REQUEST_STORE_NAME, { keyPath: 'requestId' })
store.createIndex('by-id', 'requestId')
store.createIndex('by-userid', 'userId')
store.createIndex('by-groupid', 'groupId')
}
}
})

View File

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

View File

@ -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; }
/* 未读气泡 */

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>

View File

@ -1,48 +1,58 @@
<template>
<div class="login-layout">
<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-container">
<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" 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>
<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>
</div>
</form>
<div class="register-hint">
还没有账号? <router-link to="/auth/register">立即注册</router-link>
<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="footer-links">
<span>还没有账号?</span>
<router-link to="/auth/register" class="link">立即注册</router-link>
</div>
</div>
</div>
</div>
</template>
@ -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>

View File

@ -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" 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>
<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>
</div>
</form>
<div class="register-hint">
已有账号? <router-link to="/auth/login">直接登录</router-link>
<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="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) {
message.error(v$.value.$errors[0].$message)
}
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;
const res = await authService.register(registerData);
// confirmPassword agree
const res = await authService.register({
username: form.username,
email: form.email,
password: form.password,
nickName: form.nickName
});
if(res.code === 0){
message.success('注册成功,请登录')
router.push('/auth/login')
}else{
message.error(res.message || '注册失败')
message.success('注册成功,请登录')
router.push('/auth/login')
} 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;
/* 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;
}
}
.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;
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>

View File

@ -7,17 +7,17 @@
<input v-model="searchQuery" placeholder="搜索联系人" />
</div>
</div>
<div class="scroll-area">
<div class="fixed-entries">
<RouterLink class="list-item mini" to="/contacts/requests">
<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>
@ -27,7 +27,7 @@
<button class="group-title" :class="{'group-title-active': contactTab === 0}" @click="contactTab = 0">我的好友</button>
<button class="group-title" :class="{'group-title-active': contactTab === 1}" @click="contactTab = 1">群聊</button>
</div>
<contactShow v-if="contactTab == 0" :contacts="filteredContacts"></contactShow>
<groupsShow v-if="contactTab == 1" :groups="myGroups"></groupsShow>
</div>
@ -35,8 +35,8 @@
<RouterView></RouterView>
</div>
<Transition>
<GroupChatModal
v-if="groupModal"
<GroupChatModal
v-if="groupModal"
@close="groupModal = false"
@select="handleChatSelect"
/>
@ -104,9 +104,8 @@ const filteredContacts = computed(() => {
// Tab
const emit = defineEmits(['start-chat'])
const showGroupList = () => {
groupModal.value = true;
}
// const showGroupList = () => {
// }
@ -206,8 +205,8 @@ onMounted(async () => {
}
/* 去除 hover、active 等状态的效果 */
a:hover,
a:active,
a:hover,
a:active,
a:focus {
text-decoration: none;
color: inherit; /* 保持颜色不变 */
@ -237,4 +236,4 @@ a:focus {
.icon-box.orange { background: #faad14; }
.icon-box.green { background: #52c41a; }
.icon-box.blue { background: #1890ff; }
</style>
</style>

View File

@ -1,6 +1,10 @@
<template>
<div class="minimal-page">
<div class="content-limit">
<WindowControls/>
<div class="request-container">
<div class="content-limit">
<div class="section-title">申请列表</div>
<div class="request-group">
@ -50,6 +54,9 @@
</div>
</div>
</div>
</div>
</div>
</template>
@ -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();
@ -104,7 +112,7 @@ const handleFriendRequest = async (action) => {
const res = await friendService.handleFriendRequest(activeItem.value.id,action,activeItem.value.remarkName);
if(res.code == SYSTEM_BASE_STATUS.SUCCESS){
switch(action){
case FRIEND_ACTIONS.Accept:
case FRIEND_ACTIONS.Accept:
message.show('添加好友成功');
break;
case FRIEND_ACTIONS.Reject:
@ -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;
@ -320,4 +333,4 @@ onMounted(async () => {
border-radius: 10px;
cursor: pointer;
}
</style>
</style>

View File

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

View File

@ -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; }
/* 未读气泡 */

View File

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

View File

@ -1,21 +1,41 @@
export function useRightClickHandler() {
const items = [
{
label: '查看资料',
action: () => console.log('打开之前的悬浮卡片', user)
},
{
label: '发送消息',
action: () => console.log('进入私聊', user.id)
},
{
label: '修改备注',
action: () => { }
},
{
label: '删除好友',
type: 'danger',
action: () => alert('删除成功')
}
];
}
import { FILE_TYPE } from '../../../../constants/fileTypeDefine'
export function useRightClickHandler(e, m) {
const textRightItem = [
{
label: '复制',
action: async () => {
await navigator.clipboard.writeText(e.target.innerText)
}
},
{
label: '引用',
action: () => console.log('进入私聊')
},
{
label: '转发',
action: () => {}
},
{
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;
}
}

View File

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