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:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
173
frontend/src/pages/chat/read-detail.vue
Normal file
173
frontend/src/pages/chat/read-detail.vue
Normal 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>
|
||||
736
frontend/src/pages/group/conversation.vue
Normal file
736
frontend/src/pages/group/conversation.vue
Normal 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>
|
||||
576
frontend/src/pages/group/create.vue
Normal file
576
frontend/src/pages/group/create.vue
Normal 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>
|
||||
541
frontend/src/pages/group/invite.vue
Normal file
541
frontend/src/pages/group/invite.vue
Normal 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>
|
||||
515
frontend/src/pages/group/join-requests.vue
Normal file
515
frontend/src/pages/group/join-requests.vue
Normal 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>
|
||||
453
frontend/src/pages/group/members.vue
Normal file
453
frontend/src/pages/group/members.vue
Normal 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>
|
||||
466
frontend/src/pages/group/search.vue
Normal file
466
frontend/src/pages/group/search.vue
Normal 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>
|
||||
538
frontend/src/pages/group/settings.vue
Normal file
538
frontend/src/pages/group/settings.vue
Normal 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>
|
||||
Reference in New Issue
Block a user