From 77b19a8157e187a0ef5fe583264c5080dc21184e Mon Sep 17 00:00:00 2001 From: richjohnrambo Date: Fri, 21 Aug 2026 16:34:55 +0800 Subject: [PATCH] feat: complete IM group governance actions --- docs/backstage/IM-未实现增强项.md | 27 +- server/api/admin/imgroup/im_group.go | 51 ++ server/internal/consts/im_group.go | 22 + .../internal/controller/admin/sys/im_group.go | 45 ++ server/internal/logic/sys/im_group.go | 441 ++++++++++++++++++ server/internal/model/input/sysin/im_group.go | 96 ++++ server/internal/service/sys.go | 12 + .../storage/data/generate/im_group_menu.sql | 24 + web/src/api/imGroup/index.ts | 54 +++ web/src/views/imGroup/governance.vue | 130 +++++- web/src/views/imGroup/members.vue | 62 ++- web/src/views/imGroup/model.ts | 34 ++ 12 files changed, 981 insertions(+), 17 deletions(-) diff --git a/docs/backstage/IM-未实现增强项.md b/docs/backstage/IM-未实现增强项.md index 708e2f3..cb14c15 100644 --- a/docs/backstage/IM-未实现增强项.md +++ b/docs/backstage/IM-未实现增强项.md @@ -13,7 +13,7 @@ 4. 审计日志:列表、详情、导出。 5. Outbox 运维:列表、详情、重试、标记死信,覆盖 `im_business_events_outbox` 和 `im_group_events_outbox`。 6. 租户边界:用户、审计、群、实名、工单、Outbox 等后台接口已按登录态解析租户范围。 -7. 群治理处置:治理配置更新、解散群、成员禁言、解除禁言。 +7. 群治理处置:治理配置更新、解散群、成员禁言、解除禁言、入群审批、管理员设置/取消、群主转让、群文件删除。 ## 剩余 P0 @@ -123,7 +123,7 @@ ### P1-1 群治理处置动作 -状态:部分实现。 +状态:已完成基础后台闭环。 已实现: @@ -131,20 +131,19 @@ 2. 成员列表。 3. 治理配置查看。 4. 入群申请、邀请、公告、禁言、管理日志列表。 +5. 治理配置更新。 +6. 全员禁言开关。 +7. 单人禁言/解禁。 +8. 解散群。 +9. 入群审批通过/拒绝。 +10. 设置/取消管理员。 +11. 转让群主。 +12. 群文件列表和删除处置。 -未实现: +待 IM 服务端配合确认: -1. 入群审批通过/拒绝。 -2. 设置/取消管理员。 -3. 转让群主。 -4. 群文件处置。 - -已增强: - -1. 治理配置更新。 -2. 全员禁言开关。 -3. 单人禁言/解禁。 -4. 解散群。 +1. 后台写入 `im_group_events_outbox` 后,IM 服务端消费者需同步在线会话、群资料缓存和成员 ACL。 +2. 群文件删除当前为业务索引软删除,底层对象文件物理清理归入 P1-2 文件治理。 ### P1-2 文件治理 diff --git a/server/api/admin/imgroup/im_group.go b/server/api/admin/imgroup/im_group.go index c32bb62..44f4919 100644 --- a/server/api/admin/imgroup/im_group.go +++ b/server/api/admin/imgroup/im_group.go @@ -86,6 +86,38 @@ type UnmuteReq struct { type UnmuteRes struct{} +// JoinRequestApproveReq 通过IM入群申请 +type JoinRequestApproveReq struct { + g.Meta `path:"/imGroup/joinRequestApprove" method:"post" tags:"IM群组" summary:"通过IM入群申请"` + sysin.ImGroupJoinRequestResolveInp +} + +type JoinRequestApproveRes struct{} + +// JoinRequestRejectReq 拒绝IM入群申请 +type JoinRequestRejectReq struct { + g.Meta `path:"/imGroup/joinRequestReject" method:"post" tags:"IM群组" summary:"拒绝IM入群申请"` + sysin.ImGroupJoinRequestResolveInp +} + +type JoinRequestRejectRes struct{} + +// RoleUpdateReq 更新IM群成员角色 +type RoleUpdateReq struct { + g.Meta `path:"/imGroup/roleUpdate" method:"post" tags:"IM群组" summary:"更新IM群成员角色"` + sysin.ImGroupRoleUpdateInp +} + +type RoleUpdateRes struct{} + +// OwnerTransferReq 转让IM群主 +type OwnerTransferReq struct { + g.Meta `path:"/imGroup/ownerTransfer" method:"post" tags:"IM群组" summary:"转让IM群主"` + sysin.ImGroupOwnerTransferInp +} + +type OwnerTransferRes struct{} + // JoinRequestListReq 查询IM入群申请列表 type JoinRequestListReq struct { g.Meta `path:"/imGroup/joinRequestList" method:"get" tags:"IM群组" summary:"获取IM入群申请列表"` @@ -97,6 +129,25 @@ type JoinRequestListRes struct { List []*sysin.ImGroupJoinRequestListModel `json:"list" dc:"数据列表"` } +// FileListReq 查询IM群文件列表 +type FileListReq struct { + g.Meta `path:"/imGroup/fileList" method:"get" tags:"IM群组" summary:"获取IM群文件列表"` + sysin.ImGroupFileListInp +} + +type FileListRes struct { + form.PageRes + List []*sysin.ImGroupFileListModel `json:"list" dc:"数据列表"` +} + +// FileDeleteReq 删除IM群文件 +type FileDeleteReq struct { + g.Meta `path:"/imGroup/fileDelete" method:"post" tags:"IM群组" summary:"删除IM群文件"` + sysin.ImGroupFileDeleteInp +} + +type FileDeleteRes struct{} + // InvitationListReq 查询IM群邀请列表 type InvitationListReq struct { g.Meta `path:"/imGroup/invitationList" method:"get" tags:"IM群组" summary:"获取IM群邀请列表"` diff --git a/server/internal/consts/im_group.go b/server/internal/consts/im_group.go index 1649dd9..d555c60 100644 --- a/server/internal/consts/im_group.go +++ b/server/internal/consts/im_group.go @@ -19,6 +19,8 @@ func init() { dict.RegisterEnums("ImGroupMuteStatusOptions", "IM禁言状态选项", ImGroupMuteStatusOptions) dict.RegisterEnums("ImGroupAnnouncementStatusOptions", "IM群公告状态选项", ImGroupAnnouncementStatusOptions) dict.RegisterEnums("ImGroupOutboxStatusOptions", "IM群事件状态选项", ImGroupOutboxStatusOptions) + dict.RegisterEnums("ImGroupFileStatusOptions", "IM群文件状态选项", ImGroupFileStatusOptions) + dict.RegisterEnums("ImGroupFileTypeOptions", "IM群文件类型选项", ImGroupFileTypeOptions) } var ImGroupStateOptions = []*model.Option{ @@ -58,6 +60,14 @@ const ( ImGroupOutboxStatusPending = "pending" ImGroupOutboxStatusDelivered = "delivered" ImGroupOutboxStatusFailed = "failed" + + ImGroupFileStatusActive = "active" + ImGroupFileStatusDeleted = "deleted" + + ImGroupFileTypeFile = "file" + ImGroupFileTypeImage = "image" + ImGroupFileTypeVideo = "video" + ImGroupFileTypeAudio = "audio" ) var ImGroupStatusOptions = []*model.Option{ @@ -103,3 +113,15 @@ var ImGroupOutboxStatusOptions = []*model.Option{ dict.GenSuccessOption(ImGroupOutboxStatusDelivered, "已投递"), dict.GenErrorOption(ImGroupOutboxStatusFailed, "投递失败"), } + +var ImGroupFileStatusOptions = []*model.Option{ + dict.GenSuccessOption(ImGroupFileStatusActive, "正常"), + dict.GenDefaultOption(ImGroupFileStatusDeleted, "已删除"), +} + +var ImGroupFileTypeOptions = []*model.Option{ + dict.GenDefaultOption(ImGroupFileTypeFile, "文件"), + dict.GenInfoOption(ImGroupFileTypeImage, "图片"), + dict.GenPrimaryOption(ImGroupFileTypeVideo, "视频"), + dict.GenWarningOption(ImGroupFileTypeAudio, "音频"), +} diff --git a/server/internal/controller/admin/sys/im_group.go b/server/internal/controller/admin/sys/im_group.go index ba56d2b..f9a39b4 100644 --- a/server/internal/controller/admin/sys/im_group.go +++ b/server/internal/controller/admin/sys/im_group.go @@ -99,6 +99,30 @@ func (c *cImGroup) Unmute(ctx context.Context, req *imgroup.UnmuteReq) (res *img return } +// JoinRequestApprove 通过IM入群申请 +func (c *cImGroup) JoinRequestApprove(ctx context.Context, req *imgroup.JoinRequestApproveReq) (res *imgroup.JoinRequestApproveRes, err error) { + err = service.SysImGroup().JoinRequestApprove(ctx, &req.ImGroupJoinRequestResolveInp) + return +} + +// JoinRequestReject 拒绝IM入群申请 +func (c *cImGroup) JoinRequestReject(ctx context.Context, req *imgroup.JoinRequestRejectReq) (res *imgroup.JoinRequestRejectRes, err error) { + err = service.SysImGroup().JoinRequestReject(ctx, &req.ImGroupJoinRequestResolveInp) + return +} + +// RoleUpdate 更新IM群成员角色 +func (c *cImGroup) RoleUpdate(ctx context.Context, req *imgroup.RoleUpdateReq) (res *imgroup.RoleUpdateRes, err error) { + err = service.SysImGroup().RoleUpdate(ctx, &req.ImGroupRoleUpdateInp) + return +} + +// OwnerTransfer 转让IM群主 +func (c *cImGroup) OwnerTransfer(ctx context.Context, req *imgroup.OwnerTransferReq) (res *imgroup.OwnerTransferRes, err error) { + err = service.SysImGroup().OwnerTransfer(ctx, &req.ImGroupOwnerTransferInp) + return +} + // JoinRequestList 查看IM入群申请列表 func (c *cImGroup) JoinRequestList(ctx context.Context, req *imgroup.JoinRequestListReq) (res *imgroup.JoinRequestListRes, err error) { list, totalCount, err := service.SysImGroup().JoinRequestList(ctx, &req.ImGroupJoinRequestListInp) @@ -114,6 +138,27 @@ func (c *cImGroup) JoinRequestList(ctx context.Context, req *imgroup.JoinRequest return } +// FileList 查看IM群文件列表 +func (c *cImGroup) FileList(ctx context.Context, req *imgroup.FileListReq) (res *imgroup.FileListRes, err error) { + list, totalCount, err := service.SysImGroup().FileList(ctx, &req.ImGroupFileListInp) + if err != nil { + return + } + if list == nil { + list = []*sysin.ImGroupFileListModel{} + } + res = new(imgroup.FileListRes) + res.List = list + res.PageRes.Pack(req, totalCount) + return +} + +// FileDelete 删除IM群文件 +func (c *cImGroup) FileDelete(ctx context.Context, req *imgroup.FileDeleteReq) (res *imgroup.FileDeleteRes, err error) { + err = service.SysImGroup().FileDelete(ctx, &req.ImGroupFileDeleteInp) + return +} + // InvitationList 查看IM群邀请列表 func (c *cImGroup) InvitationList(ctx context.Context, req *imgroup.InvitationListReq) (res *imgroup.InvitationListRes, err error) { list, totalCount, err := service.SysImGroup().InvitationList(ctx, &req.ImGroupInvitationListInp) diff --git a/server/internal/logic/sys/im_group.go b/server/internal/logic/sys/im_group.go index 7e0a760..a8bb969 100644 --- a/server/internal/logic/sys/im_group.go +++ b/server/internal/logic/sys/im_group.go @@ -25,6 +25,12 @@ import ( type sSysImGroup struct{} +const ( + imGroupModeMember = "JRWPS" + imGroupModeAdmin = "JRWPASO" + imGroupModeOwner = "JRWPASDO" +) + func NewSysImGroup() *sSysImGroup { return &sSysImGroup{} } @@ -523,6 +529,262 @@ func (s *sSysImGroup) Unmute(ctx context.Context, in *sysin.ImGroupUnmuteInp) (e }) } +// JoinRequestApprove 通过IM入群申请 +func (s *sSysImGroup) JoinRequestApprove(ctx context.Context, in *sysin.ImGroupJoinRequestResolveInp) (err error) { + if err = in.Filter(ctx); err != nil { + return + } + return s.resolveJoinRequest(ctx, in, consts.ImGroupRequestStatusAccepted) +} + +// JoinRequestReject 拒绝IM入群申请 +func (s *sSysImGroup) JoinRequestReject(ctx context.Context, in *sysin.ImGroupJoinRequestResolveInp) (err error) { + if err = in.Filter(ctx); err != nil { + return + } + return s.resolveJoinRequest(ctx, in, consts.ImGroupRequestStatusRejected) +} + +func (s *sSysImGroup) resolveJoinRequest(ctx context.Context, in *sysin.ImGroupJoinRequestResolveInp, status string) (err error) { + db, _, err := resolveImTenantDB(ctx, in.TenantId) + if err != nil { + return err + } + actor := contexts.GetUserId(ctx) + requestId := gctx.CtxId(ctx) + if requestId == "" { + requestId = guid.S() + } + now := gtime.Now() + return db.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + if err := s.ensureActiveGroup(ctx, tx, in.Topic); err != nil { + return err + } + before, txErr := s.groupJoinRequestSnapshot(ctx, tx, in.Topic, in.RequestId) + if txErr != nil { + return txErr + } + if before == nil { + return gerror.New("入群申请不存在") + } + if before["status"].String() != consts.ImGroupRequestStatusPending { + return gerror.New("入群申请已处理") + } + applicantUserId := before["applicant_user_id"].Int64() + if status == consts.ImGroupRequestStatusAccepted { + if txErr = s.upsertGroupSubscription(ctx, tx, in.Topic, applicantUserId, imGroupModeMember, now); txErr != nil { + return txErr + } + if txErr = s.upsertGroupMember(ctx, tx, in.Topic, applicantUserId, consts.ImGroupRoleMember, "approval", actor, now); txErr != nil { + return txErr + } + } + result, txErr := tx.Exec(`UPDATE im_group_join_requests + SET status=?,reject_reason=?,handled_by=?,handled_at=?,updated_at=?,version=version+1 + WHERE topic=? AND request_id=? AND status=?`, + status, in.RejectReason, actor, now, now, in.Topic, in.RequestId, consts.ImGroupRequestStatusPending) + if txErr != nil { + return txErr + } + if affected, _ := result.RowsAffected(); affected == 0 { + return gerror.New("入群申请已处理") + } + if txErr = s.recountGroupMembers(ctx, tx, in.Topic); txErr != nil { + return txErr + } + after, txErr := s.groupJoinRequestSnapshot(ctx, tx, in.Topic, in.RequestId) + if txErr != nil { + return txErr + } + action := "join_request_reject" + if status == consts.ImGroupRequestStatusAccepted { + action = "join_request_accept" + } + if txErr = s.insertGroupAdminLog(ctx, tx, in.Topic, actor, applicantUserId, action, "join_request", in.RequestId, recordMap(before), recordMap(after), requestId); txErr != nil { + return txErr + } + return s.insertGroupEvent(ctx, tx, in.Topic, actor, "group.join_request."+status, g.Map{ + "topic": in.Topic, "request_id": in.RequestId, "applicant_user_id": applicantUserId, "status": status, "request_id_admin": requestId, + }) + }) +} + +// RoleUpdate 更新IM群成员角色 +func (s *sSysImGroup) RoleUpdate(ctx context.Context, in *sysin.ImGroupRoleUpdateInp) (err error) { + if err = in.Filter(ctx); err != nil { + return + } + if in.Role != consts.ImGroupRoleAdmin && in.Role != consts.ImGroupRoleMember { + return gerror.New("只能设置管理员或普通成员") + } + db, _, err := resolveImTenantDB(ctx, in.TenantId) + if err != nil { + return err + } + actor := contexts.GetUserId(ctx) + requestId := gctx.CtxId(ctx) + if requestId == "" { + requestId = guid.S() + } + modeGiven := imGroupModeMember + action := "member_role_member" + if in.Role == consts.ImGroupRoleAdmin { + modeGiven = imGroupModeAdmin + action = "member_role_admin" + } + now := gtime.Now() + return db.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + if err := s.ensureActiveGroup(ctx, tx, in.Topic); err != nil { + return err + } + before, txErr := s.groupMemberSnapshot(ctx, tx, in.Topic, in.UserId) + if txErr != nil { + return txErr + } + if before == nil || before["member_status"].String() != consts.ImGroupMemberStatusActive { + return gerror.New("成员不在群内或不是正常状态") + } + if before["role"].String() == consts.ImGroupRoleOwner { + return gerror.New("群主角色请通过群主转让处理") + } + if in.Role == consts.ImGroupRoleAdmin { + if txErr = s.ensureAdminLimit(ctx, tx, in.Topic, in.UserId); txErr != nil { + return txErr + } + } + subResult, txErr := tx.Exec(`UPDATE im_subscriptions SET modegiven=?,updatedat=? + WHERE topic=? AND userid=? AND deletedat IS NULL`, modeGiven, now, in.Topic, in.UserId) + if txErr != nil { + return txErr + } + if affected, _ := subResult.RowsAffected(); affected == 0 { + return gerror.New("成员通信订阅不存在") + } + result, txErr := tx.Exec(`UPDATE im_group_members + SET role=?,version=version+1,updated_at=? + WHERE topic=? AND user_id=? AND member_status=?`, + in.Role, now, in.Topic, in.UserId, consts.ImGroupMemberStatusActive) + if txErr != nil { + return txErr + } + if affected, _ := result.RowsAffected(); affected == 0 { + return gerror.New("成员不在群内或不是正常状态") + } + after, txErr := s.groupMemberSnapshot(ctx, tx, in.Topic, in.UserId) + if txErr != nil { + return txErr + } + if txErr = s.insertGroupAdminLog(ctx, tx, in.Topic, actor, in.UserId, action, "member", fmt.Sprintf("%d", in.UserId), recordMap(before), recordMap(after), requestId); txErr != nil { + return txErr + } + return s.insertGroupEvent(ctx, tx, in.Topic, actor, "group.role.updated", g.Map{ + "topic": in.Topic, "user_id": in.UserId, "role": in.Role, "request_id": requestId, + }) + }) +} + +// OwnerTransfer 转让IM群主 +func (s *sSysImGroup) OwnerTransfer(ctx context.Context, in *sysin.ImGroupOwnerTransferInp) (err error) { + if err = in.Filter(ctx); err != nil { + return + } + db, _, err := resolveImTenantDB(ctx, in.TenantId) + if err != nil { + return err + } + actor := contexts.GetUserId(ctx) + requestId := gctx.CtxId(ctx) + if requestId == "" { + requestId = guid.S() + } + now := gtime.Now() + return db.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + beforeGroup, txErr := s.groupGovernanceSnapshot(ctx, tx, in.Topic) + if txErr != nil { + return txErr + } + if beforeGroup == nil { + return gerror.New("群治理配置不存在") + } + if beforeGroup["group_status"].String() == consts.ImGroupStatusDissolved { + return gerror.New("已解散群不能转让群主") + } + previousOwner := beforeGroup["owner_user_id"].Int64() + if previousOwner <= 0 { + return gerror.New("原群主不存在") + } + if previousOwner == in.UserId { + return gerror.New("新群主不能与原群主相同") + } + if txErr = s.ensureActiveGroupMember(ctx, tx, in.Topic, previousOwner); txErr != nil { + return txErr + } + if txErr = s.ensureActiveGroupMember(ctx, tx, in.Topic, in.UserId); txErr != nil { + return txErr + } + beforeOld, txErr := s.groupMemberSnapshot(ctx, tx, in.Topic, previousOwner) + if txErr != nil { + return txErr + } + beforeNew, txErr := s.groupMemberSnapshot(ctx, tx, in.Topic, in.UserId) + if txErr != nil { + return txErr + } + oldSubResult, txErr := tx.Exec(`UPDATE im_subscriptions SET modegiven=?,updatedat=? + WHERE topic=? AND userid=? AND deletedat IS NULL`, imGroupModeAdmin, now, in.Topic, previousOwner) + if txErr != nil { + return txErr + } + if affected, _ := oldSubResult.RowsAffected(); affected == 0 { + return gerror.New("原群主通信订阅不存在") + } + if _, txErr = tx.Exec(`UPDATE im_group_members SET role=?,version=version+1,updated_at=? + WHERE topic=? AND user_id=? AND member_status=?`, consts.ImGroupRoleAdmin, now, in.Topic, previousOwner, consts.ImGroupMemberStatusActive); txErr != nil { + return txErr + } + newSubResult, txErr := tx.Exec(`UPDATE im_subscriptions SET modegiven=?,updatedat=? + WHERE topic=? AND userid=? AND deletedat IS NULL`, imGroupModeOwner, now, in.Topic, in.UserId) + if txErr != nil { + return txErr + } + if affected, _ := newSubResult.RowsAffected(); affected == 0 { + return gerror.New("新群主通信订阅不存在") + } + if _, txErr = tx.Exec(`UPDATE im_group_members SET role=?,version=version+1,updated_at=? + WHERE topic=? AND user_id=? AND member_status=?`, consts.ImGroupRoleOwner, now, in.Topic, in.UserId, consts.ImGroupMemberStatusActive); txErr != nil { + return txErr + } + if _, txErr = tx.Exec(`UPDATE im_topics SET owner=?,updatedat=? WHERE name=?`, in.UserId, now, in.Topic); txErr != nil { + return txErr + } + if _, txErr = tx.Exec(`UPDATE im_group_settings SET owner_user_id=?,version=version+1,updated_at=? WHERE topic=?`, in.UserId, now, in.Topic); txErr != nil { + return txErr + } + afterGroup, txErr := s.groupGovernanceSnapshot(ctx, tx, in.Topic) + if txErr != nil { + return txErr + } + afterOld, txErr := s.groupMemberSnapshot(ctx, tx, in.Topic, previousOwner) + if txErr != nil { + return txErr + } + afterNew, txErr := s.groupMemberSnapshot(ctx, tx, in.Topic, in.UserId) + if txErr != nil { + return txErr + } + if txErr = s.insertGroupAdminLog(ctx, tx, in.Topic, actor, in.UserId, "owner_transfer", "group", in.Topic, g.Map{ + "group": recordMap(beforeGroup), "previous_owner": recordMap(beforeOld), "new_owner": recordMap(beforeNew), + }, g.Map{ + "group": recordMap(afterGroup), "previous_owner": recordMap(afterOld), "new_owner": recordMap(afterNew), + }, requestId); txErr != nil { + return txErr + } + return s.insertGroupEvent(ctx, tx, in.Topic, actor, "group.owner.transferred", g.Map{ + "topic": in.Topic, "previous_owner": previousOwner, "owner": in.UserId, "request_id": requestId, + }) + }) +} + func (s *sSysImGroup) groupGovernanceSnapshot(ctx context.Context, tx gdb.TX, topic string) (res gdb.Record, err error) { return tx.Model("im_group_settings").Safe().Ctx(ctx). Fields( @@ -535,6 +797,41 @@ func (s *sSysImGroup) groupGovernanceSnapshot(ctx context.Context, tx gdb.TX, to One() } +func (s *sSysImGroup) groupJoinRequestSnapshot(ctx context.Context, tx gdb.TX, topic string, requestId string) (res gdb.Record, err error) { + return tx.Model("im_group_join_requests").Safe().Ctx(ctx). + Where("topic", topic). + Where("request_id", requestId). + One() +} + +func (s *sSysImGroup) groupMemberSnapshot(ctx context.Context, tx gdb.TX, topic string, userId int64) (res gdb.Record, err error) { + return tx.Model("im_group_members").Safe().Ctx(ctx). + Where("topic", topic). + Where("user_id", userId). + One() +} + +func (s *sSysImGroup) groupFileSnapshot(ctx context.Context, tx gdb.TX, topic string, groupFileId string) (res gdb.Record, err error) { + return tx.Model("im_group_files").Safe().Ctx(ctx). + Where("topic", topic). + Where("group_file_id", groupFileId). + One() +} + +func (s *sSysImGroup) ensureActiveGroup(ctx context.Context, tx gdb.TX, topic string) error { + status, err := tx.Model("im_group_settings").Safe().Ctx(ctx).Where("topic", topic).Value("group_status") + if err != nil { + return err + } + if status.IsNil() { + return gerror.New("群治理配置不存在") + } + if status.String() == consts.ImGroupStatusDissolved { + return gerror.New("已解散群不能执行该操作") + } + return nil +} + func (s *sSysImGroup) groupMuteSnapshot(ctx context.Context, tx gdb.TX, topic string, userId int64) (res gdb.Record, err error) { return tx.Model("im_group_mutes").Safe().Ctx(ctx). Fields( @@ -561,6 +858,69 @@ func (s *sSysImGroup) ensureActiveGroupMember(ctx context.Context, tx gdb.TX, to return nil } +func (s *sSysImGroup) ensureAdminLimit(ctx context.Context, tx gdb.TX, topic string, userId int64) error { + currentRole, err := tx.Model("im_group_members").Safe().Ctx(ctx). + Where("topic", topic). + Where("user_id", userId). + Where("member_status", consts.ImGroupMemberStatusActive). + Value("role") + if err != nil { + return err + } + if currentRole.String() == consts.ImGroupRoleAdmin { + return nil + } + maxAdminCount, err := tx.Model("im_group_settings").Safe().Ctx(ctx).Where("topic", topic).Value("max_admin_count") + if err != nil { + return err + } + if maxAdminCount.Int() <= 0 { + return nil + } + adminCount, err := tx.Model("im_group_members").Safe().Ctx(ctx). + Where("topic", topic). + Where("role", consts.ImGroupRoleAdmin). + Where("member_status", consts.ImGroupMemberStatusActive). + Count() + if err != nil { + return err + } + if adminCount >= maxAdminCount.Int() { + return gerror.New("群管理员数量已达上限") + } + return nil +} + +func (s *sSysImGroup) upsertGroupSubscription(ctx context.Context, tx gdb.TX, topic string, userId int64, modeGiven string, now *gtime.Time) error { + _, err := tx.Exec(`INSERT INTO im_subscriptions(createdat,updatedat,deletedat,userid,topic,modewant,modegiven,private) + VALUES(?,?,NULL,?,?,?,?,CAST('{}' AS JSON)) + ON DUPLICATE KEY UPDATE createdat=VALUES(createdat),updatedat=VALUES(updatedat),deletedat=NULL, + modewant=VALUES(modewant),modegiven=VALUES(modegiven),delid=0,recvseqid=0,readseqid=0`, + now, now, userId, topic, imGroupModeMember, modeGiven) + return err +} + +func (s *sSysImGroup) upsertGroupMember(ctx context.Context, tx gdb.TX, topic string, userId int64, role string, joinSource string, joinedBy int64, now *gtime.Time) error { + _, err := tx.Exec(`INSERT INTO im_group_members(topic,user_id,role,member_status,join_source,joined_by,joined_at,version,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?) + ON DUPLICATE KEY UPDATE role=VALUES(role),member_status=VALUES(member_status),join_source=VALUES(join_source), + joined_by=VALUES(joined_by),joined_at=VALUES(joined_at),left_at=NULL,removed_by=NULL,version=version+1,updated_at=VALUES(updated_at)`, + topic, userId, role, consts.ImGroupMemberStatusActive, joinSource, joinedBy, now, 1, now, now) + return err +} + +func (s *sSysImGroup) recountGroupMembers(ctx context.Context, tx gdb.TX, topic string) error { + if _, err := tx.Exec(`UPDATE im_group_settings + SET member_count=(SELECT COUNT(*) FROM im_group_members WHERE topic=? AND member_status=?),updated_at=CURRENT_TIMESTAMP(3) + WHERE topic=?`, topic, consts.ImGroupMemberStatusActive, topic); err != nil { + return err + } + _, err := tx.Exec(`UPDATE im_topics + SET subcnt=(SELECT COUNT(*) FROM im_subscriptions WHERE topic=? AND deletedat IS NULL),updatedat=CURRENT_TIMESTAMP(3) + WHERE name=?`, topic, topic) + return err +} + func recordMap(record gdb.Record) any { if record == nil { return nil @@ -619,6 +979,87 @@ func (s *sSysImGroup) JoinRequestList(ctx context.Context, in *sysin.ImGroupJoin return } +// FileList 获取IM群文件列表 +func (s *sSysImGroup) FileList(ctx context.Context, in *sysin.ImGroupFileListInp) (list []*sysin.ImGroupFileListModel, totalCount int, err error) { + if err = in.Filter(ctx); err != nil { + return + } + db, _, err := resolveImTenantDB(ctx, in.TenantId) + if err != nil { + return + } + mod := db.Model("im_group_files").Safe().Ctx(ctx).Where("topic", in.Topic) + if in.Status != "" { + mod = mod.Where("status", in.Status) + } + if in.FileType != "" { + mod = mod.Where("file_type", in.FileType) + } + if in.FileName != "" { + mod = mod.WhereLike("file_name", "%"+in.FileName+"%") + } + if in.UploaderId > 0 { + mod = mod.Where("uploader_id", in.UploaderId) + } + mod = mod.Page(in.Page, in.PerPage).OrderDesc("id") + if err = mod.ScanAndCount(&list, &totalCount, false); err != nil { + err = gerror.Wrap(err, "获取IM群文件列表失败,请稍后重试!") + } + return +} + +// FileDelete 删除IM群文件 +func (s *sSysImGroup) FileDelete(ctx context.Context, in *sysin.ImGroupFileDeleteInp) (err error) { + if err = in.Filter(ctx); err != nil { + return + } + db, _, err := resolveImTenantDB(ctx, in.TenantId) + if err != nil { + return err + } + actor := contexts.GetUserId(ctx) + requestId := gctx.CtxId(ctx) + if requestId == "" { + requestId = guid.S() + } + now := gtime.Now() + return db.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + if err := s.ensureActiveGroup(ctx, tx, in.Topic); err != nil { + return err + } + before, txErr := s.groupFileSnapshot(ctx, tx, in.Topic, in.GroupFileId) + if txErr != nil { + return txErr + } + if before == nil { + return gerror.New("群文件不存在") + } + if before["status"].String() == consts.ImGroupFileStatusDeleted { + return gerror.New("群文件已删除") + } + result, txErr := tx.Exec(`UPDATE im_group_files + SET status=?,deleted_by=?,deleted_at=?,updated_at=? + WHERE topic=? AND group_file_id=? AND status<>?`, + consts.ImGroupFileStatusDeleted, actor, now, now, in.Topic, in.GroupFileId, consts.ImGroupFileStatusDeleted) + if txErr != nil { + return txErr + } + if affected, _ := result.RowsAffected(); affected == 0 { + return gerror.New("群文件已删除") + } + after, txErr := s.groupFileSnapshot(ctx, tx, in.Topic, in.GroupFileId) + if txErr != nil { + return txErr + } + if txErr = s.insertGroupAdminLog(ctx, tx, in.Topic, actor, before["uploader_id"].Int64(), "group_file_delete", "group_file", in.GroupFileId, recordMap(before), recordMap(after), requestId); txErr != nil { + return txErr + } + return s.insertGroupEvent(ctx, tx, in.Topic, actor, "group.file.deleted", g.Map{ + "topic": in.Topic, "group_file_id": in.GroupFileId, "file_id": before["file_id"].String(), "request_id": requestId, + }) + }) +} + // InvitationList 获取IM群邀请列表 func (s *sSysImGroup) InvitationList(ctx context.Context, in *sysin.ImGroupInvitationListInp) (list []*sysin.ImGroupInvitationListModel, totalCount int, err error) { if err = in.Filter(ctx); err != nil { diff --git a/server/internal/model/input/sysin/im_group.go b/server/internal/model/input/sysin/im_group.go index 46e70df..6ce4c14 100644 --- a/server/internal/model/input/sysin/im_group.go +++ b/server/internal/model/input/sysin/im_group.go @@ -248,6 +248,59 @@ func (in *ImGroupUnmuteInp) Filter(ctx context.Context) (err error) { return } +type ImGroupJoinRequestResolveInp struct { + ImGroupTopicInp + RequestId string `json:"requestId" v:"required#申请ID不能为空" dc:"申请ID"` + RejectReason string `json:"rejectReason" dc:"拒绝原因"` +} + +func (in *ImGroupJoinRequestResolveInp) Filter(ctx context.Context) (err error) { + if err = in.ImGroupTopicInp.Filter(ctx); err != nil { + return + } + if in.RequestId == "" { + return gerror.New("申请ID不能为空") + } + if len([]rune(in.RejectReason)) > 200 { + return gerror.New("拒绝原因不能超过200个字符") + } + return +} + +type ImGroupRoleUpdateInp struct { + ImGroupTopicInp + UserId int64 `json:"userId" v:"required#用户ID不能为空" dc:"用户ID"` + Role string `json:"role" v:"required#角色不能为空" dc:"角色"` +} + +func (in *ImGroupRoleUpdateInp) Filter(ctx context.Context) (err error) { + if err = in.ImGroupTopicInp.Filter(ctx); err != nil { + return + } + if in.UserId <= 0 { + return gerror.New("用户ID不能为空") + } + if in.Role == "" { + return gerror.New("角色不能为空") + } + return +} + +type ImGroupOwnerTransferInp struct { + ImGroupTopicInp + UserId int64 `json:"userId" v:"required#新群主ID不能为空" dc:"新群主ID"` +} + +func (in *ImGroupOwnerTransferInp) Filter(ctx context.Context) (err error) { + if err = in.ImGroupTopicInp.Filter(ctx); err != nil { + return + } + if in.UserId <= 0 { + return gerror.New("新群主ID不能为空") + } + return +} + type ImGroupJoinRequestListInp struct { form.PageReq ImGroupTopicInp @@ -273,6 +326,49 @@ type ImGroupJoinRequestListModel struct { UpdatedAt *gtime.Time `json:"updatedAt" dc:"修改时间"` } +type ImGroupFileListInp struct { + form.PageReq + ImGroupTopicInp + Status string `json:"status" dc:"状态"` + FileType string `json:"fileType" dc:"文件类型"` + FileName string `json:"fileName" dc:"文件名"` + UploaderId int64 `json:"uploaderId" dc:"上传人ID"` +} + +type ImGroupFileListModel struct { + Id int64 `json:"id" dc:"ID"` + GroupFileId string `json:"groupFileId" dc:"群文件ID"` + Topic string `json:"topic" dc:"Topic"` + FileId string `json:"fileId" dc:"文件ID"` + SourceTopic string `json:"sourceTopic" dc:"来源Topic"` + SourceSeq int `json:"sourceSeq" dc:"来源消息序号"` + UploaderId int64 `json:"uploaderId" dc:"上传人ID"` + FileName string `json:"fileName" dc:"文件名"` + MimeType string `json:"mimeType" dc:"MIME类型"` + FileType string `json:"fileType" dc:"文件类型"` + FileSize int64 `json:"fileSize" dc:"文件大小"` + Status string `json:"status" dc:"状态"` + DeletedBy int64 `json:"deletedBy" dc:"删除人"` + DeletedAt *gtime.Time `json:"deletedAt" dc:"删除时间"` + CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"` + UpdatedAt *gtime.Time `json:"updatedAt" dc:"修改时间"` +} + +type ImGroupFileDeleteInp struct { + ImGroupTopicInp + GroupFileId string `json:"groupFileId" v:"required#群文件ID不能为空" dc:"群文件ID"` +} + +func (in *ImGroupFileDeleteInp) Filter(ctx context.Context) (err error) { + if err = in.ImGroupTopicInp.Filter(ctx); err != nil { + return + } + if in.GroupFileId == "" { + return gerror.New("群文件ID不能为空") + } + return +} + type ImGroupInvitationListInp struct { form.PageReq ImGroupTopicInp diff --git a/server/internal/service/sys.go b/server/internal/service/sys.go index a25b471..4c4f433 100644 --- a/server/internal/service/sys.go +++ b/server/internal/service/sys.go @@ -244,8 +244,20 @@ type ( Mute(ctx context.Context, in *sysin.ImGroupMuteInp) (err error) // Unmute 解除IM群成员禁言 Unmute(ctx context.Context, in *sysin.ImGroupUnmuteInp) (err error) + // JoinRequestApprove 通过IM入群申请 + JoinRequestApprove(ctx context.Context, in *sysin.ImGroupJoinRequestResolveInp) (err error) + // JoinRequestReject 拒绝IM入群申请 + JoinRequestReject(ctx context.Context, in *sysin.ImGroupJoinRequestResolveInp) (err error) + // RoleUpdate 更新IM群成员角色 + RoleUpdate(ctx context.Context, in *sysin.ImGroupRoleUpdateInp) (err error) + // OwnerTransfer 转让IM群主 + OwnerTransfer(ctx context.Context, in *sysin.ImGroupOwnerTransferInp) (err error) // JoinRequestList 获取IM入群申请列表 JoinRequestList(ctx context.Context, in *sysin.ImGroupJoinRequestListInp) (list []*sysin.ImGroupJoinRequestListModel, totalCount int, err error) + // FileList 获取IM群文件列表 + FileList(ctx context.Context, in *sysin.ImGroupFileListInp) (list []*sysin.ImGroupFileListModel, totalCount int, err error) + // FileDelete 删除IM群文件 + FileDelete(ctx context.Context, in *sysin.ImGroupFileDeleteInp) (err error) // InvitationList 获取IM群邀请列表 InvitationList(ctx context.Context, in *sysin.ImGroupInvitationListInp) (list []*sysin.ImGroupInvitationListModel, totalCount int, err error) // AnnouncementList 获取IM群公告列表 diff --git a/server/storage/data/generate/im_group_menu.sql b/server/storage/data/generate/im_group_menu.sql index 5161476..7092d8b 100644 --- a/server/storage/data/generate/im_group_menu.sql +++ b/server/storage/data/generate/im_group_menu.sql @@ -52,10 +52,34 @@ INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type SELECT NULL, @groupListId, '解除成员禁言', 'imGroupUnmute', '', '', '3', '', '/imGroup/unmute', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @groupDirId, ' tr_', @groupListId, ' '), '70', '', '1', @now, @now WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imGroupUnmute'); +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT NULL, @groupListId, '通过入群申请', 'imGroupJoinRequestApprove', '', '', '3', '', '/imGroup/joinRequestApprove', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @groupDirId, ' tr_', @groupListId, ' '), '80', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imGroupJoinRequestApprove'); + +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT NULL, @groupListId, '拒绝入群申请', 'imGroupJoinRequestReject', '', '', '3', '', '/imGroup/joinRequestReject', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @groupDirId, ' tr_', @groupListId, ' '), '90', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imGroupJoinRequestReject'); + +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT NULL, @groupListId, '更新群成员角色', 'imGroupRoleUpdate', '', '', '3', '', '/imGroup/roleUpdate', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @groupDirId, ' tr_', @groupListId, ' '), '100', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imGroupRoleUpdate'); + +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT NULL, @groupListId, '转让群主', 'imGroupOwnerTransfer', '', '', '3', '', '/imGroup/ownerTransfer', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @groupDirId, ' tr_', @groupListId, ' '), '110', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imGroupOwnerTransfer'); + INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) SELECT NULL, @groupListId, '入群申请列表', 'imGroupJoinRequestList', '', '', '3', '', '/imGroup/joinRequestList', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @groupDirId, ' tr_', @groupListId, ' '), '40', '', '1', @now, @now WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imGroupJoinRequestList'); +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT NULL, @groupListId, '群文件列表', 'imGroupFileList', '', '', '3', '', '/imGroup/fileList', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @groupDirId, ' tr_', @groupListId, ' '), '120', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imGroupFileList'); + +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT NULL, @groupListId, '删除群文件', 'imGroupFileDelete', '', '', '3', '', '/imGroup/fileDelete', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @groupDirId, ' tr_', @groupListId, ' '), '130', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imGroupFileDelete'); + INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) SELECT NULL, @groupListId, '群邀请列表', 'imGroupInvitationList', '', '', '3', '', '/imGroup/invitationList', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @groupDirId, ' tr_', @groupListId, ' '), '50', '', '1', @now, @now WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imGroupInvitationList'); diff --git a/web/src/api/imGroup/index.ts b/web/src/api/imGroup/index.ts index d635ee9..184e97c 100644 --- a/web/src/api/imGroup/index.ts +++ b/web/src/api/imGroup/index.ts @@ -72,6 +72,42 @@ export function Unmute(params) { }); } +// 通过IM入群申请 +export function JoinRequestApprove(params) { + return http.request({ + url: '/imGroup/joinRequestApprove', + method: 'POST', + params, + }); +} + +// 拒绝IM入群申请 +export function JoinRequestReject(params) { + return http.request({ + url: '/imGroup/joinRequestReject', + method: 'POST', + params, + }); +} + +// 更新IM群成员角色 +export function RoleUpdate(params) { + return http.request({ + url: '/imGroup/roleUpdate', + method: 'POST', + params, + }); +} + +// 转让IM群主 +export function OwnerTransfer(params) { + return http.request({ + url: '/imGroup/ownerTransfer', + method: 'POST', + params, + }); +} + // 获取IM入群申请列表 export function JoinRequestList(params) { return http.request({ @@ -81,6 +117,24 @@ export function JoinRequestList(params) { }); } +// 获取IM群文件列表 +export function FileList(params) { + return http.request({ + url: '/imGroup/fileList', + method: 'GET', + params, + }); +} + +// 删除IM群文件 +export function FileDelete(params) { + return http.request({ + url: '/imGroup/fileDelete', + method: 'POST', + params, + }); +} + // 获取IM群邀请列表 export function InvitationList(params) { return http.request({ diff --git a/web/src/views/imGroup/governance.vue b/web/src/views/imGroup/governance.vue index 425f8de..b460e15 100644 --- a/web/src/views/imGroup/governance.vue +++ b/web/src/views/imGroup/governance.vue @@ -102,10 +102,22 @@ :columns="joinRequestColumns" :request="joinRequestData" :row-key="(row) => row.requestId" + :actionColumn="joinRequestActionColumn" :scroll-x="joinRequestScrollX" :resizeHeightOffset="-10000" /> + + + record.status === 'pending', + auth: ['/imGroup/joinRequestApprove'], + onClick: () => handleJoinRequestApprove(record), + }, + { + label: '拒绝', + type: 'error', + ifShow: () => record.status === 'pending', + auth: ['/imGroup/joinRequestReject'], + onClick: () => handleJoinRequestReject(record), + }, + ], + }); + }, + }); + const fileActionColumn = reactive({ + width: 100, + title: '操作', + key: 'action', + fixed: 'right', + render(record) { + return h(TableAction as any, { + style: 'button', + actions: [ + { + label: '删除', + type: 'error', + ifShow: () => record.status === 'active', + auth: ['/imGroup/fileDelete'], + onClick: () => handleFileDelete(record), + }, + ], + }); + }, + }); const muteActionColumn = reactive({ width: 110, title: '操作', @@ -247,7 +312,10 @@ }, }); - const joinRequestScrollX = computed(() => adaTableScrollX(joinRequestColumns, 0)); + const joinRequestScrollX = computed(() => + adaTableScrollX(joinRequestColumns, joinRequestActionColumn.width) + ); + const fileScrollX = computed(() => adaTableScrollX(groupFileColumns, fileActionColumn.width)); const invitationScrollX = computed(() => adaTableScrollX(invitationColumns, 0)); const announcementScrollX = computed(() => adaTableScrollX(announcementColumns, 0)); const muteScrollX = computed(() => adaTableScrollX(muteColumns, muteActionColumn.width)); @@ -265,6 +333,10 @@ return await JoinRequestList(baseParams(res)); } + async function fileData(res) { + return await FileList(baseParams(res)); + } + async function invitationData(res) { return await InvitationList(baseParams(res)); } @@ -361,9 +433,65 @@ }); } + function handleJoinRequestApprove(record) { + dialog.success({ + title: '通过入群申请', + content: '确定通过该入群申请?', + positiveText: '确认', + negativeText: '取消', + onPositiveClick: async () => { + await JoinRequestApprove({ + tenantId: current.value.tenantId, + topic: current.value.topic, + requestId: record.requestId, + }); + message.success('操作成功'); + joinRequestRef.value?.reload(); + }, + }); + } + + function handleJoinRequestReject(record) { + dialog.error({ + title: '拒绝入群申请', + content: '确定拒绝该入群申请?', + positiveText: '确认', + negativeText: '取消', + onPositiveClick: async () => { + await JoinRequestReject({ + tenantId: current.value.tenantId, + topic: current.value.topic, + requestId: record.requestId, + rejectReason: '后台管理员拒绝', + }); + message.success('操作成功'); + joinRequestRef.value?.reload(); + }, + }); + } + + function handleFileDelete(record) { + dialog.error({ + title: '删除群文件', + content: '删除后该群文件会被标记为已删除,并写入群事件等待 IM 服务端同步,是否确认?', + positiveText: '确认', + negativeText: '取消', + onPositiveClick: async () => { + await FileDelete({ + tenantId: current.value.tenantId, + topic: current.value.topic, + groupFileId: record.groupFileId, + }); + message.success('操作成功'); + fileRef.value?.reload(); + }, + }); + } + function reloadActiveTable() { const refs = { joinRequests: joinRequestRef, + files: fileRef, invitations: invitationRef, announcements: announcementRef, mutes: muteRef, diff --git a/web/src/views/imGroup/members.vue b/web/src/views/imGroup/members.vue index c14aa9f..7813e3a 100644 --- a/web/src/views/imGroup/members.vue +++ b/web/src/views/imGroup/members.vue @@ -32,7 +32,7 @@ import { useDialog, useMessage } from 'naive-ui'; import { BasicTable, TableAction } from '@/components/Table'; import { usePermission } from '@/hooks/web/usePermission'; - import { MemberList, Mute } from '@/api/imGroup'; + import { MemberList, Mute, OwnerTransfer, RoleUpdate } from '@/api/imGroup'; import { memberColumns, newState, State } from './model'; import { adaModalWidth, adaTableScrollX } from '@/utils/hotgo'; @@ -44,7 +44,7 @@ const actionRef = ref(); const dialogWidth = computed(() => adaModalWidth(980)); const actionColumn = reactive({ - width: 100, + width: 210, title: '操作', key: 'action', fixed: 'right', @@ -59,6 +59,27 @@ disabled: !hasPermission(['/imGroup/mute']), onClick: () => handleMute(record), }, + { + label: '设管理员', + type: 'primary', + ifShow: () => record.memberStatus === 'active' && record.role === 'member', + auth: ['/imGroup/roleUpdate'], + onClick: () => handleRoleUpdate(record, 'admin'), + }, + { + label: '取消管理员', + type: 'default', + ifShow: () => record.memberStatus === 'active' && record.role === 'admin', + auth: ['/imGroup/roleUpdate'], + onClick: () => handleRoleUpdate(record, 'member'), + }, + { + label: '转群主', + type: 'error', + ifShow: () => record.memberStatus === 'active' && record.role !== 'owner', + auth: ['/imGroup/ownerTransfer'], + onClick: () => handleOwnerTransfer(record), + }, ], }); }, @@ -102,6 +123,43 @@ }); } + function handleRoleUpdate(record, role: string) { + dialog.warning({ + title: role === 'admin' ? '设置管理员' : '取消管理员', + content: role === 'admin' ? '确定将该成员设置为管理员?' : '确定取消该成员管理员身份?', + positiveText: '确认', + negativeText: '取消', + onPositiveClick: async () => { + await RoleUpdate({ + tenantId: current.value.tenantId, + topic: current.value.topic, + userId: record.userId, + role, + }); + message.success('操作成功'); + actionRef.value?.reload(); + }, + }); + } + + function handleOwnerTransfer(record) { + dialog.error({ + title: '转让群主', + content: '转让后原群主会变为管理员,是否确认?', + positiveText: '确认', + negativeText: '取消', + onPositiveClick: async () => { + await OwnerTransfer({ + tenantId: current.value.tenantId, + topic: current.value.topic, + userId: record.userId, + }); + message.success('操作成功'); + actionRef.value?.reload(); + }, + }); + } + // 打开弹窗 function openModal(state: State) { current.value = newState(state); diff --git a/web/src/views/imGroup/model.ts b/web/src/views/imGroup/model.ts index c4592ea..04d8c70 100644 --- a/web/src/views/imGroup/model.ts +++ b/web/src/views/imGroup/model.ts @@ -368,6 +368,38 @@ export const joinRequestColumns = [ { title: '创建时间', key: 'createdAt', align: 'left', width: 180 }, ]; +export const groupFileColumns = [ + { title: '群文件ID', key: 'groupFileId', align: 'left', width: 180 }, + { title: '文件ID', key: 'fileId', align: 'left', width: 160 }, + { title: '文件名', key: 'fileName', align: 'left', width: 220 }, + { + title: '类型', + key: 'fileType', + align: 'left', + width: 100, + render(row) { + return dict.getLabel('ImGroupFileTypeOptions', row.fileType) || row.fileType; + }, + }, + { title: 'MIME', key: 'mimeType', align: 'left', width: 160 }, + { title: '大小', key: 'fileSize', align: 'left', width: 110 }, + { title: '上传人', key: 'uploaderId', align: 'left', width: 120 }, + { title: '来源Topic', key: 'sourceTopic', align: 'left', width: 160 }, + { title: '来源序号', key: 'sourceSeq', align: 'left', width: 100 }, + { + title: '状态', + key: 'status', + align: 'left', + width: 110, + render(row) { + return dict.getLabel('ImGroupFileStatusOptions', row.status); + }, + }, + { title: '删除人', key: 'deletedBy', align: 'left', width: 120 }, + { title: '删除时间', key: 'deletedAt', align: 'left', width: 180 }, + { title: '创建时间', key: 'createdAt', align: 'left', width: 180 }, +]; + export const invitationColumns = [ { title: '邀请ID', key: 'invitationId', align: 'left', width: 180 }, { title: '邀请人ID', key: 'inviterUserId', align: 'left', width: 120 }, @@ -450,6 +482,8 @@ export function loadOptions() { 'ImGroupMuteStatusOptions', 'ImGroupAnnouncementStatusOptions', 'ImGroupOutboxStatusOptions', + 'ImGroupFileStatusOptions', + 'ImGroupFileTypeOptions', ]); }