diff --git a/.cursor/rules/project-context.mdc b/.cursor/rules/project-context.mdc index f0d7eab..731a075 100644 --- a/.cursor/rules/project-context.mdc +++ b/.cursor/rules/project-context.mdc @@ -18,10 +18,11 @@ alwaysApply: true - **Phase 2a(WebSocket 实时通讯与联系人管理)**:✅ 全部完成(13 个 Task + 后期 Bug 修复 3 项) - **Phase 2b(即时通讯消息系统)**:✅ 全部完成(10 个 Task + 代码审查修复 7 项 + 用户测试修复 8 项),设计文档 `docs/plans/2026-03-03-phase2b-design.md` - 分支:`feature/phase2b-instant-messaging` -- **Phase 2c(群聊与已读回执)**:✅ 全部完成(14 个 Task + 代码审查修复 14 项),设计文档 `docs/plans/2026-03-04-phase2c-design.md` +- **Phase 2c(群聊与已读回执)**:✅ 全部完成(14 个 Task + 代码审查修复 14 项 + 浏览器测试修复 21 项),设计文档 `docs/plans/2026-03-04-phase2c-design.md` - 分支:`feature/phase2c-group-read-receipt` - 范围:群聊全功能(三级角色/禁言/@提醒/群公告/入群审批)+ 已读回执(单聊会话级 + 群聊消息级)+ MinIO 文件存储 + 管理端群组管理 - 代码审查修复:Critical×5(角色类型/公告字段/at_user_ids/@导航/Wire 注册)+ Important×4 + Minor×2 + Suggestion×3 +- 浏览器测试修复:搜索 ILIKE 替换 to_tsvector、管理端群详情 UI 重设计、群搜索已加入状态、创建/邀请支持非好友搜索、全角色身份标识、全局错误信息路径修复(8文件18处)、自定义操作弹窗替代 uni.showActionSheet、已读详情页群昵称展示、免打扰 API 补全(DAO/Service/Controller/Router)、联系人 Tab 页 onShow 刷新 - **跨模块通信模式**:接口注入标准(ws.FriendIDsGetter / im.FriendChecker / im.UserInfoGetter → contact.FriendshipDAO,im.OfflineMessagePusher → ws.Handler,contact.OnlineChecker → ws.OnlineService,im.GroupInfoGetter → group.GroupDAO,im.MessageReadRecorder → group.MessageReadDAO,group.UserInfoProvider → auth.UserDAO,group.MessageWriter → im.MessageDAO) ## 项目概述 diff --git a/admin/src/views/group/list.vue b/admin/src/views/group/list.vue index a292230..077924a 100644 --- a/admin/src/views/group/list.vue +++ b/admin/src/views/group/list.vue @@ -117,57 +117,115 @@ - -
+ +
@@ -180,11 +238,22 @@ * * 数据流:API → 列表渲染 → 分页 / 搜索 → 详情弹窗 */ -import { ref, onMounted } from 'vue' -import { Search } from '@element-plus/icons-vue' +import { ref, computed, onMounted } from 'vue' +import { Search, User, UserFilled, ChatLineSquare, CircleCloseFilled } from '@element-plus/icons-vue' import { ElMessage, ElMessageBox } from 'element-plus' import { getGroupList, getGroupDetail, dissolveGroup } from '@/api/group' +const AVATAR_COLORS = ['#2563EB', '#7C3AED', '#059669', '#D97706', '#DC2626', '#0891B2', '#4F46E5', '#BE185D'] +const avatarColor = computed(() => { + if (!currentGroup.value?.name) return AVATAR_COLORS[0] + const idx = currentGroup.value.name.charCodeAt(0) % AVATAR_COLORS.length + return AVATAR_COLORS[idx] +}) +const getMemberColor = (name) => { + if (!name) return '#94A3B8' + return AVATAR_COLORS[name.charCodeAt(0) % AVATAR_COLORS.length] +} + const loading = ref(false) const groupList = ref([]) const total = ref(0) @@ -313,10 +382,166 @@ onMounted(() => { cursor: default; } -.member-title { - margin: 20px 0 12px; - font-size: 15px; - font-weight: 600; +/* ===== 群组详情弹窗 ===== */ + +.detail-content { + min-height: 200px; +} + +.detail-header { + display: flex; + align-items: center; + gap: 16px; + padding: 20px; + background: linear-gradient(135deg, #F8FAFC 0%, #EFF6FF 100%); + border-radius: 12px; + margin-bottom: 20px; +} + +.detail-avatar { + width: 56px; + height: 56px; + border-radius: 14px; + display: flex; + align-items: center; + justify-content: center; + font-size: 22px; + font-weight: 700; + color: #FFFFFF; + flex-shrink: 0; + box-shadow: 0 4px 12px rgba(37, 99, 235, 0.2); +} + +.detail-header-info { + flex: 1; + min-width: 0; +} + +.detail-header-name { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 6px; +} + +.detail-group-name { + font-size: 18px; + font-weight: 700; + color: #0F172A; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.detail-header-meta { + display: flex; + align-items: center; + gap: 4px; + font-size: 13px; + color: #64748B; +} + +.meta-item { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.detail-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 12px; + margin-bottom: 20px; +} + +.detail-grid-item { + display: flex; + flex-direction: column; + gap: 4px; + padding: 12px 16px; + background: #F8FAFC; + border-radius: 8px; + border: 1px solid #E2E8F0; +} + +.grid-label { + font-size: 12px; + color: #94A3B8; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.grid-value { + font-size: 14px; color: #1E293B; + font-weight: 500; +} + +.text-muted { + color: #94A3B8; +} + +.detail-section { + margin-bottom: 20px; +} + +.section-header { + display: flex; + align-items: center; + gap: 6px; + font-size: 14px; + font-weight: 600; + color: #334155; + margin-bottom: 10px; + padding-bottom: 8px; + border-bottom: 1px solid #E2E8F0; +} + +.notice-content { + padding: 12px 16px; + background: #F8FAFC; + border-radius: 8px; + font-size: 14px; + color: #334155; + line-height: 1.6; + border: 1px solid #E2E8F0; +} + +.notice-empty { + color: #94A3B8; + font-style: italic; +} + +.member-table { + border-radius: 8px; + overflow: hidden; +} + +.member-cell { + display: flex; + align-items: center; + gap: 8px; +} + +.member-mini-avatar { + width: 28px; + height: 28px; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 600; + color: #FFFFFF; + flex-shrink: 0; +} + +.member-cell-name { + font-weight: 500; + color: #1E293B; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } diff --git a/backend/go-service/app/dto/group_dto.go b/backend/go-service/app/dto/group_dto.go index 3fa9915..a43a3d3 100644 --- a/backend/go-service/app/dto/group_dto.go +++ b/backend/go-service/app/dto/group_dto.go @@ -136,10 +136,11 @@ type MessageReadCountDTO struct { // MessageReadDetailDTO 消息已读详情传输对象 type MessageReadDetailDTO struct { - UserID int64 `json:"user_id"` // 已读用户 ID - UserNickname string `json:"user_nickname"` // 用户昵称 - UserAvatar string `json:"user_avatar"` // 用户头像 - ReadAt string `json:"read_at"` // 已读时间 + UserID int64 `json:"user_id"` // 已读用户 ID + UserNickname string `json:"user_nickname"` // 用户真实昵称 + UserAvatar string `json:"user_avatar"` // 用户头像 + GroupNickname string `json:"group_nickname,omitempty"` // 群内昵称(仅群聊有效,未设置时为空) + ReadAt string `json:"read_at"` // 已读时间 } // GetReadDetailRequest 获取已读详情请求 diff --git a/backend/go-service/app/group/dao/group_dao.go b/backend/go-service/app/group/dao/group_dao.go index 2a336a0..a49152b 100644 --- a/backend/go-service/app/group/dao/group_dao.go +++ b/backend/go-service/app/group/dao/group_dao.go @@ -328,8 +328,8 @@ func (d *GroupDAO) SearchGroups(ctx context.Context, keyword string, offset, lim var total int64 query := d.db.WithContext(ctx). Model(&model.Group{}). - Where("status = ? AND is_searchable = true AND to_tsvector('simple', name) @@ plainto_tsquery('simple', ?)", - constants.GroupStatusNormal, keyword) + Where("status = ? AND is_searchable = true AND name ILIKE ?", + constants.GroupStatusNormal, "%"+keyword+"%") if err := query.Count(&total).Error; err != nil { logs.Error(ctx, funcName, "搜索计数失败", zap.Error(err)) diff --git a/backend/go-service/app/im/controller/im_controller.go b/backend/go-service/app/im/controller/im_controller.go index 813146e..b3cb1e1 100644 --- a/backend/go-service/app/im/controller/im_controller.go +++ b/backend/go-service/app/im/controller/im_controller.go @@ -63,6 +63,35 @@ func (ctl *IMController) GetHistoryMessages(c *gin.Context) { utils.ResponseOK(c, result) } +// SetDoNotDisturb 设置/取消会话消息免打扰 +// PUT /api/v1/im/conversations/:id/dnd +func (ctl *IMController) SetDoNotDisturb(c *gin.Context) { + ctx := c.Request.Context() + userID, ok := middleware.GetCurrentUserID(c) + if !ok { + utils.ResponseUnauthorized(c, "无法获取当前用户信息") + return + } + + convID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + utils.ResponseBadRequest(c, "会话 ID 格式错误") + return + } + + var req dto.SetDoNotDisturbRequest + if err := c.ShouldBindJSON(&req); err != nil { + utils.ResponseBadRequest(c, "请求参数错误: "+err.Error()) + return + } + + if err := ctl.imService.SetDoNotDisturb(ctx, userID, convID, req.IsDoNotDisturb); err != nil { + ctl.handleError(c, err, "设置免打扰失败") + return + } + utils.ResponseOK(c, nil) +} + // PinConversation 置顶/取消置顶会话 // PUT /api/v1/im/conversations/:id/pin func (ctl *IMController) PinConversation(c *gin.Context) { diff --git a/backend/go-service/app/im/dao/conversation_dao.go b/backend/go-service/app/im/dao/conversation_dao.go index 5db91d5..bb4129c 100644 --- a/backend/go-service/app/im/dao/conversation_dao.go +++ b/backend/go-service/app/im/dao/conversation_dao.go @@ -159,6 +159,29 @@ func (d *ConversationDAO) GetConversationMemberIDs(ctx context.Context, conversa return ids, err } +// GetMemberNicknameMap 批量获取会话成员的群内昵称 +// 返回 map[user_id]nickname,未设置昵称的不在 map 中 +func (d *ConversationDAO) GetMemberNicknameMap(ctx context.Context, conversationID int64) (map[int64]string, error) { + type row struct { + UserID int64 `gorm:"column:user_id"` + Nickname string `gorm:"column:nickname"` + } + var rows []row + err := d.db.WithContext(ctx). + Model(&model.ConversationMember{}). + Select("user_id, nickname"). + Where("conversation_id = ? AND nickname != ''", conversationID). + Find(&rows).Error + if err != nil { + return nil, err + } + result := make(map[int64]string, len(rows)) + for _, r := range rows { + result[r.UserID] = r.Nickname + } + return result, nil +} + // UpdateMemberPinned 更新会话成员的置顶状态 func (d *ConversationDAO) UpdateMemberPinned(ctx context.Context, conversationID, userID int64, isPinned bool) error { funcName := "dao.conversation_dao.UpdateMemberPinned" @@ -257,6 +280,14 @@ func (d *ConversationDAO) GetMemberDNDMap(ctx context.Context, conversationID in return result, nil } +// UpdateMemberDND 设置/取消指定成员的消息免打扰 +func (d *ConversationDAO) UpdateMemberDND(ctx context.Context, conversationID, userID int64, isDoNotDisturb bool) error { + return d.db.WithContext(ctx). + Model(&model.ConversationMember{}). + Where("conversation_id = ? AND user_id = ?", conversationID, userID). + Update("is_do_not_disturb", isDoNotDisturb).Error +} + // ClearUnread 清零指定成员的未读消息数 func (d *ConversationDAO) ClearUnread(ctx context.Context, conversationID, userID int64, lastMsgID int64) error { return d.db.WithContext(ctx). diff --git a/backend/go-service/app/im/router.go b/backend/go-service/app/im/router.go index 4eb3c92..55a22d3 100644 --- a/backend/go-service/app/im/router.go +++ b/backend/go-service/app/im/router.go @@ -14,6 +14,7 @@ func RegisterRoutes(r *gin.Engine, ctrl *controller.IMController, jwtAuth gin.Ha // 会话管理 authed.GET("/conversations", ctrl.GetConversations) authed.PUT("/conversations/:id/pin", ctrl.PinConversation) + authed.PUT("/conversations/:id/dnd", ctrl.SetDoNotDisturb) authed.DELETE("/conversations/:id", ctrl.DeleteConversation) authed.DELETE("/conversations/:id/messages", ctrl.ClearHistory) diff --git a/backend/go-service/app/im/service/im_service.go b/backend/go-service/app/im/service/im_service.go index 4b40ddd..c1666f7 100644 --- a/backend/go-service/app/im/service/im_service.go +++ b/backend/go-service/app/im/service/im_service.go @@ -525,7 +525,7 @@ func (s *IMService) GetHistoryMessages(ctx context.Context, userID int64, req *d return &dto.HistoryMessageResponse{List: list, HasMore: hasMore}, nil } -// MarkRead 标记会话已读(清零未读 + 更新 Redis 总未读数) +// MarkRead 标记会话已读(清零未读 + 更新 Redis 总未读数 + 推送已读 ACK 给对方) func (s *IMService) MarkRead(ctx context.Context, userID int64, conversationID int64) error { funcName := "service.im_service.MarkRead" logs.Info(ctx, funcName, "标记已读", @@ -536,22 +536,35 @@ func (s *IMService) MarkRead(ctx context.Context, userID int64, conversationID i return ErrNotMember } - if member.UnreadCount == 0 { - return nil - } - latestMsgID, err := s.msgDAO.GetLatestMessageID(ctx, conversationID) if err != nil { logs.Error(ctx, funcName, "获取最新消息 ID 失败", zap.Error(err)) return err } - if err := s.convDAO.ClearUnread(ctx, conversationID, userID, latestMsgID); err != nil { - logs.Error(ctx, funcName, "清零未读失败", zap.Error(err)) - return err + if member.UnreadCount > 0 { + if err := s.convDAO.ClearUnread(ctx, conversationID, userID, latestMsgID); err != nil { + logs.Error(ctx, funcName, "清零未读失败", zap.Error(err)) + return err + } + s.decrementTotalUnread(ctx, userID, member.UnreadCount) + } + + memberIDs, err := s.convDAO.GetConversationMemberIDs(ctx, conversationID) + if err != nil { + logs.Error(ctx, funcName, "获取会话成员失败", zap.Error(err)) + return nil + } + for _, peerID := range memberIDs { + if peerID != userID { + s.pushToUser(ctx, peerID, "im.message.read.ack", map[string]interface{}{ + "conversation_id": conversationID, + "reader_id": userID, + "last_read_msg_id": latestMsgID, + }) + } } - s.decrementTotalUnread(ctx, userID, member.UnreadCount) return nil } @@ -658,6 +671,12 @@ func (s *IMService) GetMessageReadDetail(ctx context.Context, userID int64, mess } } + nicknameMap, nErr := s.convDAO.GetMemberNicknameMap(ctx, msg.ConversationID) + if nErr != nil { + logs.Error(ctx, funcName, "获取成员群昵称失败", zap.Error(nErr)) + nicknameMap = make(map[int64]string) + } + var readList, unreadList []dto.MessageReadDetailDTO for _, uid := range allUserIDs { item := dto.MessageReadDetailDTO{ @@ -667,6 +686,9 @@ func (s *IMService) GetMessageReadDetail(ctx context.Context, userID int64, mess item.UserNickname = brief.Nickname item.UserAvatar = brief.Avatar } + if gn, ok := nicknameMap[uid]; ok { + item.GroupNickname = gn + } if readUserIDs[uid] { readList = append(readList, item) } else { @@ -682,6 +704,20 @@ func (s *IMService) GetMessageReadDetail(ctx context.Context, userID int64, mess }, nil } +// SetDoNotDisturb 设置/取消会话消息免打扰 +func (s *IMService) SetDoNotDisturb(ctx context.Context, userID int64, conversationID int64, isDoNotDisturb bool) error { + funcName := "service.im_service.SetDoNotDisturb" + logs.Info(ctx, funcName, "更新免打扰状态", + zap.Int64("user_id", userID), zap.Int64("conversation_id", conversationID), zap.Bool("is_do_not_disturb", isDoNotDisturb)) + + member, err := s.convDAO.GetMember(ctx, conversationID, userID) + if err != nil || member == nil { + return ErrNotMember + } + + return s.convDAO.UpdateMemberDND(ctx, conversationID, userID, isDoNotDisturb) +} + // PinConversation 置顶/取消置顶会话 func (s *IMService) PinConversation(ctx context.Context, userID int64, conversationID int64, isPinned bool) error { funcName := "service.im_service.PinConversation" diff --git a/docs/api/frontend/im.md b/docs/api/frontend/im.md index 58a1532..8e3361c 100644 --- a/docs/api/frontend/im.md +++ b/docs/api/frontend/im.md @@ -3,7 +3,7 @@ > 通用规范(认证方式、响应格式、错误码)见 [README.md](../README.md) > 消息的实时收发(发送/撤回/标记已读/正在输入)通过 WebSocket 完成,见 [websocket.md](../websocket.md) > 本文档中的接口用于会话管理和消息历史查询等非实时操作。 -> **最后更新:** 2026-03-03(代码审查修复后同步) +> **最后更新:** 2026-03-04(Fix T19 已读详情群昵称 + Fix T20 免打扰 API 补全) --- @@ -14,10 +14,12 @@ | GET | /api/v1/im/conversations | 需认证 | 获取会话列表 | | GET | /api/v1/im/messages | 需认证 | 获取历史消息(游标分页) | | PUT | /api/v1/im/conversations/:id/pin | 需认证 | 置顶/取消置顶 | +| PUT | /api/v1/im/conversations/:id/dnd | 需认证 | 设置/取消消息免打扰 | | DELETE | /api/v1/im/conversations/:id | 需认证 | 删除会话(软删除) | | DELETE | /api/v1/im/conversations/:id/messages | 需认证 | 清空聊天记录(个人视图) | | GET | /api/v1/im/messages/search | 需认证 | 全局消息搜索 | | GET | /api/v1/im/unread | 需认证 | 获取全局未读消息总数 | +| GET | /api/v1/im/messages/:id/reads | 需认证 | 获取消息已读详情 | --- @@ -112,6 +114,22 @@ --- +## 3.5 设置/取消消息免打扰 + +`PUT /api/v1/im/conversations/:id/dnd` + +**权限:** 需认证,且为该会话成员 + +**请求参数:** + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| is_do_not_disturb | bool | 是 | true=开启免打扰, false=关闭 | + +**说明:** 免打扰模式下,新消息仍计入会话 unread_count,但不递增 Redis 全局未读数,前端会话列表中以灰色数字展示未读数。 + +--- + ## 4. 删除会话 `DELETE /api/v1/im/conversations/:id` @@ -192,3 +210,50 @@ } } ``` + +--- + +## 8. 获取消息已读详情 + +`GET /api/v1/im/messages/:id/reads` + +**权限:** 需认证,且为该会话成员 + +**说明:** 返回指定消息的已读/未读用户列表。群聊场景下,已设群昵称的用户会额外返回 `group_nickname` 字段。 + +**成功响应:** + +```json +{ + "code": 0, + "message": "success", + "data": { + "read_list": [ + { + "user_id": 4, + "user_nickname": "张三", + "user_avatar": "https://...", + "group_nickname": "群昵称A", + "read_at": "" + } + ], + "unread_list": [ + { + "user_id": 5, + "user_nickname": "李四", + "user_avatar": "https://..." + } + ], + "read_count": 1, + "total_count": 3 + } +} +``` + +**字段说明:** + +| 字段 | 说明 | +|------|------| +| group_nickname | 群内昵称,仅群聊有效且已设置时返回(omitempty) | +| read_at | 已读时间(当前暂为空,预留字段) | +| total_count | 群成员总数(不含消息发送者本人) | diff --git a/docs/conventions/frontend-backend-integration.md b/docs/conventions/frontend-backend-integration.md index ad2eec0..8770fb7 100644 --- a/docs/conventions/frontend-backend-integration.md +++ b/docs/conventions/frontend-backend-integration.md @@ -2,7 +2,7 @@ > **适用范围**:EchoChat 项目全端(Go 后端 + admin 管理端 + frontend 用户端) > **创建日期**:2026-03-02 -> **最后更新**:2026-03-04(Phase 2c 全部完成:群聊模块 + MinIO 文件存储 + 已读回执 + 代码审查修复 14 项) +> **最后更新**:2026-03-04(Phase 2c 全部完成:群聊模块 + MinIO 文件存储 + 已读回执 + 代码审查修复 14 项 + 浏览器测试修复 21 项) --- @@ -127,6 +127,8 @@ HTTP 响应错误: **401 场景区分的关键**:通过正则 `/\/auth\/(login|register)$/` 匹配请求 URL。登录/注册请求不清 Token 不跳转。 +**reject 对象结构(重要)**:`request.js` 的 `reject(data)` 传出的是 `{ code, message, ... }` 对象(即后端响应体本身),不是包装的 `{ data: { message } }`。因此在页面 catch 中获取错误信息时,必须使用 `e?.message` 而非 `e?.data?.message`。 + ### 3.4 错误处理检查清单 新增 API 接口时,必须确认以下各项: @@ -136,6 +138,7 @@ HTTP 响应错误: - [ ] 后端不使用 `_` 忽略 error,至少 log warning - [ ] 前端调用方的 catch 不自行覆盖 message(交给拦截器统一处理) - [ ] 前端页面级 catch 只做状态清理(如 loading = false),不重复弹提示 +- [ ] 前端 catch 中使用 `e?.message`(非 `e?.data?.message`)获取错误信息 --- diff --git a/docs/plans/2026-02-27-echochat-system-design.md b/docs/plans/2026-02-27-echochat-system-design.md index e8a7761..7410339 100644 --- a/docs/plans/2026-02-27-echochat-system-design.md +++ b/docs/plans/2026-02-27-echochat-system-design.md @@ -854,7 +854,7 @@ pages/ │ ├── conversation.vue # 聊天对话页 ✅ Phase 2b │ ├── settings.vue # 聊天设置页 ✅ Phase 2b │ ├── search.vue # 消息搜索页 ✅ Phase 2b -│ └── group-create.vue # 创建群聊 📋 Phase 2c +│ └── group-create.vue # 创建群聊(含全站用户搜索) ✅ Phase 2c ├── contact/ │ ├── index.vue # 联系人列表(含搜索/在线状态) ✅ Phase 2a │ ├── search.vue # 搜索添加好友 + 好友推荐 ✅ Phase 2a @@ -989,16 +989,17 @@ services: - 前台 4 个页面 + chat Store + API 封装 - 设计文档:`docs/plans/2026-03-03-phase2b-design.md` -#### Phase 2c:群聊与已读回执 🔜 设计完成,待实施 +#### Phase 2c:群聊与已读回执 ✅ 全部完成 - 群聊管理(三级角色:建群/加入/退出/解散/角色管理/禁言/全体禁言/群公告/入群审批) - 群消息收发(复用 im.message.* + @某人/@所有人 + 管理员撤回无时限 + 系统消息) - 已读回执(单聊会话级 last_read_msg_id + 群聊消息级 im_message_reads + 实时推送) - MinIO 文件存储服务(Docker + 通用上传 API) - 管理端群聊管理(群列表/详情/解散/移除成员) - 前端 9 个新页面 + 群聊 Store + 会话列表 Tab 改造 +- 创建群聊/邀请入群支持全站搜索非好友用户 - 新增 3 张数据库表(im_groups / im_group_join_requests / im_message_reads) - 设计文档:`docs/plans/2026-03-04-phase2c-design.md` -- 实施计划:`docs/plans/2026-03-04-phase2c-implementation.plan.md`(14 个 Task) +- 实施计划:`docs/plans/2026-03-04-phase2c-implementation.plan.md`(14 个 Task + 10 项测试修复) #### Phase 2d:消息类型扩展 📋 待规划 - 消息类型扩展(图片/语音/文件消息) diff --git a/docs/plans/2026-03-04-phase2c-design.md b/docs/plans/2026-03-04-phase2c-design.md index 6d94f7c..34f36aa 100644 --- a/docs/plans/2026-03-04-phase2c-design.md +++ b/docs/plans/2026-03-04-phase2c-design.md @@ -1,6 +1,6 @@ # Phase 2c 设计文档:群聊与已读回执 -> **状态:** ✅ 已完成实施(含代码审查修复 14 项) +> **状态:** ✅ 已完成(含代码审查修复 14 项 + 浏览器测试修复 21 项) > **分支:** `feature/phase2c-group-read-receipt` > **前置依赖:** Phase 2b 全部完成(单聊即时通讯) > **最后更新:** 2026-03-04 @@ -201,6 +201,7 @@ COMMENT ON COLUMN im_groups.status IS '群状态:1=正常,2=已解 CREATE INDEX idx_im_groups_owner ON im_groups(owner_id); CREATE INDEX idx_im_groups_name ON im_groups USING gin(to_tsvector('simple', name)); +-- 注意:实际搜索使用 ILIKE 模糊匹配(to_tsvector 不支持混合中英文词汇的分词) ``` #### im_group_join_requests(入群申请表) @@ -458,12 +459,12 @@ im_conversations (type=2 群聊) |------|------|----------| | 群聊会话列表 | pages/group/index.vue | 群聊 Tab 页,显示群会话列表 | | 群聊对话页 | pages/group/conversation.vue | 群消息展示 + 发送 + @选择器 | -| 创建群聊 | pages/group/create.vue | 选好友 + 搜索用户 + 填群名 | +| 创建群聊 | pages/group/create.vue | 选好友 + 全站搜索非好友用户 + 填群名(最少 1 人即可创建) | | 群设置 | pages/group/settings.vue | 群信息/公告/成员概览/免打扰/退群/解散 | -| 群成员列表 | pages/group/members.vue | 完整成员列表 + 角色标识 + 管理操作 | -| 邀请入群 | pages/group/invite.vue | 搜索用户 ID 拉人入群 | +| 群成员列表 | pages/group/members.vue | 完整成员列表 + 全角色标识(群主/管理员/成员)+ 自定义操作弹窗(含头像角色信息)+ 管理操作 | +| 邀请入群 | pages/group/invite.vue | 好友多选 + 全站搜索非好友用户 + 排除已在群内成员 | | 入群审批 | pages/group/join-requests.vue | 群主/管理员审批入群申请 | -| 搜索群聊 | pages/group/search.vue | 搜索公开群 + 申请加入 | +| 搜索群聊 | pages/group/search.vue | 搜索公开群 + 申请加入 + 已加入状态智能显示 | | 已读详情 | pages/chat/read-detail.vue | 已读/未读人员列表(单聊+群聊共用) | ### 7.2 修改现有页面 diff --git a/docs/plans/2026-03-04-phase2c-implementation.plan.md b/docs/plans/2026-03-04-phase2c-implementation.plan.md index c1147a2..25e0f98 100644 --- a/docs/plans/2026-03-04-phase2c-implementation.plan.md +++ b/docs/plans/2026-03-04-phase2c-implementation.plan.md @@ -1,9 +1,9 @@ # Phase 2c 实施计划:群聊与已读回执 -> **状态:** ✅ 已完成(含代码审查修复 14 项) +> **状态:** ✅ 已完成(含代码审查修复 14 项 + 浏览器测试修复 21 项) > **设计文档:** `docs/plans/2026-03-04-phase2c-design.md` > **分支:** `feature/phase2c-group-read-receipt` -> **预计 Task 数:** 14 个(全部完成) +> **预计 Task 数:** 14 个(全部完成)+ Playwright 端到端测试 > **最后更新:** 2026-03-04 --- @@ -625,3 +625,33 @@ Task 0 + Task 12 ── Task 13 (管理端+文档+审查) 5. **权限层级**:群主(2) > 管理员(1) > 成员(0),操作时必须校验层级 6. **Wire 依赖**:Phase 2b 中 Wire 有过手动 patch 历史,注意检查 wire_gen.go 一致性 7. **依赖管理**:当前 Go 版本 1.23.12,添加新依赖必须选择兼容版本 + +--- + +## Playwright 浏览器测试修复记录 + +> 在代码审查修复完成后,使用 Playwright MCP 进行端到端浏览器测试,共发现并修复 21 项问题。 + +| # | 类型 | 修复内容 | 涉及文件 | +|---|------|----------|----------| +| T1 | Bug | create.vue 创建群后跳转使用 `result.id`(原 `result.group_id` 不存在) | `frontend/src/pages/group/create.vue` | +| T2 | Bug | 群聊页面成员显示使用 `user_nickname` 替代 `username`(对齐 GroupMemberDTO) | `conversation/settings/members/join-requests.vue` | +| T3 | UI | 管理端群详情弹窗重新设计(卡片头像+分区布局+动态配色) | `admin/src/views/group/list.vue` | +| T4 | UX | 群搜索结果已加入的群显示「已加入」标签,不显示「申请加入」 | `frontend/src/pages/group/search.vue` | +| T5 | Bug | `SearchGroups` 从 `to_tsvector` 改为 `ILIKE`(修复混合中英文搜索) | `backend/go-service/app/group/dao/group_dao.go` | +| T6 | Bug | conversation.vue 增加 groupId=0 时从 chatStore 回退查找 | `frontend/src/pages/group/conversation.vue` | +| T7 | UX | 创建群最低人数从 2 改为 1(支持 2 人群聊) | `frontend/src/pages/group/create.vue` | +| T8 | 功能 | 创建群聊支持搜索非好友用户(全站搜索 + 非好友标签 + selectedMap) | `frontend/src/pages/group/create.vue` | +| T9 | 功能 | 邀请入群支持搜索非好友用户(同 create.vue 改造模式) | `frontend/src/pages/group/invite.vue` | +| T10 | Bug | 管理端成员表用 `username` 替代 `user_nickname`(对齐 admin DTO) | `admin/src/views/group/list.vue` | +| T11 | UX | 群聊已读回执:无人已读时显示「0人已读」而非隐藏标签 | `frontend/src/pages/group/conversation.vue` | +| T12 | Bug | 新好友聊天页 conversationId=0 时「加载更多」点击 400 报错 | `frontend/src/pages/chat/conversation.vue` + `frontend/src/store/chat.js` | +| T13 | UX | 联系人 TabBar 添加好友申请未读数 badge(与消息 Tab 一致) | `frontend/src/components/CustomTabBar.vue` | +| T14 | 功能 | App.vue 启动时预加载好友申请数,确保 badge 立即可见 | `frontend/src/App.vue` | +| T15 | Bug | 单聊已读回执:后端 MarkRead 缺失 `im.message.read.ack` 推送 | `backend/go-service/app/im/service/im_service.go` | +| T16 | UX | 群成员列表为所有角色添加身份标识(🔑群主/🛡管理员/👤成员) | `frontend/src/pages/group/members.vue` | +| T17 | Bug | 全局修复 `e?.data?.message` → `e?.message`(8 文件 18 处) | `create/search/read-detail/members/join-requests/settings/request.vue` | +| T18 | UI/UX | 群成员管理操作弹窗改为自定义组件 + 三个点按钮从 @longpress 改为 @tap | `frontend/src/pages/group/members.vue` | +| T19 | 功能 | 已读详情页展示群昵称:DTO 新增 group_nickname + DAO 批量查询 + 前端主显群昵称/副显真实昵称 | `group_dto.go` + `conversation_dao.go` + `im_service.go` + `read-detail.vue` | +| T20 | Bug | 消息免打扰后端 API 补全:DAO UpdateMemberDND + Service SetDoNotDisturb + Controller + Router 注册 | `conversation_dao.go` + `im_service.go` + `im_controller.go` + `router.go` | +| T21 | Bug | 联系人 Tab 页切换回来数据不刷新:新增 onShow 生命周期钩子自动重新获取好友列表和待处理申请数 | `pages/contact/index.vue` | diff --git a/docs/progress/CURRENT_STATUS.md b/docs/progress/CURRENT_STATUS.md index 5c9bdeb..f626fca 100644 --- a/docs/progress/CURRENT_STATUS.md +++ b/docs/progress/CURRENT_STATUS.md @@ -1,7 +1,7 @@ # EchoChat 项目开发进度 -> **最后更新**:2026-03-04(Phase 2c 代码审查修复完成) -> **当前阶段**:Phase 2c 代码审查修复完成,待最终验证 +> **最后更新**:2026-03-04(Phase 2c 浏览器测试 + UI/UX 优化 + Tab 切换刷新修复完成) +> **当前阶段**:Phase 2c 全部完成(含 Playwright 浏览器测试 + 21 项用户反馈修复) > **当前分支**:`feature/phase2c-group-read-receipt` > **实施计划**:`docs/plans/2026-03-04-phase2c-implementation.plan.md` > **设计文档**:`docs/plans/2026-03-04-phase2c-design.md` @@ -212,20 +212,42 @@ EchoChat/ ## 七、开发测试指南 -### 启动命令 +### 服务管理命令 + +> 建议开 4 个终端窗口,分别运行各服务。终止方式统一为 `Ctrl + C` 或 `kill` 命令。 + +#### 基础设施(Docker Compose) ```bash -# 1. 启动 PostgreSQL + Redis -cd deploy && docker compose -f docker-compose.dev.yml up -d postgres redis +# 一键启动全部基础设施(PostgreSQL + Redis + MinIO) +cd deploy && docker compose -f docker-compose.dev.yml up -d postgres redis minio -# 2. 启动 Go 后端(http://localhost:8085) -cd backend/go-service && go run cmd/server/main.go +# 查看容器状态 +cd deploy && docker compose -f docker-compose.dev.yml ps -# 3. 启动管理端(http://localhost:3100) -cd admin && npm run dev +# 一键停止全部基础设施 +cd deploy && docker compose -f docker-compose.dev.yml stop +``` -# 4. 启动前台 H5(http://localhost:5173+) -cd frontend && npm run dev:h5 +| 服务 | 地址 | 单独启动 | 单独停止 | +|------|------|----------|----------| +| PostgreSQL | localhost:5432 | `docker compose -f docker-compose.dev.yml up -d postgres` | `docker compose -f docker-compose.dev.yml stop postgres` | +| Redis | localhost:6379 | `docker compose -f docker-compose.dev.yml up -d redis` | `docker compose -f docker-compose.dev.yml stop redis` | +| MinIO API | localhost:9000 | `docker compose -f docker-compose.dev.yml up -d minio` | `docker compose -f docker-compose.dev.yml stop minio` | +| MinIO 控制台 | localhost:9001 | (同上) | (同上) | + +#### 应用服务 + +| 服务 | 地址 | 启动命令 | 终止命令 | +|------|------|----------|----------| +| Go 后端 | http://localhost:8085 | `cd backend/go-service && go run cmd/server/main.go` | `Ctrl+C` 或 `kill $(lsof -ti :8085)` | +| 前台用户端 (H5) | http://localhost:5173 | `cd frontend && npm run dev:h5` | `Ctrl+C` 或 `kill $(lsof -ti :5173)` | +| 后台管理端 | http://localhost:3100 | `cd admin && npm run dev` | `Ctrl+C` 或 `kill $(lsof -ti :3100)` | + +#### Go 后端快速重启(一行命令) + +```bash +kill $(lsof -ti :8085) 2>/dev/null; sleep 2; cd backend/go-service && go run cmd/server/main.go ``` ### 测试账号 @@ -249,7 +271,7 @@ cd frontend && npm run dev:h5 ## 八、Phase 2c — 群聊与已读回执 -> **状态:** 实施中 +> **状态:** ✅ 已完成 > **设计文档:** `docs/plans/2026-03-04-phase2c-design.md` > **实施计划:** `docs/plans/2026-03-04-phase2c-implementation.plan.md` > **分支:** `feature/phase2c-group-read-receipt` @@ -284,6 +306,33 @@ cd frontend && npm run dev:h5 | Task 12 | 群聊辅助功能(入群审批 + 搜索群聊) | ✅ 完成 | | Task 13 | 管理端群组管理 + 文档更新 | ✅ 完成 | | 代码审查修复 | 14 项修复(Critical×5 + Important×4 + Minor×2 + Suggestion×3) | ✅ 完成 | +| Playwright 测试 + 用户反馈修复 | 浏览器端到端测试 + 21 项修复(搜索/UI/交互/功能增强) | ✅ 完成 | + +### 用户测试修复详情(Phase 2c) + +| # | 修复内容 | +|---|----------| +| Fix T1 | create.vue 创建群后跳转使用 `result.id`(原 `result.group_id` 字段不存在) | +| Fix T2 | conversation/settings/members/join-requests 用 `user_nickname` 替代 `username`(对齐 DTO) | +| Fix T3 | 管理端群组详情弹窗 UI 全面重设计(卡片+头像+分区布局+成员mini头像) | +| Fix T4 | 群搜索结果已在群内的显示「已加入」标签,不再显示「申请加入」按钮 | +| Fix T5 | group_dao.go `SearchGroups` 改用 ILIKE 模糊匹配(原 to_tsvector 不支持混合词搜索) | +| Fix T6 | conversation.vue 增加 groupId=0 时从 chatStore 回退查找 group_id | +| Fix T7 | create.vue 最低选择人数从 2 改为 1(支持 2 人群聊) | +| Fix T8 | create.vue 支持搜索非好友用户并加入群聊(全站用户搜索 + 非好友标签) | +| Fix T9 | invite.vue 支持搜索非好友用户并邀请入群(同 create.vue 改造) | +| Fix T10 | admin/list.vue 成员表用 `username` 替代 `user_nickname`(对齐 admin DTO) | +| Fix T11 | 群聊已读回执优化:无人已读时显示「0人已读」(含点击跳转已读详情功能) | +| Fix T12 | 新好友聊天页 conversationId=0 时「加载更多」点击报错(hasMore 增加 ID 校验) | +| Fix T13 | 联系人 TabBar 添加好友申请未读数 badge(与消息 Tab 一致的交互体验) | +| Fix T14 | App.vue 启动时预加载好友申请数,确保 badge 立即可见 | +| Fix T15 | 单聊已读回执:后端 MarkRead 缺失 `im.message.read.ack` 推送(补全对方已读通知链路) | +| Fix T16 | 群成员列表页为所有角色添加身份标识(群主/管理员/成员) | +| Fix T17 | 全局修复 `e?.data?.message` → `e?.message`(8 个页面 18 处,确保后端错误信息正确展示) | +| Fix T18 | members.vue 管理操作弹窗改为自定义组件(头像+角色+图标操作列表),替代 uni.showActionSheet;三个点按钮从 @longpress 改为 @tap | +| Fix T19 | 已读详情页展示群内昵称:后端 DTO 新增 group_nickname 字段 + DAO 批量查询群昵称 + 前端主显群昵称/副显真实昵称 | +| Fix T20 | 消息免打扰 API 补全:后端缺失 DAO/Service/Controller/Router 完整链路(PUT /api/v1/im/conversations/:id/dnd) | +| Fix T21 | 联系人 Tab 页切换回来后数据不刷新:onMounted → 增加 onShow 生命周期钩子自动重新获取好友列表和待处理申请数 | ### 代码审查修复详情(Phase 2c) @@ -317,13 +366,13 @@ cd frontend && npm run dev:h5 | 页面 | 路径 | 功能 | |------|------|------| | 群聊对话页 | `pages/group/conversation.vue` | 群消息收发 + @选择器 + 已读计数 | -| 创建群聊页 | `pages/group/create.vue` | 好友多选 + 群名称输入 | +| 创建群聊页 | `pages/group/create.vue` | 好友/非好友多选 + 全站用户搜索 + 群名称输入 | | 群设置页 | `pages/group/settings.vue` | 群信息修改 + 成员概览 + 退出/解散 | -| 群成员页 | `pages/group/members.vue` | 成员列表 + 角色管理 + 禁言操作 | -| 邀请入群页 | `pages/group/invite.vue` | 好友多选 + 排除已在群内成员 | +| 群成员页 | `pages/group/members.vue` | 成员列表 + 全角色标识(群主/管理员/成员)+ 自定义操作弹窗 + 角色管理 + 禁言操作 | +| 邀请入群页 | `pages/group/invite.vue` | 好友/非好友多选 + 全站用户搜索 + 排除已在群内成员 | | 入群审批页 | `pages/group/join-requests.vue` | 申请列表 + 通过/拒绝操作 | -| 搜索群聊页 | `pages/group/search.vue` | 关键词搜索 + 申请加入 | -| 已读详情页 | `pages/chat/read-detail.vue` | 已读/未读成员列表(群聊消息级) | +| 搜索群聊页 | `pages/group/search.vue` | 关键词搜索 + 申请加入 + 已加入状态显示 | +| 已读详情页 | `pages/chat/read-detail.vue` | 已读/未读成员列表(群聊消息级)+ 群昵称优先展示 + 真实昵称副行 | | 会话列表改造 | `pages/chat/index.vue` | Tab 切换(全部/单聊/群聊)+ @标记 + 免打扰标识 | #### 管理端新增 diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 502f44d..2de7333 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -40,6 +40,7 @@ export default { chatStore.initWsListeners() contactStore.initWsListeners() groupStore.initWsListeners() + contactStore.fetchPendingRequests().catch(() => {}) } } } diff --git a/frontend/src/components/CustomTabBar.vue b/frontend/src/components/CustomTabBar.vue index ca76896..c3511b3 100644 --- a/frontend/src/components/CustomTabBar.vue +++ b/frontend/src/components/CustomTabBar.vue @@ -8,7 +8,7 @@ 功能: - 底部导航栏,4 个 Tab:消息 / 联系人 / 会议 / 我的 - 选中态使用 filled 图标 + Primary 色,未选中态使用轮廓图标 + Muted 色 - - 消息 Tab 支持未读消息 badge + - 消息 Tab 支持未读消息 badge,联系人 Tab 支持好友申请未读 badge - 使用 switchTab 跳转 -->