feat(phase2c): 群聊与已读回执功能完整实现

Phase 2c 全部 14 个 Task 完成,包含:

后端(Go):
- MinIO 文件存储服务集成(Docker + Go SDK + 通用上传 API)
- Group 模块完整实现(DAO + Service + Controller + Router + Wire)
  - 18 个群管理 REST API + 11 个 WS 群事件推送
  - 群创建/解散/邀请/踢人/退出/转让群主/设管理员/禁言/全体禁言/群公告/群昵称/免打扰/搜索
- IM Service 扩展(群消息发送/撤回 + @提醒 + 管理员无时限撤回)
- 已读回执后端(单聊会话级 last_read_msg_id + 群聊消息级 im_message_reads)
- 管理端群组管理(列表/详情/解散)
- 数据库迁移(3 张新表 + 2 张表字段扩展)

前端(uni-app):
- 群聊 Store + API 封装 + 11 个 WS 事件监听
- 已读回执 UI(单聊已读/未读标记 + 群聊 X人已读 + 已读详情页)
- 7 个群聊页面(对话/创建/设置/成员/邀请/审批/搜索)
- 会话列表改造(全部/单聊/群聊 Tab + @标记 + 免打扰标识)

管理端(Vue 3 + Element Plus):
- 群组列表页(搜索/分页/详情弹窗/解散群聊)
- 侧边栏群组管理入口

文档同步:进度/架构/设计/API/规范文档全部更新

Made-with: Cursor
This commit is contained in:
bujinyuan
2026-03-04 14:58:23 +08:00
parent ad76d9a613
commit 19979da59e
77 changed files with 10053 additions and 490 deletions

View File

@@ -61,25 +61,32 @@
<!-- ====== 自己消息右侧[状态] [气泡] [头像] ====== -->
<template v-else>
<view v-if="msg._sending" class="msg-status">
<uni-icons type="loop" size="16" color="#94A3B8" />
</view>
<view v-if="msg._failed" class="msg-status msg-status-tap" @tap="onResend(msg)">
<uni-icons type="info-filled" size="18" color="#EF4444" />
</view>
<view
class="bubble bubble-self"
:class="{ 'bubble-recalled': msg.status === 2 }"
@longpress="onMsgLongPress(msg)"
>
<text v-if="msg.status === 2" class="recalled-text">消息已撤回</text>
<text v-else class="msg-text msg-text-self">{{ msg.content }}</text>
</view>
<view class="avatar-wrap">
<image v-if="selfAvatar" class="avatar-img" :src="selfAvatar" mode="aspectFill" />
<view v-else class="avatar-img avatar-placeholder avatar-self">
<text class="avatar-char">{{ (selfName || '我')[0] }}</text>
<view class="self-msg-col">
<view class="self-msg-row">
<view v-if="msg._sending" class="msg-status">
<uni-icons type="loop" size="16" color="#94A3B8" />
</view>
<view v-if="msg._failed" class="msg-status msg-status-tap" @tap="onResend(msg)">
<uni-icons type="info-filled" size="18" color="#EF4444" />
</view>
<view
class="bubble bubble-self"
:class="{ 'bubble-recalled': msg.status === 2 }"
@longpress="onMsgLongPress(msg)"
>
<text v-if="msg.status === 2" class="recalled-text">消息已撤回</text>
<text v-else class="msg-text msg-text-self">{{ msg.content }}</text>
</view>
<view class="avatar-wrap">
<image v-if="selfAvatar" class="avatar-img" :src="selfAvatar" mode="aspectFill" />
<view v-else class="avatar-img avatar-placeholder avatar-self">
<text class="avatar-char">{{ (selfName || '我')[0] }}</text>
</view>
</view>
</view>
<text v-if="getReadLabel(msg)" class="read-label" :class="isRead(msg) ? 'read-label-read' : 'read-label-unread'">
{{ getReadLabel(msg) }}
</text>
</view>
</template>
</view>
@@ -134,11 +141,29 @@ export default {
const selfAvatar = computed(() => userStore.userInfo?.avatar || '')
const selfName = computed(() => userStore.userInfo?.nickname || userStore.userInfo?.username || '我')
const convType = ref(1)
const isSelf = (msg) => {
const myId = Number(userStore.userInfo?.id) || 0
return msg.sender_id === myId || msg._sending === true || msg._failed === true
}
const isRead = (msg) => {
if (convType.value === 2) return false
if (msg.status === 2 || msg._sending || msg._failed || !msg.id) return false
const lastReadId = chatStore.readStatusMap[conversationId.value] || 0
return msg.id <= lastReadId
}
const getReadLabel = (msg) => {
if (msg.status === 2 || msg._sending || msg._failed || !msg.id) return ''
if (convType.value === 2) {
const count = chatStore.groupReadCountMap[msg.id]
return count > 0 ? `${count}人已读` : ''
}
return isRead(msg) ? '已读' : '未读'
}
const tryFindExistingConversation = async () => {
try {
await chatStore.fetchConversations()
@@ -158,6 +183,7 @@ export default {
peerId.value = parseInt(query.peerId) || 0
peerName.value = decodeURIComponent(query.peerName || '')
peerAvatar.value = decodeURIComponent(query.peerAvatar || '')
convType.value = parseInt(query.convType) || 1
chatStore.initWsListeners()
@@ -265,9 +291,10 @@ export default {
return {
peerName, peerAvatar, selfAvatar, selfName,
inputText, scrollToId, loadingMore,
inputText, scrollToId, loadingMore, convType,
messages, hasMore, isTyping,
isSelf, onSend, onInputChange, onLoadMore,
isSelf, isRead, getReadLabel,
onSend, onInputChange, onLoadMore,
onMsgLongPress, onResend, goBack, goToSettings
}
}
@@ -376,6 +403,25 @@ export default {
.msg-text-self { color: #FFFFFF; }
.recalled-text { font-size: 24rpx; color: #94A3B8; font-style: italic; }
/* ===== 自己消息布局(含已读标记) ===== */
.self-msg-col {
display: flex;
flex-direction: column;
align-items: flex-end;
}
.self-msg-row {
display: flex;
align-items: flex-start;
justify-content: flex-end;
}
.read-label {
font-size: 20rpx;
margin-top: 4rpx;
margin-right: 88rpx;
}
.read-label-read { color: #2563EB; }
.read-label-unread { color: #94A3B8; }
/* ===== 发送状态 ===== */
.msg-status {
display: flex;

View File

@@ -15,6 +15,22 @@
<view class="action-btn" @tap="goToSearch">
<uni-icons type="search" size="20" color="#475569" />
</view>
<view class="action-btn" @tap="goToCreateGroup">
<uni-icons type="plusempty" size="20" color="#475569" />
</view>
</view>
</view>
<!-- Tab 筛选栏 -->
<view class="tab-filter">
<view class="tab-item" :class="{ 'tab-active': activeTab === 'all' }" @tap="activeTab = 'all'">
<text class="tab-text">全部</text>
</view>
<view class="tab-item" :class="{ 'tab-active': activeTab === 'private' }" @tap="activeTab = 'private'">
<text class="tab-text">单聊</text>
</view>
<view class="tab-item" :class="{ 'tab-active': activeTab === 'group' }" @tap="activeTab = 'group'">
<text class="tab-text">群聊</text>
</view>
</view>
@@ -47,7 +63,7 @@
<view v-else class="conv-avatar conv-avatar-placeholder">
<text class="avatar-text">{{ (conv.peer_nickname || '?')[0] }}</text>
</view>
<view v-if="conv.unread_count > 0" class="conv-badge">
<view v-if="conv.unread_count > 0" class="conv-badge" :class="{ 'conv-badge-mute': conv.is_do_not_disturb }">
<text class="conv-badge-text">{{ conv.unread_count > 99 ? '99+' : conv.unread_count }}</text>
</view>
</view>
@@ -60,9 +76,13 @@
</view>
<view class="conv-bottom">
<text v-if="isTyping(conv.id)" class="conv-typing">对方正在输入...</text>
<text v-else class="conv-preview" :class="{ 'conv-preview-unread': conv.unread_count > 0 }">
{{ conv.last_msg_content || ' ' }}
</text>
<view v-else class="conv-preview-row">
<text v-if="conv.at_me_count > 0" class="conv-at-tag">[{{ conv.at_me_count }}] @了我</text>
<text v-if="conv.is_do_not_disturb" class="conv-dnd-icon">🔕</text>
<text class="conv-preview" :class="{ 'conv-preview-unread': conv.unread_count > 0 }">
{{ conv.last_msg_content || ' ' }}
</text>
</view>
<view v-if="conv.is_pinned" class="conv-pin-tag">
<uni-icons type="top" size="14" color="#94A3B8" />
</view>
@@ -87,8 +107,14 @@ export default {
setup() {
const chatStore = useChatStore()
const loading = ref(false)
const activeTab = ref('all')
const conversations = computed(() => chatStore.sortedConversations)
const conversations = computed(() => {
const all = chatStore.sortedConversations
if (activeTab.value === 'private') return all.filter(c => c.type === 1)
if (activeTab.value === 'group') return all.filter(c => c.type === 2)
return all
})
const loadData = async () => {
loading.value = true
@@ -105,15 +131,25 @@ export default {
})
const openChat = (conv) => {
uni.navigateTo({
url: `/pages/chat/conversation?conversationId=${conv.id}&peerId=${conv.peer_user_id}&peerName=${encodeURIComponent(conv.peer_nickname || '')}&peerAvatar=${encodeURIComponent(conv.peer_avatar || '')}`
})
if (conv.type === 2) {
uni.navigateTo({
url: `/pages/group/conversation?conversationId=${conv.id}&groupId=${conv.group_id || 0}&peerName=${encodeURIComponent(conv.peer_nickname || '')}&peerAvatar=${encodeURIComponent(conv.peer_avatar || '')}`
})
} else {
uni.navigateTo({
url: `/pages/chat/conversation?conversationId=${conv.id}&peerId=${conv.peer_user_id}&peerName=${encodeURIComponent(conv.peer_nickname || '')}&peerAvatar=${encodeURIComponent(conv.peer_avatar || '')}&convType=1`
})
}
}
const goToSearch = () => {
uni.navigateTo({ url: '/pages/chat/search' })
}
const goToCreateGroup = () => {
uni.navigateTo({ url: '/pages/group/create' })
}
const isTyping = (convId) => {
return chatStore.typingMap[convId] || false
}
@@ -160,9 +196,11 @@ export default {
return {
loading,
activeTab,
conversations,
openChat,
goToSearch,
goToCreateGroup,
isTyping,
formatTime,
onLongPress,
@@ -215,9 +253,25 @@ export default {
background-color: #E2E8F0;
}
/* Tab 筛选栏 */
.tab-filter {
display: flex;
padding: 0 32rpx;
background-color: #FFFFFF;
border-bottom: 1rpx solid #E2E8F0;
}
.tab-item {
padding: 16rpx 24rpx;
margin-right: 8rpx;
position: relative;
}
.tab-active { border-bottom: 4rpx solid #2563EB; }
.tab-text { font-size: 26rpx; color: #475569; }
.tab-active .tab-text { color: #2563EB; font-weight: 600; }
/* 会话列表 */
.conv-list {
height: calc(100vh - 88rpx - var(--status-bar-height, 44px) - 120rpx);
height: calc(100vh - 88rpx - var(--status-bar-height, 44px) - 120rpx - 72rpx);
}
.conv-item {
@@ -336,6 +390,31 @@ export default {
font-weight: 500;
}
.conv-preview-row {
display: flex;
align-items: center;
overflow: hidden;
flex: 1;
}
.conv-at-tag {
font-size: 22rpx;
color: #EF4444;
font-weight: 600;
margin-right: 8rpx;
flex-shrink: 0;
}
.conv-dnd-icon {
font-size: 22rpx;
margin-right: 4rpx;
flex-shrink: 0;
}
.conv-badge-mute {
background-color: #94A3B8;
}
.conv-typing {
font-size: 26rpx;
color: #2563EB;

View File

@@ -0,0 +1,173 @@
<!--
已读详情页
展示群消息的已读/未读用户列表
Tab 切换已读 / 未读
-->
<template>
<view class="page-wrapper">
<!-- Tab 切换 -->
<view class="tab-bar">
<view
class="tab-item"
:class="{ 'tab-active': activeTab === 'read' }"
@tap="activeTab = 'read'"
>
<text class="tab-text">已读 ({{ readCount }})</text>
</view>
<view
class="tab-item"
:class="{ 'tab-active': activeTab === 'unread' }"
@tap="activeTab = 'unread'"
>
<text class="tab-text">未读 ({{ unreadCount }})</text>
</view>
</view>
<!-- 用户列表 -->
<scroll-view scroll-y class="user-list">
<view v-if="loading" class="empty-state">
<text class="empty-text">加载中...</text>
</view>
<view v-else-if="displayList.length === 0" class="empty-state">
<text class="empty-text">暂无数据</text>
</view>
<view
v-for="user in displayList"
:key="user.user_id"
class="user-item"
>
<view class="user-avatar-wrap">
<image v-if="user.user_avatar" class="user-avatar" :src="user.user_avatar" mode="aspectFill" />
<view v-else class="user-avatar user-avatar-placeholder">
<text class="avatar-char">{{ (user.user_nickname || '?')[0] }}</text>
</view>
</view>
<view class="user-info">
<text class="user-name">{{ user.user_nickname || '未知用户' }}</text>
<text v-if="user.read_at && activeTab === 'read'" class="user-time">{{ user.read_at }}</text>
</view>
</view>
</scroll-view>
</view>
</template>
<script>
import { onLoad } from '@dcloudio/uni-app'
import { ref, computed } from 'vue'
import imApi from '@/api/im'
export default {
name: 'ReadDetail',
setup() {
const messageId = ref(0)
const activeTab = ref('read')
const loading = ref(false)
const readList = ref([])
const unreadList = ref([])
const readCount = ref(0)
const totalCount = ref(0)
const unreadCount = computed(() => totalCount.value - readCount.value)
const displayList = computed(() => {
return activeTab.value === 'read' ? readList.value : unreadList.value
})
const fetchReadDetail = async () => {
if (!messageId.value) return
loading.value = true
try {
const res = await imApi.getMessageReadDetail(messageId.value)
if (res.data) {
readList.value = res.data.read_list || []
unreadList.value = res.data.unread_list || []
readCount.value = res.data.read_count || 0
totalCount.value = res.data.total_count || 0
}
} catch (e) {
console.warn('[ReadDetail] 获取已读详情失败', e)
uni.showToast({ title: e?.data?.message || '获取已读详情失败', icon: 'none' })
} finally {
loading.value = false
}
}
onLoad((query) => {
messageId.value = parseInt(query.messageId) || 0
fetchReadDetail()
})
return {
activeTab, loading, readCount, unreadCount,
displayList
}
}
}
</script>
<style scoped>
.page-wrapper {
height: 100vh;
display: flex;
flex-direction: column;
background-color: #F8FAFC;
}
/* Tab 切换 */
.tab-bar {
display: flex;
background-color: #FFFFFF;
border-bottom: 1rpx solid #E2E8F0;
}
.tab-item {
flex: 1;
text-align: center;
padding: 24rpx 0;
position: relative;
}
.tab-active {
border-bottom: 4rpx solid #2563EB;
}
.tab-text {
font-size: 28rpx;
color: #475569;
}
.tab-active .tab-text {
color: #2563EB;
font-weight: 600;
}
/* 用户列表 */
.user-list {
flex: 1;
height: 0;
}
.user-item {
display: flex;
align-items: center;
padding: 24rpx 32rpx;
background-color: #FFFFFF;
border-bottom: 1rpx solid #F1F5F9;
}
.user-avatar-wrap { flex-shrink: 0; margin-right: 20rpx; }
.user-avatar {
width: 72rpx;
height: 72rpx;
border-radius: 18rpx;
}
.user-avatar-placeholder {
display: flex;
align-items: center;
justify-content: center;
background-color: #2563EB;
}
.avatar-char { color: #FFFFFF; font-size: 28rpx; font-weight: 600; }
.user-info { flex: 1; }
.user-name { font-size: 28rpx; color: #1E293B; }
.user-time { display: block; font-size: 22rpx; color: #94A3B8; margin-top: 4rpx; }
/* 空状态 */
.empty-state { padding: 80rpx 0; text-align: center; }
.empty-text { font-size: 28rpx; color: #94A3B8; }
</style>

View File

@@ -0,0 +1,736 @@
<!--
群聊对话页
布局规则
- 自己的消息靠右对齐顺序 [状态图标] [蓝色气泡] [自己头像]
- 他人的消息靠左对齐顺序 [头像] [昵称 + 白色气泡]
- 系统消息type === 10居中灰色文字无头像无气泡
- 导航栏返回 / 群名称 / 群设置入口
-->
<template>
<view class="page-wrapper">
<!-- 导航栏 -->
<view class="nav-bar">
<view class="nav-left" @tap="goBack">
<uni-icons type="back" size="20" color="#1E293B" />
</view>
<view class="nav-center">
<text class="nav-title">{{ groupName || '群聊' }}</text>
<text v-if="memberCount" class="nav-subtitle">({{ memberCount }})</text>
</view>
<view class="nav-right" @tap="goToSettings">
<uni-icons type="more-filled" size="20" color="#475569" />
</view>
</view>
<!-- 消息列表 -->
<scroll-view
scroll-y
class="msg-list"
:scroll-into-view="scrollToId"
:scroll-with-animation="true"
@scrolltoupper="onLoadMore"
>
<view v-if="hasMore" class="load-more" @tap="onLoadMore">
<text class="load-more-text">{{ loadingMore ? '加载中...' : '加载更多' }}</text>
</view>
<view
v-for="msg in messages"
:key="msg.client_msg_id || msg.id"
:id="'msg-' + (msg.id || msg.client_msg_id)"
>
<!-- ====== 系统消息居中灰色文字 ====== -->
<view v-if="msg.type === 10 || msg.sender_id === 0" class="msg-row-system">
<text class="system-text">{{ msg.content }}</text>
</view>
<!-- ====== 普通消息 ====== -->
<view
v-else
class="msg-row"
:class="isSelf(msg) ? 'msg-row-self' : 'msg-row-other'"
>
<!-- 他人消息左侧[头像] [昵称 + 气泡] -->
<template v-if="!isSelf(msg)">
<view class="avatar-wrap">
<image
v-if="getMemberAvatar(msg.sender_id)"
class="avatar-img"
:src="getMemberAvatar(msg.sender_id)"
mode="aspectFill"
/>
<view v-else class="avatar-img avatar-placeholder avatar-peer">
<text class="avatar-char">{{ getMemberName(msg.sender_id)[0] || '?' }}</text>
</view>
</view>
<view class="bubble-col">
<text class="sender-name">{{ getMemberName(msg.sender_id) }}</text>
<view
class="bubble bubble-other"
:class="{ 'bubble-recalled': msg.status === 2 }"
@longpress="onMsgLongPress(msg)"
>
<text v-if="msg.status === 2" class="recalled-text">消息已撤回</text>
<text v-else class="msg-text">{{ msg.content }}</text>
</view>
</view>
</template>
<!-- 自己消息右侧[状态] [气泡] [头像] -->
<template v-else>
<view class="self-msg-col">
<view class="self-msg-row">
<view v-if="msg._sending" class="msg-status">
<uni-icons type="loop" size="16" color="#94A3B8" />
</view>
<view v-if="msg._failed" class="msg-status msg-status-tap" @tap="onResend(msg)">
<uni-icons type="info-filled" size="18" color="#EF4444" />
</view>
<view
class="bubble bubble-self"
:class="{ 'bubble-recalled': msg.status === 2 }"
@longpress="onMsgLongPress(msg)"
>
<text v-if="msg.status === 2" class="recalled-text">消息已撤回</text>
<text v-else class="msg-text msg-text-self">{{ msg.content }}</text>
</view>
<view class="avatar-wrap">
<image v-if="selfAvatar" class="avatar-img" :src="selfAvatar" mode="aspectFill" />
<view v-else class="avatar-img avatar-placeholder avatar-self">
<text class="avatar-char">{{ (selfName || '我')[0] }}</text>
</view>
</view>
</view>
<!-- 群聊已读计数 -->
<text
v-if="getReadLabel(msg)"
class="read-label"
@tap="goToReadDetail(msg)"
>
{{ getReadLabel(msg) }}
</text>
</view>
</template>
</view>
</view>
<view id="msg-bottom" style="height: 2rpx;" />
</scroll-view>
<!-- @成员选择器 -->
<view v-if="showAtPicker" class="at-overlay" @tap="showAtPicker = false">
<view class="at-panel" @tap.stop>
<view class="at-header">
<text class="at-header-text">选择成员</text>
<view class="at-close" @tap="showAtPicker = false">
<uni-icons type="close" size="18" color="#475569" />
</view>
</view>
<scroll-view scroll-y class="at-member-list">
<view class="at-member-item" @tap="onSelectAtMember(null)">
<view class="avatar-img avatar-placeholder avatar-all">
<text class="avatar-char">@</text>
</view>
<text class="at-member-name">所有人</text>
</view>
<view
v-for="member in atMemberList"
:key="member.user_id"
class="at-member-item"
@tap="onSelectAtMember(member)"
>
<image
v-if="member.avatar"
class="avatar-img at-avatar"
:src="member.avatar"
mode="aspectFill"
/>
<view v-else class="avatar-img avatar-placeholder avatar-peer at-avatar">
<text class="avatar-char">{{ (member.nickname || member.username || '?')[0] }}</text>
</view>
<text class="at-member-name">{{ member.nickname || member.username }}</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>
</view>
</scroll-view>
</view>
</view>
<!-- 输入栏 -->
<view v-if="isMuted" class="input-bar input-bar-muted">
<text class="muted-tip">{{ isAllMuted ? '当前群聊已开启全体禁言' : '你已被禁言,无法发送消息' }}</text>
</view>
<view v-else class="input-bar">
<view class="input-wrap">
<input
class="msg-input"
v-model="inputText"
placeholder="输入消息..."
placeholder-style="color: #94A3B8"
confirm-type="send"
@confirm="onSend"
@input="onInputChange"
:adjust-position="true"
/>
</view>
<view class="send-btn" :class="{ 'send-btn-active': inputText.trim() }" @tap="onSend">
<uni-icons type="paperplane" size="22" color="#FFFFFF" />
</view>
</view>
</view>
</template>
<script>
import { onLoad, onUnload } from '@dcloudio/uni-app'
import { ref, computed, nextTick, watch } from 'vue'
import { useChatStore } from '@/store/chat'
import { useGroupStore } from '@/store/group'
import { useUserStore } from '@/store/user'
import { GROUP_ROLE } from '@/constants/group'
export default {
name: 'GroupConversation',
setup() {
const chatStore = useChatStore()
const groupStore = useGroupStore()
const userStore = useUserStore()
const conversationId = ref(0)
const groupId = ref(0)
const groupName = ref('')
const groupAvatar = ref('')
const inputText = ref('')
const scrollToId = ref('')
const loadingMore = ref(false)
const showAtPicker = ref(false)
const atUserIds = ref([])
const messages = computed(() => chatStore.currentMessages)
const hasMore = computed(() => chatStore.hasMoreMap[conversationId.value] !== false)
const selfAvatar = computed(() => userStore.userInfo?.avatar || '')
const selfName = computed(() => userStore.userInfo?.nickname || userStore.userInfo?.username || '我')
const myId = computed(() => Number(userStore.userInfo?.id) || 0)
const memberCount = computed(() => groupStore.currentMembers.length || 0)
/** 构建成员 ID → 信息的映射,用于显示发送者昵称 / 头像 */
const memberMap = computed(() => {
const map = {}
for (const m of groupStore.currentMembers) {
map[m.user_id] = m
}
return map
})
/** @选择器中排除自己的成员列表 */
const atMemberList = computed(() => {
return groupStore.currentMembers.filter(m => m.user_id !== myId.value)
})
/** 当前用户在群中的角色(用于判断撤回权限) */
const myRole = computed(() => {
const me = memberMap.value[myId.value]
return me ? me.role : GROUP_ROLE.MEMBER
})
/** 当前群是否全体禁言 */
const isAllMuted = computed(() => {
return groupStore.currentGroup?.is_all_muted === true
})
/** 当前用户是否被禁言(个人禁言或全体禁言且非管理员/群主) */
const isMuted = computed(() => {
const me = memberMap.value[myId.value]
if (!me) return false
if (me.is_muted) return true
if (isAllMuted.value && me.role === GROUP_ROLE.MEMBER) return true
return false
})
const isSelf = (msg) => {
return msg.sender_id === myId.value
}
const getMemberName = (userId) => {
const member = memberMap.value[userId]
if (member) return member.nickname || member.username || '未知'
return '未知成员'
}
const getMemberAvatar = (userId) => {
const member = memberMap.value[userId]
return member ? member.avatar : ''
}
/** 群聊已读标签:仅自己的消息显示 "X人已读" */
const getReadLabel = (msg) => {
if (msg.status === 2 || msg._sending || msg._failed || !msg.id) return ''
const count = chatStore.groupReadCountMap[msg.id]
return count > 0 ? `${count}人已读` : ''
}
// ==================== 生命周期 ====================
onLoad((query) => {
conversationId.value = parseInt(query.conversationId) || 0
groupId.value = parseInt(query.groupId) || 0
groupName.value = decodeURIComponent(query.peerName || '')
groupAvatar.value = decodeURIComponent(query.peerAvatar || '')
chatStore.initWsListeners()
groupStore.initWsListeners()
if (conversationId.value) {
chatStore.setCurrentConversation(conversationId.value)
loadInitialMessages()
}
if (groupId.value) {
groupStore.fetchMembers(groupId.value)
groupStore.fetchGroupDetail(groupId.value)
}
})
onUnload(() => {
chatStore.setCurrentConversation(null)
})
watch(() => chatStore.currentConversationId, (newId) => {
if (newId && newId !== conversationId.value) {
conversationId.value = newId
}
})
// ==================== 消息加载 ====================
const loadInitialMessages = async () => {
if (!chatStore.messagesMap[conversationId.value]?.length) {
await chatStore.loadHistoryMessages(conversationId.value)
}
scrollToBottom()
markVisibleMessagesRead()
}
const scrollToBottom = () => {
nextTick(() => {
scrollToId.value = ''
nextTick(() => { scrollToId.value = 'msg-bottom' })
})
}
/** 标记当前可见消息为已读 */
const markVisibleMessagesRead = () => {
const msgs = chatStore.messagesMap[conversationId.value] || []
const messageIds = msgs.filter(m => m.id && m.sender_id !== myId.value).map(m => m.id)
if (messageIds.length > 0) {
chatStore.markGroupRead(conversationId.value, messageIds)
}
}
// ==================== 消息发送 ====================
const onSend = () => {
const content = inputText.value.trim()
if (!content) return
chatStore.sendMessage({
conversationId: conversationId.value,
content,
type: 1,
at_user_ids: atUserIds.value.length > 0 ? [...atUserIds.value] : undefined
})
inputText.value = ''
atUserIds.value = []
scrollToBottom()
}
/** 输入变化检测:输入 @ 时弹出成员选择器 */
const onInputChange = (e) => {
const val = e.detail.value || inputText.value
if (val.endsWith('@')) {
showAtPicker.value = true
}
}
/** 选择 @成员null 表示 @所有人) */
const onSelectAtMember = (member) => {
showAtPicker.value = false
if (member === null) {
atUserIds.value.push(0)
inputText.value = inputText.value + '所有人 '
} else {
atUserIds.value.push(member.user_id)
const name = member.nickname || member.username || ''
inputText.value = inputText.value + name + ' '
}
}
// ==================== 加载更多 ====================
const onLoadMore = async () => {
if (loadingMore.value || !hasMore.value) return
loadingMore.value = true
try { await chatStore.loadHistoryMessages(conversationId.value) }
finally { loadingMore.value = false }
}
// ==================== 消息撤回 ====================
/**
* 撤回权限判断
* - 管理员/群主:可撤回任何人消息,无时间限制
* - 普通成员仅可撤回自己消息2 分钟内
*/
const canRecall = (msg) => {
if (msg.status === 2 || !msg.id) return false
const isAdmin = myRole.value === GROUP_ROLE.OWNER || myRole.value === GROUP_ROLE.ADMIN
if (isAdmin) return true
if (!isSelf(msg)) return false
if (!msg.created_at) return false
return (Date.now() - new Date(msg.created_at).getTime()) < 2 * 60 * 1000
}
const onMsgLongPress = (msg) => {
if (msg.status === 2 || !canRecall(msg)) return
uni.showActionSheet({
itemList: ['撤回'],
success: (res) => {
if (res.tapIndex === 0 && msg.id) chatStore.recallMessage(msg.id)
}
})
}
const onResend = (msg) => {
uni.showModal({
title: '提示',
content: '是否重新发送?',
success: (res) => {
if (res.confirm) {
chatStore.sendMessage({
conversationId: conversationId.value,
content: msg.content,
type: msg.type || 1
})
}
}
})
}
// ==================== 导航 ====================
const goBack = () => {
if (getCurrentPages().length > 1) {
uni.navigateBack()
} else {
uni.switchTab({ url: '/pages/chat/index' })
}
}
const goToSettings = () => {
uni.navigateTo({
url: `/pages/group/settings?groupId=${groupId.value}&conversationId=${conversationId.value}&peerName=${encodeURIComponent(groupName.value)}&peerAvatar=${encodeURIComponent(groupAvatar.value)}`
})
}
const goToReadDetail = (msg) => {
if (!msg.id) return
uni.navigateTo({
url: `/pages/chat/read-detail?messageId=${msg.id}`
})
}
// ==================== 监听新消息到达后标记已读 ====================
watch(
() => messages.value.length,
() => {
scrollToBottom()
markVisibleMessagesRead()
}
)
return {
GROUP_ROLE,
groupName, groupAvatar, selfAvatar, selfName,
inputText, scrollToId, loadingMore, memberCount,
messages, hasMore, showAtPicker, atMemberList, isMuted, isAllMuted,
isSelf, getMemberName, getMemberAvatar, getReadLabel,
onSend, onInputChange, onSelectAtMember,
onLoadMore, onMsgLongPress, onResend,
goBack, goToSettings, goToReadDetail
}
}
}
</script>
<style scoped>
.page-wrapper {
height: 100vh;
display: flex;
flex-direction: column;
background-color: #F1F5F9;
overflow: hidden;
}
/* ===== 导航栏 ===== */
.nav-bar {
display: flex;
align-items: center;
height: 88rpx;
padding: 0 24rpx;
padding-top: var(--status-bar-height, 44px);
background-color: #FFFFFF;
border-bottom: 1rpx solid #E2E8F0;
}
.nav-left, .nav-right {
min-width: 88rpx;
min-height: 88rpx;
display: flex;
align-items: center;
justify-content: center;
transition: opacity 150ms ease;
}
.nav-left:active, .nav-right:active { opacity: 0.6; }
.nav-center {
flex: 1;
display: flex;
align-items: baseline;
justify-content: center;
}
.nav-title { font-size: 32rpx; font-weight: 600; color: #1E293B; }
.nav-subtitle { font-size: 24rpx; color: #94A3B8; margin-left: 8rpx; }
/* ===== 消息列表 ===== */
.msg-list {
flex: 1;
height: 0;
min-height: 0;
padding: 16rpx 24rpx;
box-sizing: border-box;
width: 100%;
overflow: hidden;
}
.load-more { text-align: center; padding: 16rpx 0; }
.load-more-text { font-size: 24rpx; color: #94A3B8; }
/* ===== 系统消息 ===== */
.msg-row-system {
text-align: center;
padding: 16rpx 48rpx;
margin-bottom: 24rpx;
}
.system-text {
font-size: 24rpx;
color: #94A3B8;
line-height: 36rpx;
background-color: rgba(148, 163, 184, 0.12);
padding: 8rpx 20rpx;
border-radius: 12rpx;
}
/* ===== 消息行 ===== */
.msg-row {
display: flex;
align-items: flex-start;
margin-bottom: 24rpx;
}
.msg-row-other { justify-content: flex-start; }
.msg-row-self { justify-content: flex-end; }
/* ===== 头像 ===== */
.avatar-wrap { flex-shrink: 0; }
.msg-row-other .avatar-wrap { margin-right: 16rpx; }
.msg-row-self .avatar-wrap { margin-left: 16rpx; }
.avatar-img {
width: 72rpx;
height: 72rpx;
border-radius: 18rpx;
}
.avatar-placeholder {
display: flex;
align-items: center;
justify-content: center;
}
.avatar-peer { background-color: #2563EB; }
.avatar-self { background-color: #64748B; }
.avatar-all { background-color: #F59E0B; }
.avatar-char { color: #FFFFFF; font-size: 28rpx; font-weight: 600; }
/* ===== 发送者昵称 + 气泡列 ===== */
.bubble-col {
display: flex;
flex-direction: column;
max-width: 65vw;
}
.sender-name {
font-size: 22rpx;
color: #94A3B8;
margin-bottom: 6rpx;
line-height: 30rpx;
}
/* ===== 气泡 ===== */
.bubble {
max-width: 65vw;
padding: 20rpx 24rpx;
border-radius: 24rpx;
word-break: break-word;
overflow-wrap: break-word;
}
.bubble-self {
background-color: #2563EB;
border-bottom-right-radius: 8rpx;
}
.bubble-other {
background-color: #FFFFFF;
border-bottom-left-radius: 8rpx;
}
.bubble-recalled {
background-color: transparent !important;
padding: 8rpx 16rpx;
}
.msg-text { font-size: 30rpx; line-height: 42rpx; color: #1E293B; }
.msg-text-self { color: #FFFFFF; }
.recalled-text { font-size: 24rpx; color: #94A3B8; font-style: italic; }
/* ===== 自己消息布局(含已读标记) ===== */
.self-msg-col {
display: flex;
flex-direction: column;
align-items: flex-end;
}
.self-msg-row {
display: flex;
align-items: flex-start;
justify-content: flex-end;
}
.read-label {
font-size: 20rpx;
margin-top: 4rpx;
margin-right: 88rpx;
color: #2563EB;
}
/* ===== 发送状态 ===== */
.msg-status {
display: flex;
align-items: center;
align-self: center;
margin: 0 8rpx;
}
.msg-status-tap {
min-width: 44rpx;
min-height: 44rpx;
display: flex;
align-items: center;
justify-content: center;
}
/* ===== @成员选择器 ===== */
.at-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.35);
z-index: 999;
display: flex;
align-items: flex-end;
}
.at-panel {
width: 100%;
max-height: 60vh;
background-color: #FFFFFF;
border-radius: 24rpx 24rpx 0 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.at-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx 32rpx;
border-bottom: 1rpx solid #E2E8F0;
}
.at-header-text { font-size: 30rpx; font-weight: 600; color: #1E293B; }
.at-close {
min-width: 44rpx;
min-height: 44rpx;
display: flex;
align-items: center;
justify-content: center;
}
.at-member-list {
flex: 1;
max-height: 50vh;
padding-bottom: env(safe-area-inset-bottom, 0);
}
.at-member-item {
display: flex;
align-items: center;
padding: 20rpx 32rpx;
transition: background-color 150ms ease;
}
.at-member-item:active { background-color: #F8FAFC; }
.at-avatar { width: 64rpx; height: 64rpx; border-radius: 16rpx; }
.at-member-name {
flex: 1;
font-size: 28rpx;
color: #1E293B;
margin-left: 20rpx;
}
.at-role-tag {
font-size: 22rpx;
color: #2563EB;
background-color: rgba(37, 99, 235, 0.08);
padding: 4rpx 12rpx;
border-radius: 8rpx;
margin-left: 12rpx;
}
/* ===== 输入栏 ===== */
.input-bar {
display: flex;
align-items: center;
padding: 16rpx 24rpx;
padding-bottom: calc(16rpx + env(safe-area-inset-bottom, 0));
background-color: #FFFFFF;
border-top: 1rpx solid #E2E8F0;
}
.input-wrap {
flex: 1;
background-color: #F1F5F9;
border-radius: 36rpx;
padding: 0 28rpx;
height: 72rpx;
display: flex;
align-items: center;
}
.msg-input { flex: 1; font-size: 28rpx; color: #1E293B; }
.send-btn {
min-width: 72rpx;
min-height: 72rpx;
margin-left: 16rpx;
border-radius: 50%;
background-color: #CBD5E1;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 200ms ease;
}
.send-btn-active { background-color: #2563EB; }
.send-btn:active { opacity: 0.85; }
/* ===== 禁言状态 ===== */
.input-bar-muted {
justify-content: center;
}
.muted-tip {
font-size: 26rpx;
color: #94A3B8;
text-align: center;
}
</style>

View File

@@ -0,0 +1,576 @@
<!--
创建群聊页
设计系统design-system/echochat/MASTER.md
色板Primary #2563EB / BG #F8FAFC / Surface #F1F5F9 / Text #1E293B
ui-ux-pro-max 规范防重复提交 / loading 状态 / 触摸目标 >= 88rpx
-->
<template>
<view class="page-wrapper">
<!-- 群名称输入区 -->
<view class="section">
<view class="section-header">
<text class="section-title">群名称</text>
</view>
<view class="name-input-wrap">
<input
class="name-input"
v-model="groupName"
placeholder="请输入群聊名称"
placeholder-style="color: #94A3B8"
maxlength="30"
/>
<text class="name-count">{{ groupName.length }}/30</text>
</view>
</view>
<!-- 已选好友展示区 -->
<view v-if="selectedList.length > 0" class="section">
<view class="section-header">
<text class="section-title">已选好友{{ selectedList.length }}</text>
</view>
<scroll-view class="selected-scroll" scroll-x>
<view class="selected-list">
<view
v-for="friend in selectedList"
:key="friend.user_id"
class="selected-item"
@tap="toggleSelect(friend)"
>
<view class="selected-avatar" :style="{ backgroundColor: getAvatarColor(friend.nickname || friend.username) }">
<text class="selected-avatar-text">{{ getInitial(friend.nickname || friend.username) }}</text>
<view class="selected-remove">
<text class="selected-remove-text">×</text>
</view>
</view>
<text class="selected-name">{{ friend.remark || friend.nickname || friend.username }}</text>
</view>
</view>
</scroll-view>
</view>
<!-- 搜索好友 -->
<view class="section">
<view class="section-header">
<text class="section-title">选择好友</text>
</view>
<view class="search-bar">
<uni-icons type="search" size="18" color="#94A3B8" />
<input
class="search-input"
v-model="searchKeyword"
placeholder="搜索好友昵称"
placeholder-style="color: #94A3B8"
/>
<view v-if="searchKeyword" class="search-clear" @tap="searchKeyword = ''">
<uni-icons type="clear" size="16" color="#94A3B8" />
</view>
</view>
</view>
<!-- 好友列表 -->
<view v-if="loading" class="skeleton-list">
<view v-for="i in 5" :key="i" class="skeleton-item">
<view class="skeleton-check"></view>
<view class="skeleton-avatar"></view>
<view class="skeleton-info">
<view class="skeleton-line skeleton-line--name"></view>
</view>
</view>
</view>
<view v-else-if="filteredFriends.length > 0" class="friend-list">
<view
v-for="friend in filteredFriends"
:key="friend.user_id"
class="friend-item"
@tap="toggleSelect(friend)"
>
<view class="checkbox" :class="{ 'checkbox--checked': isSelected(friend.user_id) }">
<text v-if="isSelected(friend.user_id)" class="checkbox-icon"></text>
</view>
<view class="avatar" :style="{ backgroundColor: getAvatarColor(friend.nickname || friend.username) }">
<text class="avatar-text">{{ getInitial(friend.nickname || friend.username) }}</text>
</view>
<view class="friend-info">
<text class="friend-name">{{ friend.remark || friend.nickname || friend.username }}</text>
<text v-if="friend.remark" class="friend-account">{{ friend.nickname || friend.username }}</text>
</view>
</view>
</view>
<view v-else class="empty-state">
<text class="empty-text">{{ searchKeyword ? '未找到匹配的好友' : '暂无好友' }}</text>
</view>
<!-- 底部占位 -->
<view class="bottom-spacer"></view>
<!-- 底部固定创建按钮 -->
<view class="bottom-bar">
<view
class="create-btn"
:class="{ 'create-btn--disabled': !canCreate || submitting }"
@tap="handleCreate"
>
<text class="create-btn-text">
{{ submitting ? '创建中...' : `创建群聊${selectedList.length > 0 ? '' + selectedList.length + '人)' : ''}` }}
</text>
</view>
</view>
</view>
</template>
<script>
import { ref, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { useContactStore } from '@/store/contact'
import { useGroupStore } from '@/store/group'
import { getAvatarColor, getInitial } from '@/utils/avatar'
export default {
name: 'GroupCreate',
setup() {
const contactStore = useContactStore()
const groupStore = useGroupStore()
const groupName = ref('')
const searchKeyword = ref('')
const selectedIds = ref({})
const loading = ref(true)
const submitting = ref(false)
/** 已选中的好友列表 */
const selectedList = computed(() => {
return contactStore.friendList.filter(f => selectedIds.value[f.user_id])
})
/** 搜索过滤后的好友列表 */
const filteredFriends = computed(() => {
const kw = searchKeyword.value.trim().toLowerCase()
if (!kw) return contactStore.friendList
return contactStore.friendList.filter(f => {
const nickname = (f.nickname || '').toLowerCase()
const username = (f.username || '').toLowerCase()
const remark = (f.remark || '').toLowerCase()
return nickname.includes(kw) || username.includes(kw) || remark.includes(kw)
})
})
/** 是否满足创建条件:群名非空 + 至少选2人 */
const canCreate = computed(() => {
return groupName.value.trim().length > 0 && selectedList.value.length >= 2
})
/** 判断好友是否已选中 */
const isSelected = (userId) => {
return !!selectedIds.value[userId]
}
/** 切换好友选中状态 */
const toggleSelect = (friend) => {
const newMap = { ...selectedIds.value }
if (newMap[friend.user_id]) {
delete newMap[friend.user_id]
} else {
newMap[friend.user_id] = true
}
selectedIds.value = newMap
}
/** 提交创建群聊 */
const handleCreate = async () => {
if (!canCreate.value || submitting.value) return
submitting.value = true
try {
const memberIds = selectedList.value.map(f => f.user_id)
const result = await groupStore.createGroup(groupName.value.trim(), memberIds)
uni.showToast({ title: '群聊创建成功', icon: 'success' })
setTimeout(() => {
if (result && result.conversation_id) {
uni.redirectTo({
url: `/pages/group/conversation?conversationId=${result.conversation_id}&groupId=${result.group_id || 0}&peerName=${encodeURIComponent(groupName.value.trim())}`
})
} else {
uni.navigateBack()
}
}, 500)
} catch (e) {
console.error('创建群聊失败', e)
uni.showToast({
title: e?.data?.message || '创建群聊失败',
icon: 'none'
})
} finally {
submitting.value = false
}
}
onLoad(async () => {
try {
await contactStore.fetchFriends()
} catch (e) {
console.error('获取好友列表失败', e)
}
loading.value = false
})
return {
groupName,
searchKeyword,
selectedList,
filteredFriends,
canCreate,
loading,
submitting,
isSelected,
toggleSelect,
handleCreate,
getAvatarColor,
getInitial
}
}
}
</script>
<style scoped>
.page-wrapper {
min-height: 100vh;
background-color: #F8FAFC;
padding-bottom: env(safe-area-inset-bottom);
}
/* 区块通用 */
.section {
margin-bottom: 16rpx;
}
.section-header {
padding: 20rpx 32rpx 12rpx;
}
.section-title {
font-size: 26rpx;
color: #94A3B8;
font-weight: 500;
}
/* 群名称输入 */
.name-input-wrap {
display: flex;
align-items: center;
background-color: #FFFFFF;
padding: 0 32rpx;
height: 96rpx;
}
.name-input {
flex: 1;
font-size: 30rpx;
color: #1E293B;
height: 96rpx;
}
.name-count {
font-size: 24rpx;
color: #94A3B8;
flex-shrink: 0;
margin-left: 16rpx;
}
/* 已选好友区 */
.selected-scroll {
white-space: nowrap;
background-color: #FFFFFF;
padding: 20rpx 32rpx;
}
.selected-list {
display: flex;
gap: 24rpx;
}
.selected-item {
display: flex;
flex-direction: column;
align-items: center;
width: 100rpx;
flex-shrink: 0;
}
.selected-avatar {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.selected-avatar-text {
font-size: 32rpx;
color: #FFFFFF;
font-weight: 600;
}
.selected-remove {
position: absolute;
top: -6rpx;
right: -6rpx;
width: 32rpx;
height: 32rpx;
border-radius: 50%;
background-color: #EF4444;
display: flex;
align-items: center;
justify-content: center;
}
.selected-remove-text {
font-size: 20rpx;
color: #FFFFFF;
font-weight: 600;
line-height: 1;
}
.selected-name {
font-size: 22rpx;
color: #475569;
margin-top: 8rpx;
max-width: 100rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: center;
}
/* 搜索栏 */
.search-bar {
display: flex;
align-items: center;
background-color: #FFFFFF;
padding: 0 24rpx;
height: 80rpx;
gap: 12rpx;
}
.search-input {
flex: 1;
font-size: 28rpx;
color: #1E293B;
height: 80rpx;
}
.search-clear {
flex-shrink: 0;
padding: 8rpx;
}
/* 骨架屏 */
.skeleton-list {
background-color: #FFFFFF;
}
.skeleton-item {
display: flex;
align-items: center;
padding: 20rpx 32rpx;
gap: 20rpx;
border-bottom: 1rpx solid #F1F5F9;
}
.skeleton-check {
width: 40rpx;
height: 40rpx;
border-radius: 8rpx;
background: linear-gradient(90deg, #F1F5F9 25%, #E2E8F0 50%, #F1F5F9 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
flex-shrink: 0;
}
.skeleton-avatar {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
background: linear-gradient(90deg, #F1F5F9 25%, #E2E8F0 50%, #F1F5F9 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
flex-shrink: 0;
}
.skeleton-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 10rpx;
}
.skeleton-line {
border-radius: 8rpx;
background: linear-gradient(90deg, #F1F5F9 25%, #E2E8F0 50%, #F1F5F9 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
.skeleton-line--name {
width: 40%;
height: 28rpx;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* 好友列表 */
.friend-list {
background-color: #FFFFFF;
}
.friend-item {
display: flex;
align-items: center;
padding: 20rpx 32rpx;
border-bottom: 1rpx solid #F1F5F9;
gap: 20rpx;
cursor: pointer;
transition: background-color 150ms ease;
}
.friend-item:active {
background-color: #F8FAFC;
}
.friend-item:last-child {
border-bottom: none;
}
/* 复选框 */
.checkbox {
width: 40rpx;
height: 40rpx;
border-radius: 8rpx;
border: 2rpx solid #CBD5E1;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all 200ms ease;
}
.checkbox--checked {
background-color: #2563EB;
border-color: #2563EB;
}
.checkbox-icon {
font-size: 24rpx;
color: #FFFFFF;
font-weight: 700;
line-height: 1;
}
/* 头像 */
.avatar {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.avatar-text {
font-size: 32rpx;
color: #FFFFFF;
font-weight: 600;
}
/* 好友信息 */
.friend-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 4rpx;
min-width: 0;
}
.friend-name {
font-size: 30rpx;
color: #1E293B;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.friend-account {
font-size: 24rpx;
color: #94A3B8;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 空状态 */
.empty-state {
padding: 80rpx 0;
display: flex;
align-items: center;
justify-content: center;
}
.empty-text {
font-size: 28rpx;
color: #94A3B8;
}
/* 底部占位 */
.bottom-spacer {
height: 140rpx;
}
/* 底部固定按钮 */
.bottom-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 16rpx 32rpx;
padding-bottom: calc(16rpx + env(safe-area-inset-bottom));
background-color: #FFFFFF;
box-shadow: 0 -2rpx 12rpx rgba(0, 0, 0, 0.04);
}
.create-btn {
height: 88rpx;
border-radius: 16rpx;
background-color: #2563EB;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: opacity 200ms ease;
}
.create-btn:active {
opacity: 0.85;
}
.create-btn--disabled {
background-color: #CBD5E1;
pointer-events: none;
}
.create-btn-text {
font-size: 30rpx;
color: #FFFFFF;
font-weight: 600;
}
</style>

View File

@@ -0,0 +1,541 @@
<!--
邀请入群页
设计系统design-system/echochat/MASTER.md
色板Primary #2563EB / BG #F8FAFC / Surface #F1F5F9 / Text #1E293B
功能好友列表多选排除已在群内的成员搜索过滤已选展示确认邀请
-->
<template>
<view class="page-wrapper">
<!-- 已选好友展示区 -->
<view v-if="selectedList.length > 0" class="section">
<view class="section-header">
<text class="section-title">已选好友{{ selectedList.length }}</text>
</view>
<scroll-view class="selected-scroll" scroll-x>
<view class="selected-list">
<view
v-for="friend in selectedList"
:key="friend.user_id"
class="selected-item"
@tap="toggleSelect(friend)"
>
<view class="selected-avatar" :style="{ backgroundColor: getAvatarColor(friend.nickname || friend.username) }">
<text class="selected-avatar-text">{{ getInitial(friend.nickname || friend.username) }}</text>
<view class="selected-remove">
<text class="selected-remove-text">×</text>
</view>
</view>
<text class="selected-name">{{ friend.remark || friend.nickname || friend.username }}</text>
</view>
</view>
</scroll-view>
</view>
<!-- 搜索好友 -->
<view class="section">
<view class="section-header">
<text class="section-title">选择好友</text>
</view>
<view class="search-bar">
<uni-icons type="search" size="18" color="#94A3B8" />
<input
class="search-input"
v-model="searchKeyword"
placeholder="搜索好友昵称"
placeholder-style="color: #94A3B8"
/>
<view v-if="searchKeyword" class="search-clear" @tap="searchKeyword = ''">
<uni-icons type="clear" size="16" color="#94A3B8" />
</view>
</view>
</view>
<!-- 好友列表 -->
<view v-if="loading" class="skeleton-list">
<view v-for="i in 5" :key="i" class="skeleton-item">
<view class="skeleton-check"></view>
<view class="skeleton-avatar"></view>
<view class="skeleton-info">
<view class="skeleton-line skeleton-line--name"></view>
</view>
</view>
</view>
<view v-else-if="availableFriends.length > 0" class="friend-list">
<view
v-for="friend in availableFriends"
:key="friend.user_id"
class="friend-item"
@tap="toggleSelect(friend)"
>
<view class="checkbox" :class="{ 'checkbox--checked': isSelected(friend.user_id) }">
<text v-if="isSelected(friend.user_id)" class="checkbox-icon"></text>
</view>
<view class="avatar" :style="{ backgroundColor: getAvatarColor(friend.nickname || friend.username) }">
<text class="avatar-text">{{ getInitial(friend.nickname || friend.username) }}</text>
</view>
<view class="friend-info">
<text class="friend-name">{{ friend.remark || friend.nickname || friend.username }}</text>
<text v-if="friend.remark" class="friend-account">{{ friend.nickname || friend.username }}</text>
</view>
</view>
</view>
<view v-else class="empty-state">
<text class="empty-text">{{ searchKeyword ? '未找到匹配的好友' : '没有可邀请的好友' }}</text>
</view>
<!-- 底部占位 -->
<view class="bottom-spacer"></view>
<!-- 底部固定邀请按钮 -->
<view class="bottom-bar">
<view
class="invite-btn"
:class="{ 'invite-btn--disabled': selectedList.length === 0 || submitting }"
@tap="handleInvite"
>
<text class="invite-btn-text">
{{ submitting ? '邀请中...' : `确认邀请${selectedList.length > 0 ? '' + selectedList.length + '人)' : ''}` }}
</text>
</view>
</view>
</view>
</template>
<script>
import { ref, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { useContactStore } from '@/store/contact'
import { useGroupStore } from '@/store/group'
import { getAvatarColor, getInitial } from '@/utils/avatar'
export default {
name: 'GroupInvite',
setup() {
const contactStore = useContactStore()
const groupStore = useGroupStore()
const groupId = ref(0)
const searchKeyword = ref('')
const selectedIds = ref({})
const loading = ref(true)
const submitting = ref(false)
/** 已在群内的成员 ID 集合 */
const memberIdSet = computed(() => {
const set = new Set()
for (const m of groupStore.currentMembers) {
set.add(m.user_id)
}
return set
})
/** 过滤掉已在群内的好友 + 搜索关键词过滤 */
const availableFriends = computed(() => {
const filtered = contactStore.friendList.filter(f => !memberIdSet.value.has(f.user_id))
const kw = searchKeyword.value.trim().toLowerCase()
if (!kw) return filtered
return filtered.filter(f => {
const nickname = (f.nickname || '').toLowerCase()
const username = (f.username || '').toLowerCase()
const remark = (f.remark || '').toLowerCase()
return nickname.includes(kw) || username.includes(kw) || remark.includes(kw)
})
})
/** 已选中的好友列表 */
const selectedList = computed(() => {
return contactStore.friendList.filter(f => selectedIds.value[f.user_id])
})
/** 判断好友是否已选中 */
const isSelected = (userId) => {
return !!selectedIds.value[userId]
}
/** 切换好友选中状态 */
const toggleSelect = (friend) => {
const newMap = { ...selectedIds.value }
if (newMap[friend.user_id]) {
delete newMap[friend.user_id]
} else {
newMap[friend.user_id] = true
}
selectedIds.value = newMap
}
/** 提交邀请 */
const handleInvite = async () => {
if (selectedList.value.length === 0 || submitting.value) return
submitting.value = true
try {
const userIds = selectedList.value.map(f => f.user_id)
await groupStore.inviteMembers(groupId.value, userIds)
uni.showToast({ title: '邀请成功', icon: 'success' })
setTimeout(() => {
uni.navigateBack()
}, 500)
} catch (e) {
console.error('邀请入群失败', e)
uni.showToast({
title: e?.data?.message || '邀请失败',
icon: 'none'
})
} finally {
submitting.value = false
}
}
// ==================== 生命周期 ====================
onLoad(async (query) => {
groupId.value = parseInt(query.groupId) || 0
try {
const tasks = [contactStore.fetchFriends()]
if (groupId.value) {
tasks.push(groupStore.fetchMembers(groupId.value))
}
await Promise.all(tasks)
} catch (e) {
console.error('加载数据失败', e)
}
loading.value = false
})
return {
searchKeyword,
selectedList,
availableFriends,
loading,
submitting,
isSelected,
toggleSelect,
handleInvite,
getAvatarColor,
getInitial
}
}
}
</script>
<style scoped>
.page-wrapper {
min-height: 100vh;
background-color: #F8FAFC;
padding-bottom: env(safe-area-inset-bottom);
}
/* 区块通用 */
.section {
margin-bottom: 16rpx;
}
.section-header {
padding: 20rpx 32rpx 12rpx;
}
.section-title {
font-size: 26rpx;
color: #94A3B8;
font-weight: 500;
}
/* 已选好友区 */
.selected-scroll {
white-space: nowrap;
background-color: #FFFFFF;
padding: 20rpx 32rpx;
}
.selected-list {
display: flex;
gap: 24rpx;
}
.selected-item {
display: flex;
flex-direction: column;
align-items: center;
width: 100rpx;
flex-shrink: 0;
}
.selected-avatar {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.selected-avatar-text {
font-size: 32rpx;
color: #FFFFFF;
font-weight: 600;
}
.selected-remove {
position: absolute;
top: -6rpx;
right: -6rpx;
width: 32rpx;
height: 32rpx;
border-radius: 50%;
background-color: #EF4444;
display: flex;
align-items: center;
justify-content: center;
}
.selected-remove-text {
font-size: 20rpx;
color: #FFFFFF;
font-weight: 600;
line-height: 1;
}
.selected-name {
font-size: 22rpx;
color: #475569;
margin-top: 8rpx;
max-width: 100rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: center;
}
/* 搜索栏 */
.search-bar {
display: flex;
align-items: center;
background-color: #FFFFFF;
padding: 0 24rpx;
height: 80rpx;
gap: 12rpx;
}
.search-input {
flex: 1;
font-size: 28rpx;
color: #1E293B;
height: 80rpx;
}
.search-clear {
flex-shrink: 0;
padding: 8rpx;
}
/* 骨架屏 */
.skeleton-list {
background-color: #FFFFFF;
}
.skeleton-item {
display: flex;
align-items: center;
padding: 20rpx 32rpx;
gap: 20rpx;
border-bottom: 1rpx solid #F1F5F9;
}
.skeleton-check {
width: 40rpx;
height: 40rpx;
border-radius: 8rpx;
background: linear-gradient(90deg, #F1F5F9 25%, #E2E8F0 50%, #F1F5F9 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
flex-shrink: 0;
}
.skeleton-avatar {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
background: linear-gradient(90deg, #F1F5F9 25%, #E2E8F0 50%, #F1F5F9 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
flex-shrink: 0;
}
.skeleton-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 10rpx;
}
.skeleton-line {
border-radius: 8rpx;
background: linear-gradient(90deg, #F1F5F9 25%, #E2E8F0 50%, #F1F5F9 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
.skeleton-line--name {
width: 40%;
height: 28rpx;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* 好友列表 */
.friend-list {
background-color: #FFFFFF;
}
.friend-item {
display: flex;
align-items: center;
padding: 20rpx 32rpx;
border-bottom: 1rpx solid #F1F5F9;
gap: 20rpx;
cursor: pointer;
transition: background-color 150ms ease;
}
.friend-item:active {
background-color: #F8FAFC;
}
.friend-item:last-child {
border-bottom: none;
}
/* 复选框 */
.checkbox {
width: 40rpx;
height: 40rpx;
border-radius: 8rpx;
border: 2rpx solid #CBD5E1;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all 200ms ease;
}
.checkbox--checked {
background-color: #2563EB;
border-color: #2563EB;
}
.checkbox-icon {
font-size: 24rpx;
color: #FFFFFF;
font-weight: 700;
line-height: 1;
}
/* 头像 */
.avatar {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.avatar-text {
font-size: 32rpx;
color: #FFFFFF;
font-weight: 600;
}
/* 好友信息 */
.friend-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 4rpx;
min-width: 0;
}
.friend-name {
font-size: 30rpx;
color: #1E293B;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.friend-account {
font-size: 24rpx;
color: #94A3B8;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 空状态 */
.empty-state {
padding: 80rpx 0;
display: flex;
align-items: center;
justify-content: center;
}
.empty-text {
font-size: 28rpx;
color: #94A3B8;
}
/* 底部占位 */
.bottom-spacer {
height: 140rpx;
}
/* 底部固定按钮 */
.bottom-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 16rpx 32rpx;
padding-bottom: calc(16rpx + env(safe-area-inset-bottom));
background-color: #FFFFFF;
box-shadow: 0 -2rpx 12rpx rgba(0, 0, 0, 0.04);
}
.invite-btn {
height: 88rpx;
border-radius: 16rpx;
background-color: #2563EB;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: opacity 200ms ease;
}
.invite-btn:active {
opacity: 0.85;
}
.invite-btn--disabled {
background-color: #CBD5E1;
pointer-events: none;
}
.invite-btn-text {
font-size: 30rpx;
color: #FFFFFF;
font-weight: 600;
}
</style>

View File

@@ -0,0 +1,515 @@
<!--
入群申请审批页
设计系统design-system/echochat/MASTER.md
色板Primary #2563EB / BG #F8FAFC / Surface #F1F5F9 / Text #1E293B
功能查看待审批/已处理的入群申请通过或拒绝操作
-->
<template>
<view class="page-wrapper">
<!-- 骨架屏 -->
<view v-if="loading" class="skeleton-list">
<view v-for="i in 4" :key="i" class="skeleton-item">
<view class="skeleton-avatar"></view>
<view class="skeleton-info">
<view class="skeleton-line skeleton-line--name"></view>
<view class="skeleton-line skeleton-line--msg"></view>
</view>
<view class="skeleton-actions">
<view class="skeleton-btn"></view>
<view class="skeleton-btn"></view>
</view>
</view>
</view>
<!-- 内容区域 -->
<view v-else>
<!-- 待审批区域 -->
<view v-if="pendingList.length > 0" class="section">
<view class="section-header">
<text class="section-title">待审批{{ pendingList.length }}</text>
</view>
<view class="request-list">
<view
v-for="req in pendingList"
:key="req.id"
class="request-item"
>
<image
v-if="req.avatar"
class="request-avatar"
:src="req.avatar"
mode="aspectFill"
/>
<view
v-else
class="request-avatar request-avatar-placeholder"
:style="{ backgroundColor: getAvatarColor(req.nickname || req.username) }"
>
<text class="avatar-char">{{ getInitial(req.nickname || req.username) }}</text>
</view>
<view class="request-info">
<text class="request-name">{{ req.nickname || req.username }}</text>
<text class="request-msg" v-if="req.message">{{ req.message }}</text>
<text class="request-msg" v-else>申请加入群聊</text>
<text class="request-time">{{ formatTime(req.created_at) }}</text>
</view>
<view class="request-actions">
<view
class="btn btn--approve"
:class="{ 'btn--disabled': processingMap[req.id] }"
@tap="handleApprove(req)"
>
<text class="btn-text">通过</text>
</view>
<view
class="btn btn--reject"
:class="{ 'btn--disabled': processingMap[req.id] }"
@tap="handleReject(req)"
>
<text class="btn-text btn-text--reject">拒绝</text>
</view>
</view>
</view>
</view>
</view>
<!-- 已处理区域 -->
<view v-if="processedList.length > 0" class="section">
<view class="section-header">
<text class="section-title">已处理{{ processedList.length }}</text>
</view>
<view class="request-list">
<view
v-for="req in processedList"
:key="req.id"
class="request-item"
>
<image
v-if="req.avatar"
class="request-avatar"
:src="req.avatar"
mode="aspectFill"
/>
<view
v-else
class="request-avatar request-avatar-placeholder"
:style="{ backgroundColor: getAvatarColor(req.nickname || req.username) }"
>
<text class="avatar-char">{{ getInitial(req.nickname || req.username) }}</text>
</view>
<view class="request-info">
<text class="request-name">{{ req.nickname || req.username }}</text>
<text class="request-msg" v-if="req.message">{{ req.message }}</text>
<text class="request-msg" v-else>申请加入群聊</text>
<text class="request-time">{{ formatTime(req.created_at) }}</text>
</view>
<view class="status-tag" :class="req.status === 1 ? 'status-tag--approved' : 'status-tag--rejected'">
<text class="status-tag-text" :class="req.status === 1 ? 'status-tag-text--approved' : 'status-tag-text--rejected'">
{{ req.status === 1 ? '已通过' : '已拒绝' }}
</text>
</view>
</view>
</view>
</view>
<!-- 空状态 -->
<view v-if="pendingList.length === 0 && processedList.length === 0" class="empty-state">
<uni-icons type="person" size="48" color="#CBD5E1" />
<text class="empty-title">暂无入群申请</text>
<text class="empty-desc">当有人申请加入群聊时会在这里显示</text>
</view>
</view>
</view>
</template>
<script>
import { onLoad, onUnload } from '@dcloudio/uni-app'
import { ref, computed, reactive } from 'vue'
import { useGroupStore } from '@/store/group'
import { getAvatarColor, getInitial } from '@/utils/avatar'
import wsService from '@/services/websocket'
export default {
name: 'GroupJoinRequests',
setup() {
const groupStore = useGroupStore()
const groupId = ref(0)
const loading = ref(true)
const processingMap = reactive({})
/** 本地维护的已处理申请(审批后从 store 移除但需要页面展示) */
const localProcessed = ref([])
/** 待审批列表status === 0 */
const pendingList = computed(() => {
return groupStore.joinRequests.filter(r => r.status === 0)
})
/**
* 已处理列表:合并后端返回的已处理申请 + 本地刚审批的
* 后端 fetch 可能已返回 status !== 0 的,加上本地审批后保存的
*/
const processedList = computed(() => {
const fromStore = groupStore.joinRequests.filter(r => r.status !== 0)
const processedIds = new Set(fromStore.map(r => r.id))
const fromLocal = localProcessed.value.filter(r => !processedIds.has(r.id))
return [...fromStore, ...fromLocal]
})
/** 格式化时间 */
const formatTime = (dateStr) => {
if (!dateStr) return ''
const date = new Date(dateStr)
const now = new Date()
const diff = now - date
const minutes = Math.floor(diff / 60000)
if (minutes < 1) return '刚刚'
if (minutes < 60) return `${minutes} 分钟前`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours} 小时前`
const days = Math.floor(hours / 24)
if (days < 7) return `${days} 天前`
return `${date.getMonth() + 1}/${date.getDate()}`
}
// ==================== 生命周期 ====================
/** 新入群申请到达时自动刷新列表 */
const _onNewJoinRequest = () => {
if (groupId.value) {
loadRequests()
}
}
onLoad((query) => {
groupId.value = parseInt(query.groupId) || 0
if (groupId.value) {
loadRequests()
} else {
loading.value = false
}
wsService.on('group.join.request', _onNewJoinRequest)
})
onUnload(() => {
wsService.off('group.join.request', _onNewJoinRequest)
})
/** 加载入群申请列表 */
const loadRequests = async () => {
loading.value = true
try {
await groupStore.fetchJoinRequests(groupId.value)
} catch (e) {
console.error('获取入群申请失败', e)
uni.showToast({ title: e?.data?.message || '获取申请列表失败', icon: 'none' })
}
loading.value = false
}
// ==================== 审批操作 ====================
/** 通过申请 */
const handleApprove = async (req) => {
if (processingMap[req.id]) return
processingMap[req.id] = true
try {
await groupStore.reviewJoinRequest(groupId.value, req.id, 'approve')
localProcessed.value.unshift({ ...req, status: 1 })
uni.showToast({ title: '已通过', icon: 'success' })
} catch (e) {
console.error('审批通过失败', e)
uni.showToast({ title: e?.data?.message || '操作失败', icon: 'none' })
} finally {
processingMap[req.id] = false
}
}
/** 拒绝申请 */
const handleReject = async (req) => {
if (processingMap[req.id]) return
processingMap[req.id] = true
try {
await groupStore.reviewJoinRequest(groupId.value, req.id, 'reject')
localProcessed.value.unshift({ ...req, status: 2 })
uni.showToast({ title: '已拒绝', icon: 'none' })
} catch (e) {
console.error('审批拒绝失败', e)
uni.showToast({ title: e?.data?.message || '操作失败', icon: 'none' })
} finally {
processingMap[req.id] = false
}
}
return {
loading,
pendingList,
processedList,
processingMap,
formatTime,
handleApprove,
handleReject,
getAvatarColor,
getInitial
}
}
}
</script>
<style scoped>
.page-wrapper {
min-height: 100vh;
background-color: #F8FAFC;
padding-bottom: env(safe-area-inset-bottom);
}
/* ===== 骨架屏 ===== */
.skeleton-list {
background-color: #FFFFFF;
}
.skeleton-item {
display: flex;
align-items: center;
padding: 24rpx 32rpx;
gap: 20rpx;
border-bottom: 1rpx solid #F1F5F9;
}
.skeleton-avatar {
width: 80rpx;
height: 80rpx;
border-radius: 20rpx;
background: linear-gradient(90deg, #F1F5F9 25%, #E2E8F0 50%, #F1F5F9 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
flex-shrink: 0;
}
.skeleton-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 10rpx;
}
.skeleton-line {
border-radius: 8rpx;
background: linear-gradient(90deg, #F1F5F9 25%, #E2E8F0 50%, #F1F5F9 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
.skeleton-line--name {
width: 40%;
height: 28rpx;
}
.skeleton-line--msg {
width: 60%;
height: 24rpx;
}
.skeleton-actions {
display: flex;
gap: 12rpx;
}
.skeleton-btn {
width: 100rpx;
height: 56rpx;
border-radius: 12rpx;
background: linear-gradient(90deg, #F1F5F9 25%, #E2E8F0 50%, #F1F5F9 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* ===== 区块 ===== */
.section {
margin-bottom: 16rpx;
}
.section-header {
padding: 20rpx 32rpx 12rpx;
}
.section-title {
font-size: 26rpx;
color: #94A3B8;
font-weight: 500;
}
/* ===== 申请列表 ===== */
.request-list {
background-color: #FFFFFF;
}
.request-item {
display: flex;
align-items: center;
padding: 24rpx 32rpx;
border-bottom: 1rpx solid #F1F5F9;
gap: 20rpx;
}
.request-item:last-child {
border-bottom: none;
}
/* ===== 头像 ===== */
.request-avatar {
width: 80rpx;
height: 80rpx;
border-radius: 20rpx;
flex-shrink: 0;
}
.request-avatar-placeholder {
display: flex;
align-items: center;
justify-content: center;
}
.avatar-char {
color: #FFFFFF;
font-size: 28rpx;
font-weight: 600;
}
/* ===== 申请信息 ===== */
.request-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 6rpx;
min-width: 0;
}
.request-name {
font-size: 30rpx;
color: #1E293B;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.request-msg {
font-size: 24rpx;
color: #64748B;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.request-time {
font-size: 22rpx;
color: #94A3B8;
}
/* ===== 操作按钮 ===== */
.request-actions {
display: flex;
flex-direction: column;
gap: 12rpx;
flex-shrink: 0;
}
.btn {
padding: 12rpx 28rpx;
border-radius: 12rpx;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: opacity 200ms ease;
}
.btn:active {
opacity: 0.7;
}
.btn--approve {
background-color: #22C55E;
}
.btn--reject {
background-color: #F1F5F9;
}
.btn--disabled {
opacity: 0.5;
pointer-events: none;
}
.btn-text {
font-size: 24rpx;
font-weight: 500;
color: #FFFFFF;
}
.btn-text--reject {
color: #EF4444;
}
/* ===== 状态标签 ===== */
.status-tag {
padding: 8rpx 20rpx;
border-radius: 12rpx;
flex-shrink: 0;
}
.status-tag--approved {
background-color: rgba(34, 197, 94, 0.08);
}
.status-tag--rejected {
background-color: rgba(239, 68, 68, 0.08);
}
.status-tag-text {
font-size: 24rpx;
font-weight: 500;
}
.status-tag-text--approved {
color: #22C55E;
}
.status-tag-text--rejected {
color: #EF4444;
}
/* ===== 空状态 ===== */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding-top: 300rpx;
gap: 16rpx;
}
.empty-title {
font-size: 32rpx;
color: #64748B;
font-weight: 500;
}
.empty-desc {
font-size: 26rpx;
color: #94A3B8;
text-align: center;
padding: 0 60rpx;
}
</style>

View File

@@ -0,0 +1,453 @@
<!--
群成员列表页
设计系统design-system/echochat/MASTER.md
色板Primary #2563EB / BG #F8FAFC / Surface #F1F5F9 / Text #1E293B
功能成员搜索角色标识禁言标识管理操作设管理员/禁言/踢人
-->
<template>
<view class="page-wrapper">
<!-- 搜索栏 -->
<view class="search-section">
<view class="search-bar">
<uni-icons type="search" size="18" color="#94A3B8" />
<input
class="search-input"
v-model="searchKeyword"
placeholder="搜索成员昵称"
placeholder-style="color: #94A3B8"
/>
<view v-if="searchKeyword" class="search-clear" @tap="searchKeyword = ''">
<uni-icons type="clear" size="16" color="#94A3B8" />
</view>
</view>
</view>
<!-- 成员数量标题 -->
<view class="section-header">
<text class="section-title">全部成员{{ filteredMembers.length }}</text>
</view>
<!-- 成员列表 -->
<view v-if="filteredMembers.length > 0" class="member-list">
<view
v-for="member in filteredMembers"
:key="member.user_id"
class="member-item"
@longpress="onMemberLongPress(member)"
>
<image
v-if="member.avatar"
class="member-avatar"
:src="member.avatar"
mode="aspectFill"
/>
<view
v-else
class="member-avatar member-avatar-placeholder"
:style="{ backgroundColor: getAvatarColor(member.nickname || member.username) }"
>
<text class="avatar-char">{{ getInitial(member.nickname || member.username) }}</text>
</view>
<view class="member-info">
<view class="member-name-row">
<text class="member-name">{{ member.nickname || member.username }}</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>
</view>
<view v-if="member.is_muted" class="muted-row">
<text class="muted-tag">已禁言</text>
</view>
</view>
<uni-icons
v-if="canManage(member)"
type="more-filled"
size="18"
color="#94A3B8"
/>
</view>
</view>
<view v-else class="empty-state">
<text class="empty-text">{{ searchKeyword ? '未找到匹配的成员' : '暂无成员' }}</text>
</view>
<!-- 右上角邀请按钮 -->
<view class="fab-btn" @tap="goToInvite">
<uni-icons type="plusempty" size="24" color="#FFFFFF" />
</view>
</view>
</template>
<script>
import { onLoad } from '@dcloudio/uni-app'
import { ref, computed } from 'vue'
import { useGroupStore } from '@/store/group'
import { useUserStore } from '@/store/user'
import { getAvatarColor, getInitial } from '@/utils/avatar'
import { GROUP_ROLE } from '@/constants/group'
export default {
name: 'GroupMembers',
setup() {
const groupStore = useGroupStore()
const userStore = useUserStore()
const groupId = ref(0)
const searchKeyword = ref('')
const currentMembers = computed(() => groupStore.currentMembers)
const myId = computed(() => Number(userStore.userInfo?.id) || 0)
/** 当前用户在群中的角色 */
const myRole = computed(() => {
const me = currentMembers.value.find(m => m.user_id === myId.value)
return me ? me.role : 0
})
/** 搜索过滤后的成员列表 */
const filteredMembers = computed(() => {
const kw = searchKeyword.value.trim().toLowerCase()
if (!kw) return currentMembers.value
return currentMembers.value.filter(m => {
const nickname = (m.nickname || '').toLowerCase()
const username = (m.username || '').toLowerCase()
return nickname.includes(kw) || username.includes(kw)
})
})
/**
* 判断当前用户是否可以管理某个成员
* - 群主可管理所有非群主成员
* - 管理员仅可管理普通成员
* - 普通成员无管理权限
* - 不能管理自己
*/
const canManage = (member) => {
if (member.user_id === myId.value) return false
if (myRole.value === GROUP_ROLE.OWNER) return member.role !== GROUP_ROLE.OWNER
if (myRole.value === GROUP_ROLE.ADMIN) return member.role === GROUP_ROLE.MEMBER
return false
}
// ==================== 生命周期 ====================
onLoad((query) => {
groupId.value = parseInt(query.groupId) || 0
if (groupId.value) {
groupStore.fetchMembers(groupId.value)
}
})
// ==================== 管理操作 ====================
const onMemberLongPress = (member) => {
if (!canManage(member)) return
const actions = []
if (myRole.value === GROUP_ROLE.OWNER) {
if (member.role === GROUP_ROLE.ADMIN) {
actions.push('取消管理员')
} else {
actions.push('设为管理员')
}
}
if (member.is_muted) {
actions.push('解除禁言')
} else {
actions.push('禁言')
}
actions.push('踢出群聊')
uni.showActionSheet({
itemList: actions,
success: (res) => {
const action = actions[res.tapIndex]
handleAction(action, member)
}
})
}
const handleAction = (action, member) => {
switch (action) {
case '设为管理员':
confirmSetRole(member, GROUP_ROLE.ADMIN)
break
case '取消管理员':
confirmSetRole(member, GROUP_ROLE.MEMBER)
break
case '禁言':
confirmMute(member, true)
break
case '解除禁言':
confirmMute(member, false)
break
case '踢出群聊':
confirmKick(member)
break
}
}
const confirmSetRole = (member, role) => {
const roleName = role === GROUP_ROLE.ADMIN ? '管理员' : '普通成员'
const name = member.nickname || member.username
uni.showModal({
title: '确认操作',
content: `确定将 ${name} 设为${roleName}吗?`,
success: async (res) => {
if (res.confirm) {
try {
await groupStore.setMemberRole(groupId.value, member.user_id, role)
uni.showToast({ title: '操作成功', icon: 'success' })
} catch (e) {
uni.showToast({ title: e?.data?.message || '操作失败', icon: 'none' })
}
}
}
})
}
const confirmMute = (member, isMuted) => {
const name = member.nickname || member.username
const tip = isMuted ? `确定禁言 ${name} 吗?` : `确定解除 ${name} 的禁言吗?`
uni.showModal({
title: '确认操作',
content: tip,
success: async (res) => {
if (res.confirm) {
try {
await groupStore.muteMember(groupId.value, member.user_id, isMuted)
uni.showToast({ title: '操作成功', icon: 'success' })
} catch (e) {
uni.showToast({ title: e?.data?.message || '操作失败', icon: 'none' })
}
}
}
})
}
const confirmKick = (member) => {
const name = member.nickname || member.username
uni.showModal({
title: '踢出群聊',
content: `确定将 ${name} 踢出群聊吗?`,
confirmColor: '#EF4444',
success: async (res) => {
if (res.confirm) {
try {
await groupStore.kickMember(groupId.value, member.user_id)
uni.showToast({ title: '已踢出', icon: 'success' })
} catch (e) {
uni.showToast({ title: e?.data?.message || '操作失败', icon: 'none' })
}
}
}
})
}
// ==================== 导航 ====================
const goToInvite = () => {
uni.navigateTo({
url: `/pages/group/invite?groupId=${groupId.value}`
})
}
return {
GROUP_ROLE,
searchKeyword,
filteredMembers,
canManage,
onMemberLongPress,
goToInvite,
getAvatarColor,
getInitial
}
}
}
</script>
<style scoped>
.page-wrapper {
min-height: 100vh;
background-color: #F8FAFC;
padding-bottom: env(safe-area-inset-bottom);
}
/* ===== 搜索栏 ===== */
.search-section {
background-color: #FFFFFF;
padding: 16rpx 24rpx;
border-bottom: 1rpx solid #E2E8F0;
}
.search-bar {
display: flex;
align-items: center;
background-color: #F1F5F9;
border-radius: 36rpx;
padding: 0 24rpx;
height: 72rpx;
gap: 12rpx;
}
.search-input {
flex: 1;
font-size: 28rpx;
color: #1E293B;
height: 72rpx;
}
.search-clear {
flex-shrink: 0;
padding: 8rpx;
}
/* ===== 区块标题 ===== */
.section-header {
padding: 20rpx 32rpx 12rpx;
}
.section-title {
font-size: 26rpx;
color: #94A3B8;
font-weight: 500;
}
/* ===== 成员列表 ===== */
.member-list {
background-color: #FFFFFF;
}
.member-item {
display: flex;
align-items: center;
padding: 20rpx 32rpx;
border-bottom: 1rpx solid #F1F5F9;
gap: 20rpx;
transition: background-color 150ms ease;
}
.member-item:active {
background-color: #F8FAFC;
}
.member-item:last-child {
border-bottom: none;
}
/* ===== 头像 ===== */
.member-avatar {
width: 80rpx;
height: 80rpx;
border-radius: 20rpx;
flex-shrink: 0;
}
.member-avatar-placeholder {
display: flex;
align-items: center;
justify-content: center;
}
.avatar-char {
color: #FFFFFF;
font-size: 28rpx;
font-weight: 600;
}
/* ===== 成员信息 ===== */
.member-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 6rpx;
min-width: 0;
}
.member-name-row {
display: flex;
align-items: center;
gap: 12rpx;
}
.member-name {
font-size: 30rpx;
color: #1E293B;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ===== 角色标签 ===== */
.role-tag {
font-size: 22rpx;
padding: 4rpx 12rpx;
border-radius: 8rpx;
flex-shrink: 0;
line-height: 1.4;
}
.role-tag-owner {
color: #D97706;
background-color: rgba(217, 119, 6, 0.08);
}
.role-tag-admin {
color: #2563EB;
background-color: rgba(37, 99, 235, 0.08);
}
/* ===== 禁言标签 ===== */
.muted-row {
display: flex;
align-items: center;
}
.muted-tag {
font-size: 22rpx;
color: #EF4444;
background-color: rgba(239, 68, 68, 0.08);
padding: 2rpx 12rpx;
border-radius: 6rpx;
}
/* ===== 空状态 ===== */
.empty-state {
padding: 80rpx 0;
display: flex;
align-items: center;
justify-content: center;
}
.empty-text {
font-size: 28rpx;
color: #94A3B8;
}
/* ===== 悬浮按钮 ===== */
.fab-btn {
position: fixed;
right: 32rpx;
bottom: calc(64rpx + env(safe-area-inset-bottom));
width: 96rpx;
height: 96rpx;
border-radius: 50%;
background-color: #2563EB;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 24rpx rgba(37, 99, 235, 0.3);
transition: opacity 200ms ease;
}
.fab-btn:active {
opacity: 0.85;
}
</style>

View File

@@ -0,0 +1,466 @@
<!--
搜索群聊页
设计系统design-system/echochat/MASTER.md
色板Primary #2563EB / BG #F8FAFC / Surface #F1F5F9 / Text #1E293B
功能搜索公开群聊查看群信息申请加入群聊
-->
<template>
<view class="page-wrapper">
<!-- 搜索栏 -->
<view class="search-bar">
<view class="search-input-wrap">
<uni-icons type="search" size="18" color="#94A3B8" />
<input
class="search-input"
v-model="keyword"
placeholder="输入群名称搜索"
placeholder-style="color: #94A3B8"
confirm-type="search"
@confirm="doSearch"
/>
<view v-if="keyword" class="search-clear" @tap="keyword = ''">
<uni-icons type="clear" size="16" color="#94A3B8" />
</view>
</view>
<view class="search-btn" @tap="doSearch">
<text class="search-btn-text">搜索</text>
</view>
</view>
<!-- 搜索结果 -->
<view v-if="searched" class="result-section">
<view class="section-header">
<text class="section-title">搜索结果</text>
</view>
<!-- 加载中 -->
<view v-if="searchLoading && page === 1" class="loading-wrap">
<text class="loading-text">搜索中...</text>
</view>
<!-- 结果列表 -->
<scroll-view
v-else-if="resultList.length > 0"
class="result-scroll"
scroll-y
@scrolltolower="loadMore"
>
<view class="result-list">
<view
v-for="group in resultList"
:key="group.id"
class="group-item"
>
<image
v-if="group.avatar"
class="group-avatar"
:src="group.avatar"
mode="aspectFill"
/>
<view
v-else
class="group-avatar group-avatar-placeholder"
:style="{ backgroundColor: getAvatarColor(group.name) }"
>
<text class="avatar-char">{{ getInitial(group.name) }}</text>
</view>
<view class="group-info">
<text class="group-name">{{ group.name }}</text>
<text class="group-meta">{{ group.member_count || 0 }} 名成员</text>
<text v-if="group.description" class="group-desc">{{ group.description }}</text>
</view>
<view
class="action-btn action-btn--apply"
:class="{ 'action-btn--disabled': appliedMap[group.id] }"
@tap="showApplyDialog(group)"
>
<text class="action-btn-text action-btn-text--apply">
{{ appliedMap[group.id] ? '已申请' : '申请加入' }}
</text>
</view>
</view>
</view>
<!-- 底部加载状态 -->
<view class="load-more-wrap">
<text v-if="loadingMore" class="load-more-text">加载中...</text>
<text v-else-if="noMore" class="load-more-text">没有更多了</text>
</view>
</scroll-view>
<!-- 无结果 -->
<view v-else class="empty-state">
<uni-icons type="search" size="48" color="#CBD5E1" />
<text class="empty-text">未找到相关群聊</text>
</view>
</view>
<!-- 未搜索提示 -->
<view v-else class="hint-section">
<uni-icons type="search" size="48" color="#CBD5E1" />
<text class="hint-text">输入关键词搜索群聊</text>
</view>
</view>
</template>
<script>
import { onLoad } from '@dcloudio/uni-app'
import { ref, computed, reactive } from 'vue'
import { useGroupStore } from '@/store/group'
import { getAvatarColor, getInitial } from '@/utils/avatar'
export default {
name: 'GroupSearch',
setup() {
const groupStore = useGroupStore()
const keyword = ref('')
const searched = ref(false)
const searchLoading = ref(false)
const loadingMore = ref(false)
const page = ref(1)
const pageSize = 20
const appliedMap = reactive({})
/** 搜索结果列表 */
const resultList = computed(() => groupStore.searchResults)
/** 是否还有更多数据 */
const noMore = computed(() => {
return resultList.value.length >= groupStore.searchTotal && groupStore.searchTotal > 0
})
// ==================== 生命周期 ====================
onLoad(() => {
groupStore.searchResults = []
groupStore.searchTotal = 0
})
// ==================== 搜索逻辑 ====================
/** 执行搜索 */
const doSearch = async () => {
const kw = keyword.value.trim()
if (!kw) {
uni.showToast({ title: '请输入搜索关键词', icon: 'none' })
return
}
searched.value = true
searchLoading.value = true
page.value = 1
try {
await groupStore.searchGroups(kw, 1, pageSize)
} catch (e) {
console.error('搜索群聊失败', e)
uni.showToast({ title: e?.data?.message || '搜索失败', icon: 'none' })
}
searchLoading.value = false
}
/** 滚动加载更多 */
const loadMore = async () => {
if (loadingMore.value || noMore.value || !searched.value) return
loadingMore.value = true
page.value += 1
try {
await groupStore.searchGroups(keyword.value.trim(), page.value, pageSize, true)
} catch (e) {
console.error('加载更多群聊失败', e)
page.value -= 1
}
loadingMore.value = false
}
// ==================== 申请加入 ====================
/** 弹出申请附言输入框 */
const showApplyDialog = (group) => {
if (appliedMap[group.id]) return
uni.showModal({
title: `申请加入「${group.name}`,
editable: true,
placeholderText: '请输入申请附言(可选)',
content: '',
success: async (res) => {
if (res.confirm) {
try {
await groupStore.submitJoinRequest(group.id, res.content || '')
appliedMap[group.id] = true
uni.showToast({ title: '申请已发送', icon: 'success' })
} catch (e) {
console.error('发送入群申请失败', e)
uni.showToast({ title: e?.data?.message || '申请失败', icon: 'none' })
}
}
}
})
}
return {
keyword,
searched,
searchLoading,
loadingMore,
page,
resultList,
noMore,
appliedMap,
doSearch,
loadMore,
showApplyDialog,
getAvatarColor,
getInitial
}
}
}
</script>
<style scoped>
.page-wrapper {
min-height: 100vh;
background-color: #F8FAFC;
padding-bottom: env(safe-area-inset-bottom);
}
/* ===== 搜索栏 ===== */
.search-bar {
display: flex;
align-items: center;
padding: 16rpx 24rpx;
background-color: #FFFFFF;
gap: 16rpx;
border-bottom: 1rpx solid #E2E8F0;
}
.search-input-wrap {
flex: 1;
background-color: #F1F5F9;
border-radius: 36rpx;
padding: 0 24rpx;
height: 72rpx;
display: flex;
align-items: center;
gap: 12rpx;
}
.search-input {
flex: 1;
font-size: 28rpx;
color: #1E293B;
height: 72rpx;
}
.search-clear {
flex-shrink: 0;
padding: 8rpx;
}
.search-btn {
padding: 0 28rpx;
height: 72rpx;
background-color: #2563EB;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: opacity 200ms ease;
}
.search-btn:active {
opacity: 0.8;
}
.search-btn-text {
font-size: 28rpx;
color: #FFFFFF;
font-weight: 500;
}
/* ===== 区块标题 ===== */
.section-header {
padding: 20rpx 32rpx;
}
.section-title {
font-size: 26rpx;
color: #94A3B8;
font-weight: 500;
}
/* ===== 加载状态 ===== */
.loading-wrap {
padding: 80rpx 0;
display: flex;
align-items: center;
justify-content: center;
}
.loading-text {
font-size: 28rpx;
color: #94A3B8;
}
/* ===== 搜索结果滚动区域 ===== */
.result-scroll {
height: calc(100vh - 200rpx);
}
/* ===== 结果列表 ===== */
.result-list {
background-color: #FFFFFF;
}
.group-item {
display: flex;
align-items: center;
padding: 24rpx 32rpx;
border-bottom: 1rpx solid #F1F5F9;
gap: 20rpx;
}
.group-item:last-child {
border-bottom: none;
}
/* ===== 群头像 ===== */
.group-avatar {
width: 88rpx;
height: 88rpx;
border-radius: 24rpx;
flex-shrink: 0;
}
.group-avatar-placeholder {
display: flex;
align-items: center;
justify-content: center;
}
.avatar-char {
color: #FFFFFF;
font-size: 32rpx;
font-weight: 600;
}
/* ===== 群信息 ===== */
.group-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 6rpx;
min-width: 0;
}
.group-name {
font-size: 30rpx;
color: #1E293B;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.group-meta {
font-size: 24rpx;
color: #94A3B8;
}
.group-desc {
font-size: 24rpx;
color: #64748B;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ===== 操作按钮 ===== */
.action-btn {
padding: 12rpx 24rpx;
border-radius: 12rpx;
flex-shrink: 0;
cursor: pointer;
transition: opacity 200ms ease;
}
.action-btn:active {
opacity: 0.8;
}
.action-btn--apply {
border: 2rpx solid #2563EB;
background-color: transparent;
}
.action-btn--disabled {
border-color: #CBD5E1;
pointer-events: none;
}
.action-btn-text {
font-size: 24rpx;
font-weight: 500;
}
.action-btn-text--apply {
color: #2563EB;
}
.action-btn--disabled .action-btn-text--apply {
color: #94A3B8;
}
/* ===== 加载更多 ===== */
.load-more-wrap {
padding: 24rpx 0 40rpx;
display: flex;
align-items: center;
justify-content: center;
}
.load-more-text {
font-size: 24rpx;
color: #94A3B8;
}
/* ===== 空状态 ===== */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding-top: 200rpx;
gap: 16rpx;
}
.empty-text {
font-size: 28rpx;
color: #94A3B8;
}
/* ===== 未搜索提示 ===== */
.hint-section {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding-top: 300rpx;
gap: 16rpx;
}
.hint-text {
font-size: 28rpx;
color: #94A3B8;
}
</style>

View File

@@ -0,0 +1,538 @@
<!--
群设置页
设计系统design-system/echochat/MASTER.md
色板Primary #2563EB / BG #F8FAFC / Surface #F1F5F9 / Text #1E293B
功能群信息展示/编辑成员概览免打扰解散/退出群聊
-->
<template>
<view class="page-wrapper">
<!-- 群头像 + 群名称 -->
<view class="group-card">
<view class="group-avatar-wrap" @tap="onChangeAvatar">
<image v-if="groupAvatar" class="group-avatar" :src="groupAvatar" mode="aspectFill" />
<view v-else class="group-avatar group-avatar-placeholder" :style="{ backgroundColor: getAvatarColor(groupName) }">
<text class="avatar-text">{{ getInitial(groupName) }}</text>
</view>
<view v-if="isOwnerOrAdmin" class="avatar-edit-badge">
<uni-icons type="camera-filled" size="14" color="#FFFFFF" />
</view>
</view>
<text class="group-name">{{ groupName || '群聊' }}</text>
<text class="group-id">群ID: {{ groupId }}</text>
</view>
<!-- 群公告 -->
<view class="settings-group">
<view class="settings-item" @tap="onEditAnnouncement">
<text class="settings-label">群公告</text>
<view class="settings-right">
<text class="settings-value settings-value-ellipsis">{{ currentGroup?.notice || '暂无公告' }}</text>
<uni-icons v-if="isOwnerOrAdmin" type="forward" size="16" color="#94A3B8" />
</view>
</view>
</view>
<!-- 成员概览 -->
<view class="settings-group">
<view class="section-header">
<text class="section-title">群成员{{ currentMembers.length }}</text>
<view class="section-action" @tap="goToMembers">
<text class="section-action-text">查看全部</text>
<uni-icons type="forward" size="14" color="#2563EB" />
</view>
</view>
<scroll-view class="member-preview-scroll" scroll-x>
<view class="member-preview-list">
<view
v-for="member in previewMembers"
:key="member.user_id"
class="member-preview-item"
>
<image
v-if="member.avatar"
class="member-preview-avatar"
:src="member.avatar"
mode="aspectFill"
/>
<view
v-else
class="member-preview-avatar member-preview-avatar-placeholder"
:style="{ backgroundColor: getAvatarColor(member.nickname || member.username) }"
>
<text class="member-preview-char">{{ getInitial(member.nickname || member.username) }}</text>
</view>
<text class="member-preview-name">{{ member.nickname || member.username }}</text>
</view>
</view>
</scroll-view>
</view>
<!-- 功能区 -->
<view class="settings-group">
<view class="settings-item" @tap="onEditGroupName">
<text class="settings-label">群名称</text>
<view class="settings-right">
<text class="settings-value">{{ groupName }}</text>
<uni-icons v-if="isOwnerOrAdmin" type="forward" size="16" color="#94A3B8" />
</view>
</view>
<view class="settings-item" @tap="onEditNickname">
<text class="settings-label">我在本群的昵称</text>
<view class="settings-right">
<text class="settings-value">{{ myNickname || '未设置' }}</text>
<uni-icons type="forward" size="16" color="#94A3B8" />
</view>
</view>
<view class="settings-item">
<text class="settings-label">消息免打扰</text>
<switch
:checked="isDoNotDisturb"
color="#2563EB"
style="transform: scale(0.8);"
@change="onToggleDND"
/>
</view>
</view>
<!-- 危险操作区 -->
<view class="settings-group">
<view v-if="isOwner" class="settings-item settings-danger" @tap="onDissolve">
<text class="settings-label-danger">解散群聊</text>
</view>
<view v-else class="settings-item settings-danger" @tap="onLeave">
<text class="settings-label-danger">退出群聊</text>
</view>
</view>
</view>
</template>
<script>
import { onLoad } from '@dcloudio/uni-app'
import { ref, computed } from 'vue'
import { useGroupStore } from '@/store/group'
import { useUserStore } from '@/store/user'
import { getAvatarColor, getInitial } from '@/utils/avatar'
import { GROUP_ROLE } from '@/constants/group'
export default {
name: 'GroupSettings',
setup() {
const groupStore = useGroupStore()
const userStore = useUserStore()
const groupId = ref(0)
const conversationId = ref(0)
const groupName = ref('')
const groupAvatar = ref('')
const isDoNotDisturb = ref(false)
const currentGroup = computed(() => groupStore.currentGroup)
const currentMembers = computed(() => groupStore.currentMembers)
const myId = computed(() => Number(userStore.userInfo?.id) || 0)
/** 成员概览:最多显示前 5 个 */
const previewMembers = computed(() => {
return currentMembers.value.slice(0, 5)
})
/** 当前用户在群中的成员信息 */
const myMember = computed(() => {
return currentMembers.value.find(m => m.user_id === myId.value)
})
/** 我在本群的昵称 */
const myNickname = computed(() => {
return myMember.value?.nickname || ''
})
/** 当前用户是否为群主 */
const isOwner = computed(() => {
return myMember.value?.role === GROUP_ROLE.OWNER
})
/** 当前用户是否为群主或管理员 */
const isOwnerOrAdmin = computed(() => {
const role = myMember.value?.role
return role === GROUP_ROLE.OWNER || role === GROUP_ROLE.ADMIN
})
// ==================== 生命周期 ====================
onLoad((query) => {
groupId.value = parseInt(query.groupId) || 0
conversationId.value = parseInt(query.conversationId) || 0
groupName.value = decodeURIComponent(query.peerName || '')
groupAvatar.value = decodeURIComponent(query.peerAvatar || '')
if (groupId.value) {
groupStore.fetchGroupDetail(groupId.value).then((detail) => {
if (detail) {
groupName.value = detail.name || groupName.value
groupAvatar.value = detail.avatar || groupAvatar.value
}
})
groupStore.fetchMembers(groupId.value)
}
})
// ==================== 编辑群名称 ====================
const onEditGroupName = () => {
if (!isOwnerOrAdmin.value) return
uni.showModal({
title: '修改群名称',
editable: true,
placeholderText: '请输入新群名称',
content: groupName.value,
success: async (res) => {
if (res.confirm && res.content && res.content.trim()) {
try {
await groupStore.updateGroup(groupId.value, { name: res.content.trim() })
groupName.value = res.content.trim()
uni.showToast({ title: '修改成功', icon: 'success' })
} catch (e) {
uni.showToast({ title: e?.data?.message || '修改失败', icon: 'none' })
}
}
}
})
}
// ==================== 编辑群公告 ====================
const onEditAnnouncement = () => {
if (!isOwnerOrAdmin.value) return
uni.showModal({
title: '修改群公告',
editable: true,
placeholderText: '请输入群公告',
content: currentGroup.value?.notice || '',
success: async (res) => {
if (res.confirm) {
try {
await groupStore.updateGroup(groupId.value, { notice: res.content || '' })
uni.showToast({ title: '修改成功', icon: 'success' })
} catch (e) {
uni.showToast({ title: e?.data?.message || '修改失败', icon: 'none' })
}
}
}
})
}
// ==================== 修改群头像(占位,仅群主/管理员) ====================
const onChangeAvatar = () => {
if (!isOwnerOrAdmin.value) return
uni.showToast({ title: '头像修改功能即将开放', icon: 'none' })
}
// ==================== 修改我的群昵称 ====================
const onEditNickname = () => {
uni.showModal({
title: '修改群昵称',
editable: true,
placeholderText: '请输入群昵称',
content: myNickname.value,
success: async (res) => {
if (res.confirm && res.content !== undefined) {
try {
await groupStore.updateNickname(groupId.value, res.content.trim())
groupStore.fetchMembers(groupId.value)
uni.showToast({ title: '修改成功', icon: 'success' })
} catch (e) {
uni.showToast({ title: e?.data?.message || '修改失败', icon: 'none' })
}
}
}
})
}
// ==================== 免打扰 ====================
const onToggleDND = async (e) => {
const newVal = e.detail.value
try {
await groupStore.setDoNotDisturb(conversationId.value, newVal)
isDoNotDisturb.value = newVal
} catch {
isDoNotDisturb.value = !newVal
uni.showToast({ title: '设置失败', icon: 'none' })
}
}
// ==================== 解散群聊(仅群主) ====================
const onDissolve = () => {
uni.showModal({
title: '解散群聊',
content: '确定要解散该群聊吗?此操作不可撤回,所有群成员将被移除。',
confirmColor: '#EF4444',
success: async (res) => {
if (res.confirm) {
try {
await groupStore.dissolveGroup(groupId.value)
uni.showToast({ title: '群聊已解散', icon: 'success' })
setTimeout(() => {
uni.switchTab({ url: '/pages/chat/index' })
}, 500)
} catch (e) {
uni.showToast({ title: e?.data?.message || '解散失败', icon: 'none' })
}
}
}
})
}
// ==================== 退出群聊(非群主) ====================
const onLeave = () => {
uni.showModal({
title: '退出群聊',
content: '确定要退出该群聊吗?退出后将不再接收该群消息。',
confirmColor: '#EF4444',
success: async (res) => {
if (res.confirm) {
try {
await groupStore.leaveGroup(groupId.value)
uni.showToast({ title: '已退出群聊', icon: 'success' })
setTimeout(() => {
uni.switchTab({ url: '/pages/chat/index' })
}, 500)
} catch (e) {
uni.showToast({ title: e?.data?.message || '退出失败', icon: 'none' })
}
}
}
})
}
// ==================== 导航 ====================
const goToMembers = () => {
uni.navigateTo({
url: `/pages/group/members?groupId=${groupId.value}`
})
}
return {
groupId,
groupName,
groupAvatar,
isDoNotDisturb,
currentGroup,
currentMembers,
previewMembers,
myNickname,
isOwner,
isOwnerOrAdmin,
onEditGroupName,
onEditAnnouncement,
onChangeAvatar,
onEditNickname,
onToggleDND,
onDissolve,
onLeave,
goToMembers,
getAvatarColor,
getInitial
}
}
}
</script>
<style scoped>
.page-wrapper {
min-height: 100vh;
background-color: #F1F5F9;
}
/* ===== 群信息卡片 ===== */
.group-card {
display: flex;
flex-direction: column;
align-items: center;
padding: 48rpx 32rpx;
background-color: #FFFFFF;
margin-bottom: 16rpx;
}
.group-avatar-wrap {
position: relative;
margin-bottom: 20rpx;
}
.group-avatar {
width: 120rpx;
height: 120rpx;
border-radius: 30rpx;
}
.group-avatar-placeholder {
display: flex;
align-items: center;
justify-content: center;
}
.avatar-text {
color: #FFFFFF;
font-size: 48rpx;
font-weight: 600;
}
.avatar-edit-badge {
position: absolute;
bottom: -4rpx;
right: -4rpx;
width: 40rpx;
height: 40rpx;
border-radius: 50%;
background-color: #2563EB;
display: flex;
align-items: center;
justify-content: center;
border: 3rpx solid #FFFFFF;
}
.group-name {
font-size: 34rpx;
font-weight: 600;
color: #1E293B;
margin-bottom: 8rpx;
}
.group-id {
font-size: 24rpx;
color: #94A3B8;
}
/* ===== 区块通用 ===== */
.settings-group {
background-color: #FFFFFF;
margin-bottom: 16rpx;
}
.settings-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 32rpx;
min-height: 88rpx;
border-bottom: 1rpx solid #F1F5F9;
transition: background-color 150ms ease;
}
.settings-item:last-child {
border-bottom: none;
}
.settings-item:active {
background-color: #F8FAFC;
}
.settings-label {
font-size: 30rpx;
color: #1E293B;
flex-shrink: 0;
}
.settings-right {
display: flex;
align-items: center;
gap: 8rpx;
min-width: 0;
flex: 1;
justify-content: flex-end;
}
.settings-value {
font-size: 26rpx;
color: #94A3B8;
}
.settings-value-ellipsis {
max-width: 400rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.settings-label-danger {
font-size: 30rpx;
color: #EF4444;
}
/* ===== 成员概览区 ===== */
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx 32rpx 12rpx;
}
.section-title {
font-size: 26rpx;
color: #94A3B8;
font-weight: 500;
}
.section-action {
display: flex;
align-items: center;
gap: 4rpx;
}
.section-action-text {
font-size: 24rpx;
color: #2563EB;
}
.member-preview-scroll {
white-space: nowrap;
padding: 16rpx 32rpx 24rpx;
}
.member-preview-list {
display: flex;
gap: 24rpx;
}
.member-preview-item {
display: flex;
flex-direction: column;
align-items: center;
width: 96rpx;
flex-shrink: 0;
}
.member-preview-avatar {
width: 80rpx;
height: 80rpx;
border-radius: 20rpx;
}
.member-preview-avatar-placeholder {
display: flex;
align-items: center;
justify-content: center;
}
.member-preview-char {
color: #FFFFFF;
font-size: 28rpx;
font-weight: 600;
}
.member-preview-name {
font-size: 22rpx;
color: #475569;
margin-top: 8rpx;
max-width: 96rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: center;
}
</style>