feat:群聊问题修复+相关项目文档更新
This commit is contained in:
@@ -18,10 +18,11 @@ alwaysApply: true
|
|||||||
- **Phase 2a(WebSocket 实时通讯与联系人管理)**:✅ 全部完成(13 个 Task + 后期 Bug 修复 3 项)
|
- **Phase 2a(WebSocket 实时通讯与联系人管理)**:✅ 全部完成(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.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)
|
- **跨模块通信模式**:接口注入标准(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)
|
||||||
|
|
||||||
## 项目概述
|
## 项目概述
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"` // 已读时间
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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-04(Fix 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 | 群成员总数(不含消息发送者本人) |
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> **适用范围**:EchoChat 项目全端(Go 后端 + admin 管理端 + frontend 用户端)
|
> **适用范围**:EchoChat 项目全端(Go 后端 + admin 管理端 + frontend 用户端)
|
||||||
> **创建日期**:2026-03-02
|
> **创建日期**: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 不跳转。
|
**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`)获取错误信息
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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:消息类型扩展 📋 待规划
|
||||||
- 消息类型扩展(图片/语音/文件消息)
|
- 消息类型扩展(图片/语音/文件消息)
|
||||||
|
|||||||
@@ -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 修改现有页面
|
||||||
|
|||||||
@@ -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` |
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# EchoChat 项目开发进度
|
# EchoChat 项目开发进度
|
||||||
|
|
||||||
> **最后更新**:2026-03-04(Phase 2c 代码审查修复完成)
|
> **最后更新**:2026-03-04(Phase 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. 启动前台 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 — 群聊与已读回执
|
## 八、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 切换(全部/单聊/群聊)+ @标记 + 免打扰标识 |
|
||||||
|
|
||||||
#### 管理端新增
|
#### 管理端新增
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export default {
|
|||||||
chatStore.initWsListeners()
|
chatStore.initWsListeners()
|
||||||
contactStore.initWsListeners()
|
contactStore.initWsListeners()
|
||||||
groupStore.initWsListeners()
|
groupStore.initWsListeners()
|
||||||
|
contactStore.fetchPendingRequests().catch(() => {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 配置的页面一致
|
||||||
* - 消息 Tab(index=0)显示全局未读消息 badge
|
* - 消息 Tab(index=0)显示全局未读消息 badge
|
||||||
|
* - 联系人 Tab(index=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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 || '我')
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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(),
|
||||||
@@ -147,9 +146,22 @@ onMounted(async () => {
|
|||||||
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}` })
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 + ' '
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user