后端:

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

After

Width:  |  Height:  |  Size: 72 KiB

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