feat:群聊问题修复+相关项目文档更新

This commit is contained in:
bujinyuan
2026-03-05 09:24:49 +08:00
parent 4f55ef6de0
commit 8ee870636c
29 changed files with 1155 additions and 225 deletions

View File

@@ -18,10 +18,11 @@ alwaysApply: true
- **Phase 2aWebSocket 实时通讯与联系人管理)**:✅ 全部完成13 个 Task + 后期 Bug 修复 3 项) - **Phase 2aWebSocket 实时通讯与联系人管理)**:✅ 全部完成13 个 Task + 后期 Bug 修复 3 项)
- **Phase 2b即时通讯消息系统**:✅ 全部完成10 个 Task + 代码审查修复 7 项 + 用户测试修复 8 项),设计文档 `docs/plans/2026-03-03-phase2b-design.md` - **Phase 2b即时通讯消息系统**:✅ 全部完成10 个 Task + 代码审查修复 7 项 + 用户测试修复 8 项),设计文档 `docs/plans/2026-03-03-phase2b-design.md`
- 分支:`feature/phase2b-instant-messaging` - 分支:`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` - 分支:`feature/phase2c-group-read-receipt`
- 范围:群聊全功能(三级角色/禁言/@提醒/群公告/入群审批)+ 已读回执(单聊会话级 + 群聊消息级)+ MinIO 文件存储 + 管理端群组管理 - 范围:群聊全功能(三级角色/禁言/@提醒/群公告/入群审批)+ 已读回执(单聊会话级 + 群聊消息级)+ MinIO 文件存储 + 管理端群组管理
- 代码审查修复Critical×5角色类型/公告字段/at_user_ids/@导航/Wire 注册)+ Important×4 + Minor×2 + Suggestion×3 - 代码审查修复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.FriendshipDAOim.OfflineMessagePusher → ws.Handlercontact.OnlineChecker → ws.OnlineServiceim.GroupInfoGetter → group.GroupDAOim.MessageReadRecorder → group.MessageReadDAOgroup.UserInfoProvider → auth.UserDAOgroup.MessageWriter → im.MessageDAO - **跨模块通信模式**接口注入标准ws.FriendIDsGetter / im.FriendChecker / im.UserInfoGetter → contact.FriendshipDAOim.OfflineMessagePusher → ws.Handlercontact.OnlineChecker → ws.OnlineServiceim.GroupInfoGetter → group.GroupDAOim.MessageReadRecorder → group.MessageReadDAOgroup.UserInfoProvider → auth.UserDAOgroup.MessageWriter → im.MessageDAO
## 项目概述 ## 项目概述

View File

@@ -117,57 +117,115 @@
</el-card> </el-card>
<!-- 群组详情弹窗 --> <!-- 群组详情弹窗 -->
<el-dialog v-model="detailVisible" title="群组详情" width="700px" destroy-on-close> <el-dialog v-model="detailVisible" title="群组详情" width="720px" destroy-on-close class="detail-dialog">
<div v-loading="detailLoading"> <div v-loading="detailLoading" class="detail-content">
<template v-if="currentGroup"> <template v-if="currentGroup">
<el-descriptions :column="2" border> <!-- 群头部卡片 -->
<el-descriptions-item label="群 ID">{{ currentGroup.id }}</el-descriptions-item> <div class="detail-header">
<el-descriptions-item label="会话 ID">{{ currentGroup.conversation_id }}</el-descriptions-item> <div class="detail-avatar" :style="{ backgroundColor: avatarColor }">
<el-descriptions-item label="群名称">{{ currentGroup.name }}</el-descriptions-item> {{ currentGroup.name?.[0] || 'G' }}
<el-descriptions-item label="群主">{{ currentGroup.owner_name }}</el-descriptions-item> </div>
<el-descriptions-item label="成员数">{{ currentGroup.member_count }}/{{ currentGroup.max_members }}</el-descriptions-item> <div class="detail-header-info">
<el-descriptions-item label="状态"> <div class="detail-header-name">
<el-tag v-if="currentGroup.status === 1" type="success" size="small">正常</el-tag> <span class="detail-group-name">{{ currentGroup.name }}</span>
<el-tag v-else type="danger" size="small">已解散</el-tag> <el-tag v-if="currentGroup.status === 1" type="success" size="small" effect="light">正常</el-tag>
</el-descriptions-item> <el-tag v-else type="danger" size="small" effect="light">已解散</el-tag>
<el-descriptions-item label="全体禁言"> </div>
<el-tag v-if="currentGroup.is_all_muted" type="warning" size="small"></el-tag> <div class="detail-header-meta">
<el-tag v-else type="info" size="small"></el-tag> <span class="meta-item">
</el-descriptions-item> <el-icon :size="14"><User /></el-icon>
<el-descriptions-item label="可搜索"> {{ currentGroup.member_count }}/{{ currentGroup.max_members }} 名成员
{{ currentGroup.is_searchable ? '是' : '否' }} </span>
</el-descriptions-item> <el-divider direction="vertical" />
<el-descriptions-item label="创建时间" :span="2">{{ currentGroup.created_at }}</el-descriptions-item> <span class="meta-item">群主{{ currentGroup.owner_name }}</span>
<el-descriptions-item label="群公告" :span="2"> <el-divider direction="vertical" />
<span class="meta-item">ID: {{ currentGroup.id }}</span>
</div>
</div>
</div>
<!-- 群信息网格 -->
<div class="detail-grid">
<div class="detail-grid-item">
<span class="grid-label">会话 ID</span>
<span class="grid-value">{{ currentGroup.conversation_id }}</span>
</div>
<div class="detail-grid-item">
<span class="grid-label">全体禁言</span>
<span class="grid-value">
<el-tag v-if="currentGroup.is_all_muted" type="warning" size="small" effect="plain">开启</el-tag>
<span v-else class="text-muted">未开启</span>
</span>
</div>
<div class="detail-grid-item">
<span class="grid-label">可被搜索</span>
<span class="grid-value">
<el-tag v-if="currentGroup.is_searchable" type="success" size="small" effect="plain">开启</el-tag>
<span v-else class="text-muted">关闭</span>
</span>
</div>
<div class="detail-grid-item">
<span class="grid-label">创建时间</span>
<span class="grid-value">{{ currentGroup.created_at }}</span>
</div>
</div>
<!-- 群公告 -->
<div class="detail-section">
<div class="section-header">
<el-icon :size="16"><ChatLineSquare /></el-icon>
<span>群公告</span>
</div>
<div class="notice-content" :class="{ 'notice-empty': !currentGroup.notice }">
{{ currentGroup.notice || '暂无公告' }} {{ currentGroup.notice || '暂无公告' }}
</el-descriptions-item> </div>
</el-descriptions> </div>
<!-- 成员列表 --> <!-- 成员列表 -->
<h4 class="member-title">群成员{{ currentGroup.members?.length || 0 }}</h4> <div class="detail-section">
<el-table :data="currentGroup.members || []" stripe border max-height="300" size="small"> <div class="section-header">
<el-table-column prop="user_id" label="用户 ID" width="80" align="center" /> <el-icon :size="16"><UserFilled /></el-icon>
<el-table-column prop="username" label="用户名" min-width="120" /> <span>群成员{{ currentGroup.members?.length || 0 }}</span>
<el-table-column prop="nickname" label="群昵称" min-width="120"> </div>
<el-table
:data="currentGroup.members || []"
stripe
max-height="280"
size="small"
class="member-table"
>
<el-table-column prop="user_id" label="ID" width="60" align="center" />
<el-table-column label="用户" min-width="140">
<template #default="{ row }"> <template #default="{ row }">
{{ row.nickname || '-' }} <div class="member-cell">
<div class="member-mini-avatar" :style="{ backgroundColor: getMemberColor(row.username || row.nickname) }">
{{ (row.username || row.nickname || '?')[0] }}
</div>
<span class="member-cell-name">{{ row.username || row.nickname || '-' }}</span>
</div>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="角色" width="100" align="center"> <el-table-column label="群昵称" min-width="100">
<template #default="{ row }"> <template #default="{ row }">
<el-tag v-if="row.role === 2" type="danger" size="small">群主</el-tag> <span class="text-muted">{{ row.nickname || '-' }}</span>
<el-tag v-else-if="row.role === 1" type="warning" size="small">管理员</el-tag>
<el-tag v-else type="info" size="small">成员</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="禁言" width="80" align="center"> <el-table-column label="角色" width="90" align="center">
<template #default="{ row }"> <template #default="{ row }">
<el-tag v-if="row.is_muted" type="danger" size="small"></el-tag> <el-tag v-if="row.role === 2" type="danger" size="small" effect="light" round>群主</el-tag>
<span v-else>-</span> <el-tag v-else-if="row.role === 1" type="warning" size="small" effect="light" round>管理员</el-tag>
<el-tag v-else size="small" effect="plain" round>成员</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="joined_at" label="加入时间" width="160" align="center" /> <el-table-column label="禁言" width="70" align="center">
<template #default="{ row }">
<el-icon v-if="row.is_muted" color="#EF4444" :size="16"><CircleCloseFilled /></el-icon>
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
<el-table-column prop="joined_at" label="加入时间" width="150" align="center" />
</el-table> </el-table>
</div>
</template> </template>
</div> </div>
</el-dialog> </el-dialog>
@@ -180,11 +238,22 @@
* *
* 数据流API → 列表渲染 → 分页 / 搜索 → 详情弹窗 * 数据流API → 列表渲染 → 分页 / 搜索 → 详情弹窗
*/ */
import { ref, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { Search } from '@element-plus/icons-vue' import { Search, User, UserFilled, ChatLineSquare, CircleCloseFilled } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { getGroupList, getGroupDetail, dissolveGroup } from '@/api/group' 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 loading = ref(false)
const groupList = ref([]) const groupList = ref([])
const total = ref(0) const total = ref(0)
@@ -313,10 +382,166 @@ onMounted(() => {
cursor: default; cursor: default;
} }
.member-title { /* ===== 群组详情弹窗 ===== */
margin: 20px 0 12px;
font-size: 15px; .detail-content {
font-weight: 600; 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; 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;
} }
</style> </style>

View File

@@ -137,8 +137,9 @@ type MessageReadCountDTO struct {
// MessageReadDetailDTO 消息已读详情传输对象 // MessageReadDetailDTO 消息已读详情传输对象
type MessageReadDetailDTO struct { type MessageReadDetailDTO struct {
UserID int64 `json:"user_id"` // 已读用户 ID UserID int64 `json:"user_id"` // 已读用户 ID
UserNickname string `json:"user_nickname"` // 用户昵称 UserNickname string `json:"user_nickname"` // 用户真实昵称
UserAvatar string `json:"user_avatar"` // 用户头像 UserAvatar string `json:"user_avatar"` // 用户头像
GroupNickname string `json:"group_nickname,omitempty"` // 群内昵称(仅群聊有效,未设置时为空)
ReadAt string `json:"read_at"` // 已读时间 ReadAt string `json:"read_at"` // 已读时间
} }

View File

@@ -328,8 +328,8 @@ func (d *GroupDAO) SearchGroups(ctx context.Context, keyword string, offset, lim
var total int64 var total int64
query := d.db.WithContext(ctx). query := d.db.WithContext(ctx).
Model(&model.Group{}). Model(&model.Group{}).
Where("status = ? AND is_searchable = true AND to_tsvector('simple', name) @@ plainto_tsquery('simple', ?)", Where("status = ? AND is_searchable = true AND name ILIKE ?",
constants.GroupStatusNormal, keyword) constants.GroupStatusNormal, "%"+keyword+"%")
if err := query.Count(&total).Error; err != nil { if err := query.Count(&total).Error; err != nil {
logs.Error(ctx, funcName, "搜索计数失败", zap.Error(err)) logs.Error(ctx, funcName, "搜索计数失败", zap.Error(err))

View File

@@ -63,6 +63,35 @@ func (ctl *IMController) GetHistoryMessages(c *gin.Context) {
utils.ResponseOK(c, result) 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 置顶/取消置顶会话 // PinConversation 置顶/取消置顶会话
// PUT /api/v1/im/conversations/:id/pin // PUT /api/v1/im/conversations/:id/pin
func (ctl *IMController) PinConversation(c *gin.Context) { func (ctl *IMController) PinConversation(c *gin.Context) {

View File

@@ -159,6 +159,29 @@ func (d *ConversationDAO) GetConversationMemberIDs(ctx context.Context, conversa
return ids, err 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 更新会话成员的置顶状态 // UpdateMemberPinned 更新会话成员的置顶状态
func (d *ConversationDAO) UpdateMemberPinned(ctx context.Context, conversationID, userID int64, isPinned bool) error { func (d *ConversationDAO) UpdateMemberPinned(ctx context.Context, conversationID, userID int64, isPinned bool) error {
funcName := "dao.conversation_dao.UpdateMemberPinned" funcName := "dao.conversation_dao.UpdateMemberPinned"
@@ -257,6 +280,14 @@ func (d *ConversationDAO) GetMemberDNDMap(ctx context.Context, conversationID in
return result, nil 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 清零指定成员的未读消息数 // ClearUnread 清零指定成员的未读消息数
func (d *ConversationDAO) ClearUnread(ctx context.Context, conversationID, userID int64, lastMsgID int64) error { func (d *ConversationDAO) ClearUnread(ctx context.Context, conversationID, userID int64, lastMsgID int64) error {
return d.db.WithContext(ctx). return d.db.WithContext(ctx).

View File

@@ -14,6 +14,7 @@ func RegisterRoutes(r *gin.Engine, ctrl *controller.IMController, jwtAuth gin.Ha
// 会话管理 // 会话管理
authed.GET("/conversations", ctrl.GetConversations) authed.GET("/conversations", ctrl.GetConversations)
authed.PUT("/conversations/:id/pin", ctrl.PinConversation) authed.PUT("/conversations/:id/pin", ctrl.PinConversation)
authed.PUT("/conversations/:id/dnd", ctrl.SetDoNotDisturb)
authed.DELETE("/conversations/:id", ctrl.DeleteConversation) authed.DELETE("/conversations/:id", ctrl.DeleteConversation)
authed.DELETE("/conversations/:id/messages", ctrl.ClearHistory) authed.DELETE("/conversations/:id/messages", ctrl.ClearHistory)

View File

@@ -525,7 +525,7 @@ func (s *IMService) GetHistoryMessages(ctx context.Context, userID int64, req *d
return &dto.HistoryMessageResponse{List: list, HasMore: hasMore}, nil 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 { func (s *IMService) MarkRead(ctx context.Context, userID int64, conversationID int64) error {
funcName := "service.im_service.MarkRead" funcName := "service.im_service.MarkRead"
logs.Info(ctx, funcName, "标记已读", logs.Info(ctx, funcName, "标记已读",
@@ -536,22 +536,35 @@ func (s *IMService) MarkRead(ctx context.Context, userID int64, conversationID i
return ErrNotMember return ErrNotMember
} }
if member.UnreadCount == 0 {
return nil
}
latestMsgID, err := s.msgDAO.GetLatestMessageID(ctx, conversationID) latestMsgID, err := s.msgDAO.GetLatestMessageID(ctx, conversationID)
if err != nil { if err != nil {
logs.Error(ctx, funcName, "获取最新消息 ID 失败", zap.Error(err)) logs.Error(ctx, funcName, "获取最新消息 ID 失败", zap.Error(err))
return err return err
} }
if member.UnreadCount > 0 {
if err := s.convDAO.ClearUnread(ctx, conversationID, userID, latestMsgID); err != nil { if err := s.convDAO.ClearUnread(ctx, conversationID, userID, latestMsgID); err != nil {
logs.Error(ctx, funcName, "清零未读失败", zap.Error(err)) logs.Error(ctx, funcName, "清零未读失败", zap.Error(err))
return err return err
} }
s.decrementTotalUnread(ctx, userID, member.UnreadCount) 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,
})
}
}
return nil 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 var readList, unreadList []dto.MessageReadDetailDTO
for _, uid := range allUserIDs { for _, uid := range allUserIDs {
item := dto.MessageReadDetailDTO{ item := dto.MessageReadDetailDTO{
@@ -667,6 +686,9 @@ func (s *IMService) GetMessageReadDetail(ctx context.Context, userID int64, mess
item.UserNickname = brief.Nickname item.UserNickname = brief.Nickname
item.UserAvatar = brief.Avatar item.UserAvatar = brief.Avatar
} }
if gn, ok := nicknameMap[uid]; ok {
item.GroupNickname = gn
}
if readUserIDs[uid] { if readUserIDs[uid] {
readList = append(readList, item) readList = append(readList, item)
} else { } else {
@@ -682,6 +704,20 @@ func (s *IMService) GetMessageReadDetail(ctx context.Context, userID int64, mess
}, nil }, 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 置顶/取消置顶会话 // PinConversation 置顶/取消置顶会话
func (s *IMService) PinConversation(ctx context.Context, userID int64, conversationID int64, isPinned bool) error { func (s *IMService) PinConversation(ctx context.Context, userID int64, conversationID int64, isPinned bool) error {
funcName := "service.im_service.PinConversation" funcName := "service.im_service.PinConversation"

View File

@@ -3,7 +3,7 @@
> 通用规范(认证方式、响应格式、错误码)见 [README.md](../README.md) > 通用规范(认证方式、响应格式、错误码)见 [README.md](../README.md)
> 消息的实时收发(发送/撤回/标记已读/正在输入)通过 WebSocket 完成,见 [websocket.md](../websocket.md) > 消息的实时收发(发送/撤回/标记已读/正在输入)通过 WebSocket 完成,见 [websocket.md](../websocket.md)
> 本文档中的接口用于会话管理和消息历史查询等非实时操作。 > 本文档中的接口用于会话管理和消息历史查询等非实时操作。
> **最后更新:** 2026-03-03代码审查修复后同步 > **最后更新:** 2026-03-04Fix T19 已读详情群昵称 + Fix T20 免打扰 API 补全
--- ---
@@ -14,10 +14,12 @@
| GET | /api/v1/im/conversations | 需认证 | 获取会话列表 | | GET | /api/v1/im/conversations | 需认证 | 获取会话列表 |
| GET | /api/v1/im/messages | 需认证 | 获取历史消息(游标分页) | | GET | /api/v1/im/messages | 需认证 | 获取历史消息(游标分页) |
| PUT | /api/v1/im/conversations/:id/pin | 需认证 | 置顶/取消置顶 | | 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 | 需认证 | 删除会话(软删除) |
| DELETE | /api/v1/im/conversations/:id/messages | 需认证 | 清空聊天记录(个人视图) | | DELETE | /api/v1/im/conversations/:id/messages | 需认证 | 清空聊天记录(个人视图) |
| GET | /api/v1/im/messages/search | 需认证 | 全局消息搜索 | | GET | /api/v1/im/messages/search | 需认证 | 全局消息搜索 |
| GET | /api/v1/im/unread | 需认证 | 获取全局未读消息总数 | | 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. 删除会话 ## 4. 删除会话
`DELETE /api/v1/im/conversations/:id` `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 | 群成员总数(不含消息发送者本人) |

View File

@@ -2,7 +2,7 @@
> **适用范围**EchoChat 项目全端Go 后端 + admin 管理端 + frontend 用户端) > **适用范围**EchoChat 项目全端Go 后端 + admin 管理端 + frontend 用户端)
> **创建日期**2026-03-02 > **创建日期**2026-03-02
> **最后更新**2026-03-04Phase 2c 全部完成:群聊模块 + MinIO 文件存储 + 已读回执 + 代码审查修复 14 项) > **最后更新**2026-03-04Phase 2c 全部完成:群聊模块 + MinIO 文件存储 + 已读回执 + 代码审查修复 14 项 + 浏览器测试修复 21 项
--- ---
@@ -127,6 +127,8 @@ HTTP 响应错误:
**401 场景区分的关键**:通过正则 `/\/auth\/(login|register)$/` 匹配请求 URL。登录/注册请求不清 Token 不跳转。 **401 场景区分的关键**:通过正则 `/\/auth\/(login|register)$/` 匹配请求 URL。登录/注册请求不清 Token 不跳转。
**reject 对象结构(重要)**`request.js``reject(data)` 传出的是 `{ code, message, ... }` 对象(即后端响应体本身),不是包装的 `{ data: { message } }`。因此在页面 catch 中获取错误信息时,必须使用 `e?.message` 而非 `e?.data?.message`
### 3.4 错误处理检查清单 ### 3.4 错误处理检查清单
新增 API 接口时,必须确认以下各项: 新增 API 接口时,必须确认以下各项:
@@ -136,6 +138,7 @@ HTTP 响应错误:
- [ ] 后端不使用 `_` 忽略 error至少 log warning - [ ] 后端不使用 `_` 忽略 error至少 log warning
- [ ] 前端调用方的 catch 不自行覆盖 message交给拦截器统一处理 - [ ] 前端调用方的 catch 不自行覆盖 message交给拦截器统一处理
- [ ] 前端页面级 catch 只做状态清理(如 loading = false不重复弹提示 - [ ] 前端页面级 catch 只做状态清理(如 loading = false不重复弹提示
- [ ] 前端 catch 中使用 `e?.message`(非 `e?.data?.message`)获取错误信息
--- ---

View File

@@ -854,7 +854,7 @@ pages/
│ ├── conversation.vue # 聊天对话页 ✅ Phase 2b │ ├── conversation.vue # 聊天对话页 ✅ Phase 2b
│ ├── settings.vue # 聊天设置页 ✅ Phase 2b │ ├── settings.vue # 聊天设置页 ✅ Phase 2b
│ ├── search.vue # 消息搜索页 ✅ Phase 2b │ ├── search.vue # 消息搜索页 ✅ Phase 2b
│ └── group-create.vue # 创建群聊 📋 Phase 2c │ └── group-create.vue # 创建群聊(含全站用户搜索) Phase 2c
├── contact/ ├── contact/
│ ├── index.vue # 联系人列表(含搜索/在线状态) ✅ Phase 2a │ ├── index.vue # 联系人列表(含搜索/在线状态) ✅ Phase 2a
│ ├── search.vue # 搜索添加好友 + 好友推荐 ✅ Phase 2a │ ├── search.vue # 搜索添加好友 + 好友推荐 ✅ Phase 2a
@@ -989,16 +989,17 @@ services:
- 前台 4 个页面 + chat Store + API 封装 - 前台 4 个页面 + chat Store + API 封装
- 设计文档`docs/plans/2026-03-03-phase2b-design.md` - 设计文档`docs/plans/2026-03-03-phase2b-design.md`
#### Phase 2c群聊与已读回执 🔜 设计完成,待实施 #### Phase 2c群聊与已读回执 ✅ 全部完成
- 群聊管理三级角色建群/加入/退出/解散/角色管理/禁言/全体禁言/群公告/入群审批 - 群聊管理三级角色建群/加入/退出/解散/角色管理/禁言/全体禁言/群公告/入群审批
- 群消息收发复用 im.message.* + @某人/@所有人 + 管理员撤回无时限 + 系统消息 - 群消息收发复用 im.message.* + @某人/@所有人 + 管理员撤回无时限 + 系统消息
- 已读回执单聊会话级 last_read_msg_id + 群聊消息级 im_message_reads + 实时推送 - 已读回执单聊会话级 last_read_msg_id + 群聊消息级 im_message_reads + 实时推送
- MinIO 文件存储服务Docker + 通用上传 API - MinIO 文件存储服务Docker + 通用上传 API
- 管理端群聊管理群列表/详情/解散/移除成员 - 管理端群聊管理群列表/详情/解散/移除成员
- 前端 9 个新页面 + 群聊 Store + 会话列表 Tab 改造 - 前端 9 个新页面 + 群聊 Store + 会话列表 Tab 改造
- 创建群聊/邀请入群支持全站搜索非好友用户
- 新增 3 张数据库表im_groups / im_group_join_requests / im_message_reads - 新增 3 张数据库表im_groups / im_group_join_requests / im_message_reads
- 设计文档`docs/plans/2026-03-04-phase2c-design.md` - 设计文档`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消息类型扩展 📋 待规划 #### Phase 2d消息类型扩展 📋 待规划
- 消息类型扩展图片/语音/文件消息 - 消息类型扩展图片/语音/文件消息

View File

@@ -1,6 +1,6 @@
# Phase 2c 设计文档:群聊与已读回执 # Phase 2c 设计文档:群聊与已读回执
> **状态:** ✅ 已完成实施(含代码审查修复 14 项) > **状态:** ✅ 已完成(含代码审查修复 14 项 + 浏览器测试修复 21 项
> **分支:** `feature/phase2c-group-read-receipt` > **分支:** `feature/phase2c-group-read-receipt`
> **前置依赖:** Phase 2b 全部完成(单聊即时通讯) > **前置依赖:** Phase 2b 全部完成(单聊即时通讯)
> **最后更新:** 2026-03-04 > **最后更新:** 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_owner ON im_groups(owner_id);
CREATE INDEX idx_im_groups_name ON im_groups USING gin(to_tsvector('simple', name)); CREATE INDEX idx_im_groups_name ON im_groups USING gin(to_tsvector('simple', name));
-- 注意:实际搜索使用 ILIKE 模糊匹配to_tsvector 不支持混合中英文词汇的分词)
``` ```
#### im_group_join_requests入群申请表 #### im_group_join_requests入群申请表
@@ -458,12 +459,12 @@ im_conversations (type=2 群聊)
|------|------|----------| |------|------|----------|
| 群聊会话列表 | pages/group/index.vue | 群聊 Tab 页,显示群会话列表 | | 群聊会话列表 | pages/group/index.vue | 群聊 Tab 页,显示群会话列表 |
| 群聊对话页 | pages/group/conversation.vue | 群消息展示 + 发送 + @选择器 | | 群聊对话页 | pages/group/conversation.vue | 群消息展示 + 发送 + @选择器 |
| 创建群聊 | pages/group/create.vue | 选好友 + 搜索用户 + 填群名 | | 创建群聊 | pages/group/create.vue | 选好友 + 全站搜索非好友用户 + 填群名(最少 1 人即可创建) |
| 群设置 | pages/group/settings.vue | 群信息/公告/成员概览/免打扰/退群/解散 | | 群设置 | pages/group/settings.vue | 群信息/公告/成员概览/免打扰/退群/解散 |
| 群成员列表 | pages/group/members.vue | 完整成员列表 + 角色标识 + 管理操作 | | 群成员列表 | pages/group/members.vue | 完整成员列表 + 角色标识(群主/管理员/成员)+ 自定义操作弹窗(含头像角色信息)+ 管理操作 |
| 邀请入群 | pages/group/invite.vue | 搜索用户 ID 拉人入群 | | 邀请入群 | pages/group/invite.vue | 好友多选 + 全站搜索非好友用户 + 排除已在群内成员 |
| 入群审批 | pages/group/join-requests.vue | 群主/管理员审批入群申请 | | 入群审批 | pages/group/join-requests.vue | 群主/管理员审批入群申请 |
| 搜索群聊 | pages/group/search.vue | 搜索公开群 + 申请加入 | | 搜索群聊 | pages/group/search.vue | 搜索公开群 + 申请加入 + 已加入状态智能显示 |
| 已读详情 | pages/chat/read-detail.vue | 已读/未读人员列表(单聊+群聊共用) | | 已读详情 | pages/chat/read-detail.vue | 已读/未读人员列表(单聊+群聊共用) |
### 7.2 修改现有页面 ### 7.2 修改现有页面

View File

@@ -1,9 +1,9 @@
# Phase 2c 实施计划:群聊与已读回执 # Phase 2c 实施计划:群聊与已读回执
> **状态:** ✅ 已完成(含代码审查修复 14 项) > **状态:** ✅ 已完成(含代码审查修复 14 项 + 浏览器测试修复 21 项
> **设计文档:** `docs/plans/2026-03-04-phase2c-design.md` > **设计文档:** `docs/plans/2026-03-04-phase2c-design.md`
> **分支:** `feature/phase2c-group-read-receipt` > **分支:** `feature/phase2c-group-read-receipt`
> **预计 Task 数:** 14 个(全部完成) > **预计 Task 数:** 14 个(全部完成)+ Playwright 端到端测试
> **最后更新:** 2026-03-04 > **最后更新:** 2026-03-04
--- ---
@@ -625,3 +625,33 @@ Task 0 + Task 12 ── Task 13 (管理端+文档+审查)
5. **权限层级**:群主(2) > 管理员(1) > 成员(0),操作时必须校验层级 5. **权限层级**:群主(2) > 管理员(1) > 成员(0),操作时必须校验层级
6. **Wire 依赖**Phase 2b 中 Wire 有过手动 patch 历史,注意检查 wire_gen.go 一致性 6. **Wire 依赖**Phase 2b 中 Wire 有过手动 patch 历史,注意检查 wire_gen.go 一致性
7. **依赖管理**:当前 Go 版本 1.23.12,添加新依赖必须选择兼容版本 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` |

View File

@@ -1,7 +1,7 @@
# EchoChat 项目开发进度 # EchoChat 项目开发进度
> **最后更新**2026-03-04Phase 2c 代码审查修复完成) > **最后更新**2026-03-04Phase 2c 浏览器测试 + UI/UX 优化 + Tab 切换刷新修复完成)
> **当前阶段**Phase 2c 代码审查修复完成,待最终验证 > **当前阶段**Phase 2c 全部完成(含 Playwright 浏览器测试 + 21 项用户反馈修复)
> **当前分支**`feature/phase2c-group-read-receipt` > **当前分支**`feature/phase2c-group-read-receipt`
> **实施计划**`docs/plans/2026-03-04-phase2c-implementation.plan.md` > **实施计划**`docs/plans/2026-03-04-phase2c-implementation.plan.md`
> **设计文档**`docs/plans/2026-03-04-phase2c-design.md` > **设计文档**`docs/plans/2026-03-04-phase2c-design.md`
@@ -212,20 +212,42 @@ EchoChat/
## 七、开发测试指南 ## 七、开发测试指南
### 启动命令 ### 服务管理命令
> 建议开 4 个终端窗口,分别运行各服务。终止方式统一为 `Ctrl + C` 或 `kill` 命令。
#### 基础设施Docker Compose
```bash ```bash
# 1. 启动 PostgreSQL + Redis # 一键启动全部基础设施(PostgreSQL + Redis + MinIO
cd deploy && docker compose -f docker-compose.dev.yml up -d postgres redis 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. 启动前台 H5http://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 — 群聊与已读回执 ## 八、Phase 2c — 群聊与已读回执
> **状态:** 实施中 > **状态:** ✅ 已完成
> **设计文档:** `docs/plans/2026-03-04-phase2c-design.md` > **设计文档:** `docs/plans/2026-03-04-phase2c-design.md`
> **实施计划:** `docs/plans/2026-03-04-phase2c-implementation.plan.md` > **实施计划:** `docs/plans/2026-03-04-phase2c-implementation.plan.md`
> **分支:** `feature/phase2c-group-read-receipt` > **分支:** `feature/phase2c-group-read-receipt`
@@ -284,6 +306,33 @@ cd frontend && npm run dev:h5
| Task 12 | 群聊辅助功能(入群审批 + 搜索群聊) | ✅ 完成 | | Task 12 | 群聊辅助功能(入群审批 + 搜索群聊) | ✅ 完成 |
| Task 13 | 管理端群组管理 + 文档更新 | ✅ 完成 | | Task 13 | 管理端群组管理 + 文档更新 | ✅ 完成 |
| 代码审查修复 | 14 项修复Critical×5 + Important×4 + Minor×2 + Suggestion×3 | ✅ 完成 | | 代码审查修复 | 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 ### 代码审查修复详情Phase 2c
@@ -317,13 +366,13 @@ cd frontend && npm run dev:h5
| 页面 | 路径 | 功能 | | 页面 | 路径 | 功能 |
|------|------|------| |------|------|------|
| 群聊对话页 | `pages/group/conversation.vue` | 群消息收发 + @选择器 + 已读计数 | | 群聊对话页 | `pages/group/conversation.vue` | 群消息收发 + @选择器 + 已读计数 |
| 创建群聊页 | `pages/group/create.vue` | 好友多选 + 群名称输入 | | 创建群聊页 | `pages/group/create.vue` | 好友/非好友多选 + 全站用户搜索 + 群名称输入 |
| 群设置页 | `pages/group/settings.vue` | 群信息修改 + 成员概览 + 退出/解散 | | 群设置页 | `pages/group/settings.vue` | 群信息修改 + 成员概览 + 退出/解散 |
| 群成员页 | `pages/group/members.vue` | 成员列表 + 角色管理 + 禁言操作 | | 群成员页 | `pages/group/members.vue` | 成员列表 + 全角色标识(群主/管理员/成员)+ 自定义操作弹窗 + 角色管理 + 禁言操作 |
| 邀请入群页 | `pages/group/invite.vue` | 好友多选 + 排除已在群内成员 | | 邀请入群页 | `pages/group/invite.vue` | 好友/非好友多选 + 全站用户搜索 + 排除已在群内成员 |
| 入群审批页 | `pages/group/join-requests.vue` | 申请列表 + 通过/拒绝操作 | | 入群审批页 | `pages/group/join-requests.vue` | 申请列表 + 通过/拒绝操作 |
| 搜索群聊页 | `pages/group/search.vue` | 关键词搜索 + 申请加入 | | 搜索群聊页 | `pages/group/search.vue` | 关键词搜索 + 申请加入 + 已加入状态显示 |
| 已读详情页 | `pages/chat/read-detail.vue` | 已读/未读成员列表(群聊消息级) | | 已读详情页 | `pages/chat/read-detail.vue` | 已读/未读成员列表(群聊消息级)+ 群昵称优先展示 + 真实昵称副行 |
| 会话列表改造 | `pages/chat/index.vue` | Tab 切换(全部/单聊/群聊)+ @标记 + 免打扰标识 | | 会话列表改造 | `pages/chat/index.vue` | Tab 切换(全部/单聊/群聊)+ @标记 + 免打扰标识 |
#### 管理端新增 #### 管理端新增

View File

@@ -40,6 +40,7 @@ export default {
chatStore.initWsListeners() chatStore.initWsListeners()
contactStore.initWsListeners() contactStore.initWsListeners()
groupStore.initWsListeners() groupStore.initWsListeners()
contactStore.fetchPendingRequests().catch(() => {})
} }
} }
} }

View File

@@ -8,7 +8,7 @@
功能 功能
- 底部导航栏4 Tab消息 / 联系人 / 会议 / 我的 - 底部导航栏4 Tab消息 / 联系人 / 会议 / 我的
- 选中态使用 filled 图标 + Primary 未选中态使用轮廓图标 + Muted - 选中态使用 filled 图标 + Primary 未选中态使用轮廓图标 + Muted
- 消息 Tab 支持未读消息 badge - 消息 Tab 支持未读消息 badge联系人 Tab 支持好友申请未读 badge
- 使用 switchTab 跳转 - 使用 switchTab 跳转
--> -->
<template> <template>
@@ -43,9 +43,11 @@
* 功能: * 功能:
* - 跳转使用 uni.switchTab确保与 pages.json tabBar 配置的页面一致 * - 跳转使用 uni.switchTab确保与 pages.json tabBar 配置的页面一致
* - 消息 Tabindex=0显示全局未读消息 badge * - 消息 Tabindex=0显示全局未读消息 badge
* - 联系人 Tabindex=1显示待处理好友申请 badge
* - 选中态使用 filled 图标变体以增强视觉区分 * - 选中态使用 filled 图标变体以增强视觉区分
*/ */
import { useChatStore } from '@/store/chat' import { useChatStore } from '@/store/chat'
import { useContactStore } from '@/store/contact'
export default { export default {
name: 'CustomTabBar', name: 'CustomTabBar',
@@ -91,6 +93,10 @@ export default {
const chatStore = useChatStore() const chatStore = useChatStore()
return chatStore.totalUnread return chatStore.totalUnread
} }
if (index === 1) {
const contactStore = useContactStore()
return contactStore.pendingCount
}
return 0 return 0
} }
} }

View File

@@ -136,7 +136,7 @@ export default {
const loadingMore = ref(false) const loadingMore = ref(false)
const messages = computed(() => chatStore.currentMessages) const messages = computed(() => chatStore.currentMessages)
const hasMore = computed(() => chatStore.hasMoreMap[conversationId.value] !== false) const hasMore = computed(() => conversationId.value > 0 && chatStore.hasMoreMap[conversationId.value] !== false)
const isTyping = computed(() => chatStore.typingMap[conversationId.value] || false) const isTyping = computed(() => chatStore.typingMap[conversationId.value] || false)
const selfAvatar = computed(() => userStore.userInfo?.avatar || '') const selfAvatar = computed(() => userStore.userInfo?.avatar || '')
const selfName = computed(() => userStore.userInfo?.nickname || userStore.userInfo?.username || '我') const selfName = computed(() => userStore.userInfo?.nickname || userStore.userInfo?.username || '我')

View File

@@ -40,11 +40,12 @@
<view class="user-avatar-wrap"> <view class="user-avatar-wrap">
<image v-if="user.user_avatar" class="user-avatar" :src="user.user_avatar" mode="aspectFill" /> <image v-if="user.user_avatar" class="user-avatar" :src="user.user_avatar" mode="aspectFill" />
<view v-else class="user-avatar user-avatar-placeholder"> <view v-else class="user-avatar user-avatar-placeholder">
<text class="avatar-char">{{ (user.user_nickname || '?')[0] }}</text> <text class="avatar-char">{{ (user.group_nickname || user.user_nickname || '?')[0] }}</text>
</view> </view>
</view> </view>
<view class="user-info"> <view class="user-info">
<text class="user-name">{{ user.user_nickname || '未知用户' }}</text> <text class="user-name">{{ user.group_nickname || user.user_nickname || '未知用户' }}</text>
<text v-if="user.group_nickname" class="user-real-name">{{ user.user_nickname }}</text>
<text v-if="user.read_at && activeTab === 'read'" class="user-time">{{ user.read_at }}</text> <text v-if="user.read_at && activeTab === 'read'" class="user-time">{{ user.read_at }}</text>
</view> </view>
</view> </view>
@@ -87,7 +88,7 @@ export default {
} }
} catch (e) { } catch (e) {
console.warn('[ReadDetail] 获取已读详情失败', e) console.warn('[ReadDetail] 获取已读详情失败', e)
uni.showToast({ title: e?.data?.message || '获取已读详情失败', icon: 'none' }) uni.showToast({ title: e?.message || '获取已读详情失败', icon: 'none' })
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -163,9 +164,10 @@ export default {
background-color: #2563EB; background-color: #2563EB;
} }
.avatar-char { color: #FFFFFF; font-size: 28rpx; font-weight: 600; } .avatar-char { color: #FFFFFF; font-size: 28rpx; font-weight: 600; }
.user-info { flex: 1; } .user-info { flex: 1; display: flex; flex-direction: column; gap: 4rpx; }
.user-name { font-size: 28rpx; color: #1E293B; } .user-name { font-size: 28rpx; color: #1E293B; font-weight: 500; }
.user-time { display: block; font-size: 22rpx; color: #94A3B8; margin-top: 4rpx; } .user-real-name { font-size: 22rpx; color: #94A3B8; }
.user-time { font-size: 22rpx; color: #94A3B8; }
/* 空状态 */ /* 空状态 */
.empty-state { padding: 80rpx 0; text-align: center; } .empty-state { padding: 80rpx 0; text-align: center; }

View File

@@ -113,6 +113,7 @@
<script setup> <script setup>
import { ref, onMounted, computed } from 'vue' import { ref, onMounted, computed } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { useContactStore } from '@/store/contact' import { useContactStore } from '@/store/contact'
import { useWebSocketStore } from '@/store/websocket' import { useWebSocketStore } from '@/store/websocket'
import { useUserStore } from '@/store/user' import { useUserStore } from '@/store/user'
@@ -134,10 +135,8 @@ const filteredFriends = computed(() => {
) )
}) })
onMounted(async () => { const refreshData = async () => {
if (userStore.isLoggedIn) { if (!userStore.isLoggedIn) return
wsStore.connect()
contactStore.initWsListeners()
try { try {
await Promise.all([ await Promise.all([
contactStore.fetchFriends(), contactStore.fetchFriends(),
@@ -146,10 +145,23 @@ onMounted(async () => {
} catch (e) { } catch (e) {
console.error('获取联系人数据失败', e) console.error('获取联系人数据失败', e)
} }
}
onMounted(async () => {
if (userStore.isLoggedIn) {
wsStore.connect()
contactStore.initWsListeners()
await refreshData()
} }
loading.value = false loading.value = false
}) })
onShow(() => {
if (!loading.value) {
refreshData()
}
})
const goToSearch = () => uni.navigateTo({ url: '/pages/contact/search' }) const goToSearch = () => uni.navigateTo({ url: '/pages/contact/search' })
const goToRequests = () => uni.navigateTo({ url: '/pages/contact/request' }) const goToRequests = () => uni.navigateTo({ url: '/pages/contact/request' })
const goToDetail = (friend) => uni.navigateTo({ url: `/pages/contact/detail?userId=${friend.user_id}` }) const goToDetail = (friend) => uni.navigateTo({ url: `/pages/contact/detail?userId=${friend.user_id}` })

View File

@@ -97,7 +97,7 @@ const handleAccept = async (requestId) => {
uni.showToast({ title: '已接受', icon: 'success' }) uni.showToast({ title: '已接受', icon: 'success' })
} catch (e) { } catch (e) {
console.error('接受好友申请失败', e) console.error('接受好友申请失败', e)
uni.showToast({ title: e?.data?.message || '接受申请失败', icon: 'none' }) uni.showToast({ title: e?.message || '接受申请失败', icon: 'none' })
} finally { } finally {
processingMap[requestId] = false processingMap[requestId] = false
} }
@@ -111,7 +111,7 @@ const handleReject = async (requestId) => {
uni.showToast({ title: '已拒绝', icon: 'none' }) uni.showToast({ title: '已拒绝', icon: 'none' })
} catch (e) { } catch (e) {
console.error('拒绝好友申请失败', e) console.error('拒绝好友申请失败', e)
uni.showToast({ title: e?.data?.message || '拒绝申请失败', icon: 'none' }) uni.showToast({ title: e?.message || '拒绝申请失败', icon: 'none' })
} finally { } finally {
processingMap[requestId] = false processingMap[requestId] = false
} }

View File

@@ -157,7 +157,7 @@ const showAddDialog = (user) => {
console.error('发送好友申请失败', e) console.error('发送好友申请失败', e)
addingMap[user.id] = false addingMap[user.id] = false
uni.showToast({ uni.showToast({
title: e?.data?.message || '发送申请失败', title: e?.message || '发送申请失败',
icon: 'none' icon: 'none'
}) })
} }

View File

@@ -147,9 +147,9 @@
mode="aspectFill" mode="aspectFill"
/> />
<view v-else class="avatar-img avatar-placeholder avatar-peer at-avatar"> <view v-else class="avatar-img avatar-placeholder avatar-peer at-avatar">
<text class="avatar-char">{{ (member.nickname || member.username || '?')[0] }}</text> <text class="avatar-char">{{ (member.nickname || member.user_nickname || '?')[0] }}</text>
</view> </view>
<text class="at-member-name">{{ member.nickname || member.username }}</text> <text class="at-member-name">{{ member.nickname || member.user_nickname }}</text>
<text v-if="member.role === GROUP_ROLE.OWNER" class="at-role-tag">群主</text> <text v-if="member.role === GROUP_ROLE.OWNER" class="at-role-tag">群主</text>
<text v-else-if="member.role === GROUP_ROLE.ADMIN" class="at-role-tag">管理</text> <text v-else-if="member.role === GROUP_ROLE.ADMIN" class="at-role-tag">管理</text>
</view> </view>
@@ -253,7 +253,7 @@ export default {
const getMemberName = (userId) => { const getMemberName = (userId) => {
const member = memberMap.value[userId] const member = memberMap.value[userId]
if (member) return member.nickname || member.username || '未知' if (member) return member.nickname || member.user_nickname || '未知'
return '未知成员' return '未知成员'
} }
@@ -262,16 +262,17 @@ export default {
return member ? member.avatar : '' return member ? member.avatar : ''
} }
/** 群聊已读标签:自己的消息显示 "X人已读" */ /** 群聊已读标签:自己发送的消息始终显示 "X人已读"(含 0 人) */
const getReadLabel = (msg) => { const getReadLabel = (msg) => {
if (msg.status === 2 || msg._sending || msg._failed || !msg.id) return '' if (msg.status === 2 || msg._sending || msg._failed || !msg.id) return ''
const count = chatStore.groupReadCountMap[msg.id] if (msg.sender_id !== myId.value) return ''
return count > 0 ? `${count}人已读` : '' const count = chatStore.groupReadCountMap[msg.id] || 0
return `${count}人已读`
} }
// ==================== 生命周期 ==================== // ==================== 生命周期 ====================
onLoad((query) => { onLoad(async (query) => {
conversationId.value = parseInt(query.conversationId) || 0 conversationId.value = parseInt(query.conversationId) || 0
groupId.value = parseInt(query.groupId) || 0 groupId.value = parseInt(query.groupId) || 0
groupName.value = decodeURIComponent(query.peerName || '') groupName.value = decodeURIComponent(query.peerName || '')
@@ -285,6 +286,13 @@ export default {
loadInitialMessages() loadInitialMessages()
} }
if (!groupId.value && conversationId.value) {
const conv = chatStore.conversationList.find(c => c.id === conversationId.value)
if (conv && conv.group_id) {
groupId.value = conv.group_id
}
}
if (groupId.value) { if (groupId.value) {
groupStore.fetchMembers(groupId.value) groupStore.fetchMembers(groupId.value)
groupStore.fetchGroupDetail(groupId.value) groupStore.fetchGroupDetail(groupId.value)
@@ -359,7 +367,7 @@ export default {
inputText.value = inputText.value + '所有人 ' inputText.value = inputText.value + '所有人 '
} else { } else {
atUserIds.value.push(member.user_id) atUserIds.value.push(member.user_id)
const name = member.nickname || member.username || '' const name = member.nickname || member.user_nickname || ''
inputText.value = inputText.value + name + ' ' inputText.value = inputText.value + name + ' '
} }
} }

View File

@@ -52,14 +52,14 @@
<!-- 搜索好友 --> <!-- 搜索好友 -->
<view class="section"> <view class="section">
<view class="section-header"> <view class="section-header">
<text class="section-title">选择好友</text> <text class="section-title">选择成员</text>
</view> </view>
<view class="search-bar"> <view class="search-bar">
<uni-icons type="search" size="18" color="#94A3B8" /> <uni-icons type="search" size="18" color="#94A3B8" />
<input <input
class="search-input" class="search-input"
v-model="searchKeyword" v-model="searchKeyword"
placeholder="搜索好友昵称" placeholder="搜索好友或用户名"
placeholder-style="color: #94A3B8" placeholder-style="color: #94A3B8"
/> />
<view v-if="searchKeyword" class="search-clear" @tap="searchKeyword = ''"> <view v-if="searchKeyword" class="search-clear" @tap="searchKeyword = ''">
@@ -99,10 +99,42 @@
</view> </view>
</view> </view>
<view v-else class="empty-state"> <view v-else-if="!showSearchSection" class="empty-state">
<text class="empty-text">{{ searchKeyword ? '未找到匹配的好友' : '暂无好友' }}</text> <text class="empty-text">{{ searchKeyword ? '未找到匹配的好友' : '暂无好友' }}</text>
</view> </view>
<!-- 非好友搜索结果 -->
<view v-if="showSearchSection" class="section">
<view class="section-header">
<text class="section-title">搜索到的其他用户</text>
</view>
<view v-if="searching" class="loading-tip">
<text class="loading-tip-text">搜索中...</text>
</view>
<view v-else-if="nonFriendResults.length > 0" class="friend-list">
<view
v-for="user in nonFriendResults"
:key="user.id"
class="friend-item"
@tap="toggleSelect({ user_id: user.id, nickname: user.nickname, username: user.username, avatar: user.avatar })"
>
<view class="checkbox" :class="{ 'checkbox--checked': isSelected(user.id) }">
<text v-if="isSelected(user.id)" class="checkbox-icon"></text>
</view>
<view class="avatar" :style="{ backgroundColor: getAvatarColor(user.nickname || user.username) }">
<text class="avatar-text">{{ getInitial(user.nickname || user.username) }}</text>
</view>
<view class="friend-info">
<text class="friend-name">{{ user.nickname || user.username }}</text>
<text class="friend-account non-friend-tag">非好友</text>
</view>
</view>
</view>
<view v-else class="empty-state">
<text class="empty-text">未找到其他匹配用户</text>
</view>
</view>
<!-- 底部占位 --> <!-- 底部占位 -->
<view class="bottom-spacer"></view> <view class="bottom-spacer"></view>
@@ -122,10 +154,11 @@
</template> </template>
<script> <script>
import { ref, computed } from 'vue' import { ref, computed, watch } from 'vue'
import { onLoad } from '@dcloudio/uni-app' import { onLoad } from '@dcloudio/uni-app'
import { useContactStore } from '@/store/contact' import { useContactStore } from '@/store/contact'
import { useGroupStore } from '@/store/group' import { useGroupStore } from '@/store/group'
import userApi from '@/api/user'
import { getAvatarColor, getInitial } from '@/utils/avatar' import { getAvatarColor, getInitial } from '@/utils/avatar'
export default { export default {
@@ -136,14 +169,18 @@ export default {
const groupName = ref('') const groupName = ref('')
const searchKeyword = ref('') const searchKeyword = ref('')
const selectedIds = ref({}) const selectedMap = ref({})
const loading = ref(true) const loading = ref(true)
const submitting = ref(false) const submitting = ref(false)
const searchedUsers = ref([])
const searching = ref(false)
let searchTimer = null
/** 已选中的好友列表 */ /** 已选中的用户列表(好友 + 搜索出的非好友) */
const selectedList = computed(() => { const selectedList = computed(() => Object.values(selectedMap.value))
return contactStore.friendList.filter(f => selectedIds.value[f.user_id])
}) /** 好友 ID 集合 */
const friendIdSet = computed(() => new Set(contactStore.friendList.map(f => f.user_id)))
/** 搜索过滤后的好友列表 */ /** 搜索过滤后的好友列表 */
const filteredFriends = computed(() => { const filteredFriends = computed(() => {
@@ -157,26 +194,63 @@ export default {
}) })
}) })
/** 是否满足创建条件:群名非空 + 至少选2人 */ /** 搜索到的非好友用户(已在好友列表中的去重) */
const canCreate = computed(() => { const nonFriendResults = computed(() => {
return groupName.value.trim().length > 0 && selectedList.value.length >= 2 return searchedUsers.value.filter(u => !friendIdSet.value.has(u.id))
}) })
/** 判断好友是否已选中 */ /** 是否显示全站搜索区块 */
const showSearchSection = computed(() => {
return searchKeyword.value.trim().length > 0 && (nonFriendResults.value.length > 0 || searching.value)
})
/** 是否满足创建条件 */
const canCreate = computed(() => {
return groupName.value.trim().length > 0 && selectedList.value.length >= 1
})
/** 判断用户是否已选中 */
const isSelected = (userId) => { const isSelected = (userId) => {
return !!selectedIds.value[userId] return !!selectedMap.value[userId]
} }
/** 切换好友选中状态 */ /** 切换好友选中状态 */
const toggleSelect = (friend) => { const toggleSelect = (friend) => {
const newMap = { ...selectedIds.value } const newMap = { ...selectedMap.value }
if (newMap[friend.user_id]) { const uid = friend.user_id || friend.id
delete newMap[friend.user_id] if (newMap[uid]) {
delete newMap[uid]
} else { } else {
newMap[friend.user_id] = true newMap[uid] = {
user_id: uid,
nickname: friend.nickname || friend.username || '',
username: friend.username || '',
remark: friend.remark || '',
avatar: friend.avatar || ''
} }
selectedIds.value = newMap
} }
selectedMap.value = newMap
}
/** 搜索关键词变化时延迟搜索全站用户 */
watch(searchKeyword, (val) => {
if (searchTimer) clearTimeout(searchTimer)
const kw = val.trim()
if (kw.length < 2) {
searchedUsers.value = []
return
}
searchTimer = setTimeout(async () => {
searching.value = true
try {
const res = await userApi.searchUsers(kw, 1, 20)
searchedUsers.value = res.data?.list || []
} catch {
searchedUsers.value = []
}
searching.value = false
}, 400)
})
/** 提交创建群聊 */ /** 提交创建群聊 */
const handleCreate = async () => { const handleCreate = async () => {
@@ -184,7 +258,7 @@ export default {
submitting.value = true submitting.value = true
try { try {
const memberIds = selectedList.value.map(f => f.user_id) const memberIds = selectedList.value.map(f => f.user_id || f.id)
const result = await groupStore.createGroup(groupName.value.trim(), memberIds) const result = await groupStore.createGroup(groupName.value.trim(), memberIds)
uni.showToast({ title: '群聊创建成功', icon: 'success' }) uni.showToast({ title: '群聊创建成功', icon: 'success' })
@@ -192,7 +266,7 @@ export default {
setTimeout(() => { setTimeout(() => {
if (result && result.conversation_id) { if (result && result.conversation_id) {
uni.redirectTo({ uni.redirectTo({
url: `/pages/group/conversation?conversationId=${result.conversation_id}&groupId=${result.group_id || 0}&peerName=${encodeURIComponent(groupName.value.trim())}` url: `/pages/group/conversation?conversationId=${result.conversation_id}&groupId=${result.id || 0}&peerName=${encodeURIComponent(groupName.value.trim())}`
}) })
} else { } else {
uni.navigateBack() uni.navigateBack()
@@ -201,7 +275,7 @@ export default {
} catch (e) { } catch (e) {
console.error('创建群聊失败', e) console.error('创建群聊失败', e)
uni.showToast({ uni.showToast({
title: e?.data?.message || '创建群聊失败', title: e?.message || '创建群聊失败',
icon: 'none' icon: 'none'
}) })
} finally { } finally {
@@ -223,6 +297,9 @@ export default {
searchKeyword, searchKeyword,
selectedList, selectedList,
filteredFriends, filteredFriends,
nonFriendResults,
showSearchSection,
searching,
canCreate, canCreate,
loading, loading,
submitting, submitting,
@@ -518,6 +595,25 @@ export default {
white-space: nowrap; white-space: nowrap;
} }
.non-friend-tag {
color: #D97706;
font-size: 22rpx;
background-color: #FFFBEB;
padding: 2rpx 12rpx;
border-radius: 8rpx;
}
.loading-tip {
padding: 40rpx 0;
display: flex;
justify-content: center;
}
.loading-tip-text {
font-size: 26rpx;
color: #94A3B8;
}
/* 空状态 */ /* 空状态 */
.empty-state { .empty-state {
padding: 80rpx 0; padding: 80rpx 0;

View File

@@ -32,17 +32,17 @@
</scroll-view> </scroll-view>
</view> </view>
<!-- 搜索好友 --> <!-- 搜索成员 -->
<view class="section"> <view class="section">
<view class="section-header"> <view class="section-header">
<text class="section-title">选择好友</text> <text class="section-title">选择成员</text>
</view> </view>
<view class="search-bar"> <view class="search-bar">
<uni-icons type="search" size="18" color="#94A3B8" /> <uni-icons type="search" size="18" color="#94A3B8" />
<input <input
class="search-input" class="search-input"
v-model="searchKeyword" v-model="searchKeyword"
placeholder="搜索好友昵称" placeholder="搜索好友或用户名"
placeholder-style="color: #94A3B8" placeholder-style="color: #94A3B8"
/> />
<view v-if="searchKeyword" class="search-clear" @tap="searchKeyword = ''"> <view v-if="searchKeyword" class="search-clear" @tap="searchKeyword = ''">
@@ -86,6 +86,39 @@
<text class="empty-text">{{ searchKeyword ? '未找到匹配的好友' : '没有可邀请的好友' }}</text> <text class="empty-text">{{ searchKeyword ? '未找到匹配的好友' : '没有可邀请的好友' }}</text>
</view> </view>
<!-- 搜索到的非好友用户 -->
<view v-if="showSearchSection" class="section" style="margin-top: 16rpx;">
<view class="section-header">
<text class="section-title">搜索到的其他用户</text>
</view>
<view v-if="searching" class="loading-tip">
<text class="loading-tip-text">搜索中...</text>
</view>
<view v-else-if="nonFriendResults.length > 0" class="friend-list">
<view
v-for="user in nonFriendResults"
:key="user.id"
class="friend-item"
@tap="toggleSelect(user)"
>
<view class="checkbox" :class="{ 'checkbox--checked': isSelected(user.id) }">
<text v-if="isSelected(user.id)" class="checkbox-icon"></text>
</view>
<view class="avatar" :style="{ backgroundColor: getAvatarColor(user.nickname || user.username) }">
<text class="avatar-text">{{ getInitial(user.nickname || user.username) }}</text>
</view>
<view class="friend-info">
<text class="friend-name">{{ user.nickname || user.username }}</text>
<text class="friend-account">@{{ user.username }}</text>
</view>
<text class="non-friend-tag">非好友</text>
</view>
</view>
<view v-else class="empty-state">
<text class="empty-text">未找到其他匹配用户</text>
</view>
</view>
<!-- 底部占位 --> <!-- 底部占位 -->
<view class="bottom-spacer"></view> <view class="bottom-spacer"></view>
@@ -105,10 +138,11 @@
</template> </template>
<script> <script>
import { ref, computed } from 'vue' import { ref, computed, watch } from 'vue'
import { onLoad } from '@dcloudio/uni-app' import { onLoad } from '@dcloudio/uni-app'
import { useContactStore } from '@/store/contact' import { useContactStore } from '@/store/contact'
import { useGroupStore } from '@/store/group' import { useGroupStore } from '@/store/group'
import userApi from '@/api/user'
import { getAvatarColor, getInitial } from '@/utils/avatar' import { getAvatarColor, getInitial } from '@/utils/avatar'
export default { export default {
@@ -119,9 +153,12 @@ export default {
const groupId = ref(0) const groupId = ref(0)
const searchKeyword = ref('') const searchKeyword = ref('')
const selectedIds = ref({}) const selectedMap = ref({})
const loading = ref(true) const loading = ref(true)
const submitting = ref(false) const submitting = ref(false)
const searchedUsers = ref([])
const searching = ref(false)
let searchTimer = null
/** 已在群内的成员 ID 集合 */ /** 已在群内的成员 ID 集合 */
const memberIdSet = computed(() => { const memberIdSet = computed(() => {
@@ -132,6 +169,9 @@ export default {
return set return set
}) })
/** 好友 ID 集合 */
const friendIdSet = computed(() => new Set(contactStore.friendList.map(f => f.user_id)))
/** 过滤掉已在群内的好友 + 搜索关键词过滤 */ /** 过滤掉已在群内的好友 + 搜索关键词过滤 */
const availableFriends = computed(() => { const availableFriends = computed(() => {
const filtered = contactStore.friendList.filter(f => !memberIdSet.value.has(f.user_id)) const filtered = contactStore.friendList.filter(f => !memberIdSet.value.has(f.user_id))
@@ -145,26 +185,61 @@ export default {
}) })
}) })
/** 已选中的好友列表 */ /** 搜索到的非好友、非群成员用户 */
const selectedList = computed(() => { const nonFriendResults = computed(() => {
return contactStore.friendList.filter(f => selectedIds.value[f.user_id]) return searchedUsers.value.filter(u => !friendIdSet.value.has(u.id) && !memberIdSet.value.has(u.id))
}) })
/** 判断好友是否已选中 */ /** 是否显示全站搜索区块 */
const showSearchSection = computed(() => {
return searchKeyword.value.trim().length > 0 && (nonFriendResults.value.length > 0 || searching.value)
})
/** 已选中的用户列表 */
const selectedList = computed(() => Object.values(selectedMap.value))
/** 判断用户是否已选中 */
const isSelected = (userId) => { const isSelected = (userId) => {
return !!selectedIds.value[userId] return !!selectedMap.value[userId]
} }
/** 切换好友选中状态 */ /** 切换用户选中状态 */
const toggleSelect = (friend) => { const toggleSelect = (friend) => {
const newMap = { ...selectedIds.value } const newMap = { ...selectedMap.value }
if (newMap[friend.user_id]) { const uid = friend.user_id || friend.id
delete newMap[friend.user_id] if (newMap[uid]) {
delete newMap[uid]
} else { } else {
newMap[friend.user_id] = true newMap[uid] = {
user_id: uid,
nickname: friend.nickname || friend.username || '',
username: friend.username || '',
remark: friend.remark || '',
avatar: friend.avatar || ''
} }
selectedIds.value = newMap
} }
selectedMap.value = newMap
}
/** 搜索关键词变化时延迟搜索全站用户 */
watch(searchKeyword, (val) => {
if (searchTimer) clearTimeout(searchTimer)
const kw = val.trim()
if (kw.length < 2) {
searchedUsers.value = []
return
}
searchTimer = setTimeout(async () => {
searching.value = true
try {
const res = await userApi.searchUsers(kw, 1, 20)
searchedUsers.value = res.data?.list || []
} catch {
searchedUsers.value = []
}
searching.value = false
}, 400)
})
/** 提交邀请 */ /** 提交邀请 */
const handleInvite = async () => { const handleInvite = async () => {
@@ -172,7 +247,7 @@ export default {
submitting.value = true submitting.value = true
try { try {
const userIds = selectedList.value.map(f => f.user_id) const userIds = selectedList.value.map(f => f.user_id || f.id)
await groupStore.inviteMembers(groupId.value, userIds) await groupStore.inviteMembers(groupId.value, userIds)
uni.showToast({ title: '邀请成功', icon: 'success' }) uni.showToast({ title: '邀请成功', icon: 'success' })
@@ -183,7 +258,7 @@ export default {
} catch (e) { } catch (e) {
console.error('邀请入群失败', e) console.error('邀请入群失败', e)
uni.showToast({ uni.showToast({
title: e?.data?.message || '邀请失败', title: e?.message || '邀请失败',
icon: 'none' icon: 'none'
}) })
} finally { } finally {
@@ -212,6 +287,9 @@ export default {
searchKeyword, searchKeyword,
selectedList, selectedList,
availableFriends, availableFriends,
nonFriendResults,
showSearchSection,
searching,
loading, loading,
submitting, submitting,
isSelected, isSelected,
@@ -483,6 +561,25 @@ export default {
white-space: nowrap; white-space: nowrap;
} }
.non-friend-tag {
color: #D97706;
font-size: 22rpx;
background-color: #FFFBEB;
padding: 2rpx 12rpx;
border-radius: 8rpx;
}
.loading-tip {
padding: 40rpx 0;
display: flex;
justify-content: center;
}
.loading-tip-text {
font-size: 26rpx;
color: #94A3B8;
}
/* 空状态 */ /* 空状态 */
.empty-state { .empty-state {
padding: 80rpx 0; padding: 80rpx 0;

View File

@@ -44,13 +44,13 @@
<view <view
v-else v-else
class="request-avatar request-avatar-placeholder" class="request-avatar request-avatar-placeholder"
:style="{ backgroundColor: getAvatarColor(req.nickname || req.username) }" :style="{ backgroundColor: getAvatarColor(req.user_nickname || req.nickname) }"
> >
<text class="avatar-char">{{ getInitial(req.nickname || req.username) }}</text> <text class="avatar-char">{{ getInitial(req.user_nickname || req.nickname) }}</text>
</view> </view>
<view class="request-info"> <view class="request-info">
<text class="request-name">{{ req.nickname || req.username }}</text> <text class="request-name">{{ req.user_nickname || req.nickname }}</text>
<text class="request-msg" v-if="req.message">{{ req.message }}</text> <text class="request-msg" v-if="req.message">{{ req.message }}</text>
<text class="request-msg" v-else>申请加入群聊</text> <text class="request-msg" v-else>申请加入群聊</text>
<text class="request-time">{{ formatTime(req.created_at) }}</text> <text class="request-time">{{ formatTime(req.created_at) }}</text>
@@ -96,13 +96,13 @@
<view <view
v-else v-else
class="request-avatar request-avatar-placeholder" class="request-avatar request-avatar-placeholder"
:style="{ backgroundColor: getAvatarColor(req.nickname || req.username) }" :style="{ backgroundColor: getAvatarColor(req.user_nickname || req.nickname) }"
> >
<text class="avatar-char">{{ getInitial(req.nickname || req.username) }}</text> <text class="avatar-char">{{ getInitial(req.user_nickname || req.nickname) }}</text>
</view> </view>
<view class="request-info"> <view class="request-info">
<text class="request-name">{{ req.nickname || req.username }}</text> <text class="request-name">{{ req.user_nickname || req.nickname }}</text>
<text class="request-msg" v-if="req.message">{{ req.message }}</text> <text class="request-msg" v-if="req.message">{{ req.message }}</text>
<text class="request-msg" v-else>申请加入群聊</text> <text class="request-msg" v-else>申请加入群聊</text>
<text class="request-time">{{ formatTime(req.created_at) }}</text> <text class="request-time">{{ formatTime(req.created_at) }}</text>
@@ -207,7 +207,7 @@ export default {
await groupStore.fetchJoinRequests(groupId.value) await groupStore.fetchJoinRequests(groupId.value)
} catch (e) { } catch (e) {
console.error('获取入群申请失败', e) console.error('获取入群申请失败', e)
uni.showToast({ title: e?.data?.message || '获取申请列表失败', icon: 'none' }) uni.showToast({ title: e?.message || '获取申请列表失败', icon: 'none' })
} }
loading.value = false loading.value = false
} }
@@ -224,7 +224,7 @@ export default {
uni.showToast({ title: '已通过', icon: 'success' }) uni.showToast({ title: '已通过', icon: 'success' })
} catch (e) { } catch (e) {
console.error('审批通过失败', e) console.error('审批通过失败', e)
uni.showToast({ title: e?.data?.message || '操作失败', icon: 'none' }) uni.showToast({ title: e?.message || '操作失败', icon: 'none' })
} finally { } finally {
processingMap[req.id] = false processingMap[req.id] = false
} }
@@ -240,7 +240,7 @@ export default {
uni.showToast({ title: '已拒绝', icon: 'none' }) uni.showToast({ title: '已拒绝', icon: 'none' })
} catch (e) { } catch (e) {
console.error('审批拒绝失败', e) console.error('审批拒绝失败', e)
uni.showToast({ title: e?.data?.message || '操作失败', icon: 'none' }) uni.showToast({ title: e?.message || '操作失败', icon: 'none' })
} finally { } finally {
processingMap[req.id] = false processingMap[req.id] = false
} }

View File

@@ -45,28 +45,30 @@
<view <view
v-else v-else
class="member-avatar member-avatar-placeholder" class="member-avatar member-avatar-placeholder"
:style="{ backgroundColor: getAvatarColor(member.nickname || member.username) }" :style="{ backgroundColor: getAvatarColor(member.nickname || member.user_nickname) }"
> >
<text class="avatar-char">{{ getInitial(member.nickname || member.username) }}</text> <text class="avatar-char">{{ getInitial(member.nickname || member.user_nickname) }}</text>
</view> </view>
<view class="member-info"> <view class="member-info">
<view class="member-name-row"> <view class="member-name-row">
<text class="member-name">{{ member.nickname || member.username }}</text> <text class="member-name">{{ member.nickname || member.user_nickname }}</text>
<text v-if="member.role === GROUP_ROLE.OWNER" class="role-tag role-tag-owner">🔑 群主</text> <text v-if="member.role === GROUP_ROLE.OWNER" class="role-tag role-tag-owner">🔑 群主</text>
<text v-else-if="member.role === GROUP_ROLE.ADMIN" class="role-tag role-tag-admin">🛡 管理员</text> <text v-else-if="member.role === GROUP_ROLE.ADMIN" class="role-tag role-tag-admin">🛡 管理员</text>
<text v-else class="role-tag role-tag-member">👤 成员</text>
</view> </view>
<view v-if="member.is_muted" class="muted-row"> <view v-if="member.is_muted" class="muted-row">
<text class="muted-tag">已禁言</text> <text class="muted-tag">已禁言</text>
</view> </view>
</view> </view>
<uni-icons <view
v-if="canManage(member)" v-if="canManage(member)"
type="more-filled" class="manage-btn"
size="18" @tap.stop="onMemberLongPress(member)"
color="#94A3B8" >
/> <uni-icons type="more-filled" size="18" color="#94A3B8" />
</view>
</view> </view>
</view> </view>
@@ -78,6 +80,38 @@
<view class="fab-btn" @tap="goToInvite"> <view class="fab-btn" @tap="goToInvite">
<uni-icons type="plusempty" size="24" color="#FFFFFF" /> <uni-icons type="plusempty" size="24" color="#FFFFFF" />
</view> </view>
<!-- 自定义操作弹窗 -->
<view v-if="actionTarget" class="action-overlay" @tap="actionTarget = null">
<view class="action-sheet" @tap.stop>
<view class="action-sheet-header">
<view class="action-sheet-avatar" :style="{ backgroundColor: getAvatarColor(actionTarget.nickname || actionTarget.user_nickname) }">
<text class="action-sheet-avatar-text">{{ getInitial(actionTarget.nickname || actionTarget.user_nickname) }}</text>
</view>
<view class="action-sheet-info">
<text class="action-sheet-name">{{ actionTarget.nickname || actionTarget.user_nickname }}</text>
<text class="action-sheet-role">{{ GROUP_ROLE_LABEL[actionTarget.role] || '成员' }}</text>
</view>
</view>
<view class="action-sheet-divider"></view>
<view class="action-sheet-actions">
<view
v-for="action in actionList"
:key="action.label"
class="action-sheet-item"
:class="{ 'action-sheet-item--danger': action.danger }"
@tap="doAction(action.key)"
>
<text class="action-sheet-item-icon">{{ action.icon }}</text>
<text class="action-sheet-item-label">{{ action.label }}</text>
</view>
</view>
<view class="action-sheet-divider"></view>
<view class="action-sheet-cancel" @tap="actionTarget = null">
<text class="action-sheet-cancel-text">取消</text>
</view>
</view>
</view>
</view> </view>
</template> </template>
@@ -87,7 +121,7 @@ import { ref, computed } from 'vue'
import { useGroupStore } from '@/store/group' import { useGroupStore } from '@/store/group'
import { useUserStore } from '@/store/user' import { useUserStore } from '@/store/user'
import { getAvatarColor, getInitial } from '@/utils/avatar' import { getAvatarColor, getInitial } from '@/utils/avatar'
import { GROUP_ROLE } from '@/constants/group' import { GROUP_ROLE, GROUP_ROLE_LABEL } from '@/constants/group'
export default { export default {
name: 'GroupMembers', name: 'GroupMembers',
@@ -113,8 +147,8 @@ export default {
if (!kw) return currentMembers.value if (!kw) return currentMembers.value
return currentMembers.value.filter(m => { return currentMembers.value.filter(m => {
const nickname = (m.nickname || '').toLowerCase() const nickname = (m.nickname || '').toLowerCase()
const username = (m.username || '').toLowerCase() const userNickname = (m.user_nickname || '').toLowerCase()
return nickname.includes(kw) || username.includes(kw) return nickname.includes(kw) || userNickname.includes(kw)
}) })
}) })
@@ -143,51 +177,54 @@ export default {
// ==================== 管理操作 ==================== // ==================== 管理操作 ====================
const onMemberLongPress = (member) => { const actionTarget = ref(null)
if (!canManage(member)) return
const actions = [] /** 构建当前操作目标的可用操作列表 */
const actionList = computed(() => {
const member = actionTarget.value
if (!member) return []
const list = []
if (myRole.value === GROUP_ROLE.OWNER) { if (myRole.value === GROUP_ROLE.OWNER) {
if (member.role === GROUP_ROLE.ADMIN) { if (member.role === GROUP_ROLE.ADMIN) {
actions.push('取消管理员') list.push({ key: 'unsetAdmin', icon: '👤', label: '取消管理员' })
} else { } else {
actions.push('设为管理员') list.push({ key: 'setAdmin', icon: '🛡', label: '设为管理员' })
} }
} }
if (member.is_muted) { if (member.is_muted) {
actions.push('解除禁言') list.push({ key: 'unmute', icon: '🔊', label: '解除禁言' })
} else { } else {
actions.push('禁言') list.push({ key: 'mute', icon: '🔇', label: '禁言' })
}
actions.push('踢出群聊')
uni.showActionSheet({
itemList: actions,
success: (res) => {
const action = actions[res.tapIndex]
handleAction(action, member)
} }
list.push({ key: 'kick', icon: '🚫', label: '踢出群聊', danger: true })
return list
}) })
const onMemberLongPress = (member) => {
if (!canManage(member)) return
actionTarget.value = member
} }
const handleAction = (action, member) => { const doAction = (key) => {
switch (action) { const member = actionTarget.value
case '设为管理员': if (!member) return
actionTarget.value = null
switch (key) {
case 'setAdmin':
confirmSetRole(member, GROUP_ROLE.ADMIN) confirmSetRole(member, GROUP_ROLE.ADMIN)
break break
case '取消管理员': case 'unsetAdmin':
confirmSetRole(member, GROUP_ROLE.MEMBER) confirmSetRole(member, GROUP_ROLE.MEMBER)
break break
case '禁言': case 'mute':
confirmMute(member, true) confirmMute(member, true)
break break
case '解除禁言': case 'unmute':
confirmMute(member, false) confirmMute(member, false)
break break
case '踢出群聊': case 'kick':
confirmKick(member) confirmKick(member)
break break
} }
@@ -195,7 +232,7 @@ export default {
const confirmSetRole = (member, role) => { const confirmSetRole = (member, role) => {
const roleName = role === GROUP_ROLE.ADMIN ? '管理员' : '普通成员' const roleName = role === GROUP_ROLE.ADMIN ? '管理员' : '普通成员'
const name = member.nickname || member.username const name = member.nickname || member.user_nickname
uni.showModal({ uni.showModal({
title: '确认操作', title: '确认操作',
content: `确定将 ${name} 设为${roleName}吗?`, content: `确定将 ${name} 设为${roleName}吗?`,
@@ -205,7 +242,7 @@ export default {
await groupStore.setMemberRole(groupId.value, member.user_id, role) await groupStore.setMemberRole(groupId.value, member.user_id, role)
uni.showToast({ title: '操作成功', icon: 'success' }) uni.showToast({ title: '操作成功', icon: 'success' })
} catch (e) { } catch (e) {
uni.showToast({ title: e?.data?.message || '操作失败', icon: 'none' }) uni.showToast({ title: e?.message || '操作失败', icon: 'none' })
} }
} }
} }
@@ -213,7 +250,7 @@ export default {
} }
const confirmMute = (member, isMuted) => { const confirmMute = (member, isMuted) => {
const name = member.nickname || member.username const name = member.nickname || member.user_nickname
const tip = isMuted ? `确定禁言 ${name} 吗?` : `确定解除 ${name} 的禁言吗?` const tip = isMuted ? `确定禁言 ${name} 吗?` : `确定解除 ${name} 的禁言吗?`
uni.showModal({ uni.showModal({
title: '确认操作', title: '确认操作',
@@ -224,7 +261,7 @@ export default {
await groupStore.muteMember(groupId.value, member.user_id, isMuted) await groupStore.muteMember(groupId.value, member.user_id, isMuted)
uni.showToast({ title: '操作成功', icon: 'success' }) uni.showToast({ title: '操作成功', icon: 'success' })
} catch (e) { } catch (e) {
uni.showToast({ title: e?.data?.message || '操作失败', icon: 'none' }) uni.showToast({ title: e?.message || '操作失败', icon: 'none' })
} }
} }
} }
@@ -232,7 +269,7 @@ export default {
} }
const confirmKick = (member) => { const confirmKick = (member) => {
const name = member.nickname || member.username const name = member.nickname || member.user_nickname
uni.showModal({ uni.showModal({
title: '踢出群聊', title: '踢出群聊',
content: `确定将 ${name} 踢出群聊吗?`, content: `确定将 ${name} 踢出群聊吗?`,
@@ -243,7 +280,7 @@ export default {
await groupStore.kickMember(groupId.value, member.user_id) await groupStore.kickMember(groupId.value, member.user_id)
uni.showToast({ title: '已踢出', icon: 'success' }) uni.showToast({ title: '已踢出', icon: 'success' })
} catch (e) { } catch (e) {
uni.showToast({ title: e?.data?.message || '操作失败', icon: 'none' }) uni.showToast({ title: e?.message || '操作失败', icon: 'none' })
} }
} }
} }
@@ -260,10 +297,14 @@ export default {
return { return {
GROUP_ROLE, GROUP_ROLE,
GROUP_ROLE_LABEL,
searchKeyword, searchKeyword,
filteredMembers, filteredMembers,
canManage, canManage,
onMemberLongPress, onMemberLongPress,
actionTarget,
actionList,
doAction,
goToInvite, goToInvite,
getAvatarColor, getAvatarColor,
getInitial getInitial
@@ -404,6 +445,11 @@ export default {
background-color: rgba(37, 99, 235, 0.08); background-color: rgba(37, 99, 235, 0.08);
} }
.role-tag-member {
color: #64748B;
background-color: rgba(100, 116, 139, 0.08);
}
/* ===== 禁言标签 ===== */ /* ===== 禁言标签 ===== */
.muted-row { .muted-row {
display: flex; display: flex;
@@ -418,6 +464,23 @@ export default {
border-radius: 6rpx; border-radius: 6rpx;
} }
/* ===== 管理按钮 ===== */
.manage-btn {
flex-shrink: 0;
width: 64rpx;
height: 64rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 12rpx;
cursor: pointer;
transition: background-color 150ms ease;
}
.manage-btn:active {
background-color: #F1F5F9;
}
/* ===== 空状态 ===== */ /* ===== 空状态 ===== */
.empty-state { .empty-state {
padding: 80rpx 0; padding: 80rpx 0;
@@ -450,4 +513,139 @@ export default {
.fab-btn:active { .fab-btn:active {
opacity: 0.85; opacity: 0.85;
} }
/* ===== 自定义操作弹窗 ===== */
.action-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 999;
animation: fadeIn 200ms ease;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.action-sheet {
width: 560rpx;
max-width: 90vw;
background-color: #FFFFFF;
border-radius: 24rpx;
overflow: hidden;
box-shadow: 0 16rpx 48rpx rgba(0, 0, 0, 0.15);
animation: slideUp 200ms ease;
}
@keyframes slideUp {
from { transform: translateY(40rpx); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.action-sheet-header {
display: flex;
align-items: center;
padding: 28rpx 32rpx;
gap: 20rpx;
}
.action-sheet-avatar {
width: 72rpx;
height: 72rpx;
border-radius: 18rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.action-sheet-avatar-text {
font-size: 28rpx;
color: #FFFFFF;
font-weight: 600;
}
.action-sheet-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 4rpx;
}
.action-sheet-name {
font-size: 30rpx;
color: #1E293B;
font-weight: 600;
}
.action-sheet-role {
font-size: 24rpx;
color: #94A3B8;
}
.action-sheet-divider {
height: 1rpx;
background-color: #F1F5F9;
margin: 0 24rpx;
}
.action-sheet-actions {
padding: 8rpx 0;
}
.action-sheet-item {
display: flex;
align-items: center;
padding: 24rpx 32rpx;
gap: 16rpx;
cursor: pointer;
transition: background-color 150ms ease;
}
.action-sheet-item:active {
background-color: #F8FAFC;
}
.action-sheet-item-icon {
font-size: 32rpx;
width: 40rpx;
text-align: center;
flex-shrink: 0;
}
.action-sheet-item-label {
font-size: 30rpx;
color: #334155;
font-weight: 500;
}
.action-sheet-item--danger .action-sheet-item-label {
color: #EF4444;
}
.action-sheet-cancel {
padding: 24rpx 32rpx;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background-color 150ms ease;
}
.action-sheet-cancel:active {
background-color: #F8FAFC;
}
.action-sheet-cancel-text {
font-size: 30rpx;
color: #94A3B8;
font-weight: 500;
}
</style> </style>

View File

@@ -72,7 +72,11 @@
<text v-if="group.description" class="group-desc">{{ group.description }}</text> <text v-if="group.description" class="group-desc">{{ group.description }}</text>
</view> </view>
<view v-if="isInGroup(group.id)" class="action-btn action-btn--joined">
<text class="action-btn-text action-btn-text--joined">已加入</text>
</view>
<view <view
v-else
class="action-btn action-btn--apply" class="action-btn action-btn--apply"
:class="{ 'action-btn--disabled': appliedMap[group.id] }" :class="{ 'action-btn--disabled': appliedMap[group.id] }"
@tap="showApplyDialog(group)" @tap="showApplyDialog(group)"
@@ -110,12 +114,28 @@
import { onLoad } from '@dcloudio/uni-app' import { onLoad } from '@dcloudio/uni-app'
import { ref, computed, reactive } from 'vue' import { ref, computed, reactive } from 'vue'
import { useGroupStore } from '@/store/group' import { useGroupStore } from '@/store/group'
import { useChatStore } from '@/store/chat'
import { getAvatarColor, getInitial } from '@/utils/avatar' import { getAvatarColor, getInitial } from '@/utils/avatar'
export default { export default {
name: 'GroupSearch', name: 'GroupSearch',
setup() { setup() {
const groupStore = useGroupStore() const groupStore = useGroupStore()
const chatStore = useChatStore()
/** 当前用户已加入的群 ID 集合 */
const myGroupIds = computed(() => {
const ids = new Set()
chatStore.conversationList.forEach(conv => {
if (conv.type === 2 && conv.group_id) {
ids.add(conv.group_id)
}
})
return ids
})
/** 判断是否已在该群内 */
const isInGroup = (groupId) => myGroupIds.value.has(groupId)
const keyword = ref('') const keyword = ref('')
const searched = ref(false) const searched = ref(false)
@@ -138,6 +158,9 @@ export default {
onLoad(() => { onLoad(() => {
groupStore.searchResults = [] groupStore.searchResults = []
groupStore.searchTotal = 0 groupStore.searchTotal = 0
if (chatStore.conversationList.length === 0) {
chatStore.fetchConversations()
}
}) })
// ==================== 搜索逻辑 ==================== // ==================== 搜索逻辑 ====================
@@ -158,7 +181,7 @@ export default {
await groupStore.searchGroups(kw, 1, pageSize) await groupStore.searchGroups(kw, 1, pageSize)
} catch (e) { } catch (e) {
console.error('搜索群聊失败', e) console.error('搜索群聊失败', e)
uni.showToast({ title: e?.data?.message || '搜索失败', icon: 'none' }) uni.showToast({ title: e?.message || '搜索失败', icon: 'none' })
} }
searchLoading.value = false searchLoading.value = false
@@ -200,7 +223,7 @@ export default {
uni.showToast({ title: '申请已发送', icon: 'success' }) uni.showToast({ title: '申请已发送', icon: 'success' })
} catch (e) { } catch (e) {
console.error('发送入群申请失败', e) console.error('发送入群申请失败', e)
uni.showToast({ title: e?.data?.message || '申请失败', icon: 'none' }) uni.showToast({ title: e?.message || '申请失败', icon: 'none' })
} }
} }
} }
@@ -216,6 +239,7 @@ export default {
resultList, resultList,
noMore, noMore,
appliedMap, appliedMap,
isInGroup,
doSearch, doSearch,
loadMore, loadMore,
showApplyDialog, showApplyDialog,
@@ -421,6 +445,18 @@ export default {
color: #94A3B8; color: #94A3B8;
} }
.action-btn--joined {
border-color: #059669;
background-color: #ECFDF5;
pointer-events: none;
}
.action-btn-text--joined {
color: #059669;
font-size: 24rpx;
font-weight: 500;
}
/* ===== 加载更多 ===== */ /* ===== 加载更多 ===== */
.load-more-wrap { .load-more-wrap {
padding: 24rpx 0 40rpx; padding: 24rpx 0 40rpx;

View File

@@ -58,11 +58,11 @@
<view <view
v-else v-else
class="member-preview-avatar member-preview-avatar-placeholder" class="member-preview-avatar member-preview-avatar-placeholder"
:style="{ backgroundColor: getAvatarColor(member.nickname || member.username) }" :style="{ backgroundColor: getAvatarColor(member.nickname || member.user_nickname) }"
> >
<text class="member-preview-char">{{ getInitial(member.nickname || member.username) }}</text> <text class="member-preview-char">{{ getInitial(member.nickname || member.user_nickname) }}</text>
</view> </view>
<text class="member-preview-name">{{ member.nickname || member.username }}</text> <text class="member-preview-name">{{ member.nickname || member.user_nickname }}</text>
</view> </view>
</view> </view>
</scroll-view> </scroll-view>
@@ -192,7 +192,7 @@ export default {
groupName.value = res.content.trim() groupName.value = res.content.trim()
uni.showToast({ title: '修改成功', icon: 'success' }) uni.showToast({ title: '修改成功', icon: 'success' })
} catch (e) { } catch (e) {
uni.showToast({ title: e?.data?.message || '修改失败', icon: 'none' }) uni.showToast({ title: e?.message || '修改失败', icon: 'none' })
} }
} }
} }
@@ -214,7 +214,7 @@ export default {
await groupStore.updateGroup(groupId.value, { notice: res.content || '' }) await groupStore.updateGroup(groupId.value, { notice: res.content || '' })
uni.showToast({ title: '修改成功', icon: 'success' }) uni.showToast({ title: '修改成功', icon: 'success' })
} catch (e) { } catch (e) {
uni.showToast({ title: e?.data?.message || '修改失败', icon: 'none' }) uni.showToast({ title: e?.message || '修改失败', icon: 'none' })
} }
} }
} }
@@ -243,7 +243,7 @@ export default {
groupStore.fetchMembers(groupId.value) groupStore.fetchMembers(groupId.value)
uni.showToast({ title: '修改成功', icon: 'success' }) uni.showToast({ title: '修改成功', icon: 'success' })
} catch (e) { } catch (e) {
uni.showToast({ title: e?.data?.message || '修改失败', icon: 'none' }) uni.showToast({ title: e?.message || '修改失败', icon: 'none' })
} }
} }
} }
@@ -279,7 +279,7 @@ export default {
uni.switchTab({ url: '/pages/chat/index' }) uni.switchTab({ url: '/pages/chat/index' })
}, 500) }, 500)
} catch (e) { } catch (e) {
uni.showToast({ title: e?.data?.message || '解散失败', icon: 'none' }) uni.showToast({ title: e?.message || '解散失败', icon: 'none' })
} }
} }
} }
@@ -302,7 +302,7 @@ export default {
uni.switchTab({ url: '/pages/chat/index' }) uni.switchTab({ url: '/pages/chat/index' })
}, 500) }, 500)
} catch (e) { } catch (e) {
uni.showToast({ title: e?.data?.message || '退出失败', icon: 'none' }) uni.showToast({ title: e?.message || '退出失败', icon: 'none' })
} }
} }
} }

View File

@@ -155,6 +155,7 @@ export const useChatStore = defineStore('chat', () => {
/** 加载历史消息 */ /** 加载历史消息 */
const loadHistoryMessages = async (conversationId) => { const loadHistoryMessages = async (conversationId) => {
if (!conversationId) return
const messages = messagesMap.value[conversationId] || [] const messages = messagesMap.value[conversationId] || []
const beforeId = messages.length > 0 ? messages[0].id : 0 const beforeId = messages.length > 0 ? messages[0].id : 0