群聊
This commit is contained in:
390
src/components/chat/GroupChatPanel.vue
Normal file
390
src/components/chat/GroupChatPanel.vue
Normal file
@@ -0,0 +1,390 @@
|
||||
<template>
|
||||
<div class="w-80 bg-[#1e293b] border-l border-gray-800 flex flex-col h-full">
|
||||
<!-- 群名称区域 -->
|
||||
<div class="border-b border-gray-800 p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h3 class="text-sm font-semibold text-white">群名称</h3>
|
||||
<button
|
||||
v-if="canEditGroupName && !isEditingGroupName"
|
||||
class="text-xs text-primary hover:text-primary/80 transition"
|
||||
@click="startEditGroupName"
|
||||
>
|
||||
<i class="fas fa-edit mr-1"></i>编辑
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="!isEditingGroupName" class="min-h-[40px]">
|
||||
<p v-if="groupName" class="text-base font-medium text-white">
|
||||
{{ groupName }}
|
||||
</p>
|
||||
<p v-else class="text-sm text-gray-500 italic">
|
||||
暂无群名称
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-2">
|
||||
<input
|
||||
v-model="editGroupNameText"
|
||||
type="text"
|
||||
class="w-full bg-gray-800 text-white text-sm rounded-lg p-2 border border-gray-700 focus:border-primary focus:outline-none"
|
||||
placeholder="请输入群名称..."
|
||||
maxlength="20"
|
||||
/>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="flex-1 py-1.5 bg-primary text-white text-xs rounded-lg hover:bg-primary/80 transition"
|
||||
@click="saveGroupName"
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 py-1.5 bg-gray-700 text-gray-300 text-xs rounded-lg hover:bg-gray-600 transition"
|
||||
@click="cancelEditGroupName"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 群公告区域 -->
|
||||
<div class="border-b border-gray-800 p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="text-sm font-semibold text-white">群公告</h3>
|
||||
<button
|
||||
v-if="canEditAnnouncement && !isEditingAnnouncement"
|
||||
class="text-xs text-primary hover:text-primary/80 transition"
|
||||
@click="startEditAnnouncement"
|
||||
>
|
||||
<i class="fas fa-edit mr-1"></i>编辑
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="!isEditingAnnouncement" class="min-h-[60px]">
|
||||
<p v-if="announcement" class="text-sm text-gray-300 whitespace-pre-wrap">
|
||||
{{ announcement }}
|
||||
</p>
|
||||
<p v-else class="text-sm text-gray-500 italic">
|
||||
暂无群公告
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-2">
|
||||
<textarea
|
||||
v-model="editAnnouncementText"
|
||||
class="w-full bg-gray-800 text-white text-sm rounded-lg p-2 border border-gray-700 focus:border-primary focus:outline-none resize-none"
|
||||
rows="4"
|
||||
placeholder="请输入群公告内容..."
|
||||
></textarea>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="flex-1 py-1.5 bg-primary text-white text-xs rounded-lg hover:bg-primary/80 transition"
|
||||
@click="saveAnnouncement"
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 py-1.5 bg-gray-700 text-gray-300 text-xs rounded-lg hover:bg-gray-600 transition"
|
||||
@click="cancelEditAnnouncement"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 群成员列表 -->
|
||||
<div class="flex-1 overflow-y-auto custom-scrollbar p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="text-sm font-semibold text-white">群成员 ({{ members.length }})</h3>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingMembers" class="text-center py-8 text-gray-500 text-xs">
|
||||
<i class="fas fa-spinner fa-spin mr-2"></i>
|
||||
加载中...
|
||||
</div>
|
||||
|
||||
<div v-else-if="members.length === 0" class="text-center py-8 text-gray-500 text-xs">
|
||||
暂无成员
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-2">
|
||||
<div
|
||||
v-for="member in members"
|
||||
:key="member.user_id"
|
||||
class="flex items-center gap-3 p-2 rounded-lg hover:bg-white/5 transition cursor-pointer group"
|
||||
@click="handleMemberClick(member)"
|
||||
>
|
||||
<div class="relative shrink-0">
|
||||
<Avatar
|
||||
:name="member.user?.name || member.nickname || '未知'"
|
||||
:avatar="member.user?.avatar"
|
||||
size="sm"
|
||||
rounded="full"
|
||||
/>
|
||||
<!-- 在线状态指示器 -->
|
||||
<div
|
||||
v-if="member.user?.is_online"
|
||||
class="absolute -bottom-0.5 -right-0.5 w-3 h-3 bg-emerald-500 rounded-full border-2 border-[#1e293b]"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-white truncate">
|
||||
{{ member.user?.name || member.nickname || '未知' }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500">
|
||||
<span v-if="member.role === 2" class="text-yellow-400">群主</span>
|
||||
<span v-else-if="member.role === 1" class="text-blue-400">管理员</span>
|
||||
<span v-else>成员</span>
|
||||
<span v-if="member.user?.is_online" class="ml-2 text-emerald-400">在线</span>
|
||||
<span v-else class="ml-2 text-gray-500">离线</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 成员操作按钮(仅群主/管理员可见) -->
|
||||
<div
|
||||
v-if="canManageMember(member)"
|
||||
class="opacity-0 group-hover:opacity-100 transition"
|
||||
>
|
||||
<button
|
||||
v-if="member.role !== 2"
|
||||
class="text-gray-400 hover:text-red-400 transition text-sm p-1"
|
||||
@click.stop="handleRemoveMember(member)"
|
||||
title="移除成员"
|
||||
>
|
||||
<i class="fas fa-user-minus"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import Avatar from '@/components/common/Avatar.vue'
|
||||
import * as groupApi from '@/api/modules/room'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import type { GroupMember } from '@/types/api'
|
||||
|
||||
interface Props {
|
||||
roomId: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'member-removed': []
|
||||
'member-clicked': [member: GroupMember]
|
||||
'group-updated': []
|
||||
}>()
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const toastStore = useToastStore()
|
||||
|
||||
const loadingMembers = ref(false)
|
||||
const members = ref<GroupMember[]>([])
|
||||
const announcement = ref('')
|
||||
const isEditingAnnouncement = ref(false)
|
||||
const editAnnouncementText = ref('')
|
||||
const loadingAnnouncement = ref(false)
|
||||
const groupName = ref('')
|
||||
const isEditingGroupName = ref(false)
|
||||
const editGroupNameText = ref('')
|
||||
const loadingGroupName = ref(false)
|
||||
|
||||
// 当前用户角色
|
||||
const currentUserRole = computed(() => {
|
||||
if (!authStore.user) return -1
|
||||
const member = members.value.find(m => m.user_id === authStore.user!.id)
|
||||
return member?.role ?? -1
|
||||
})
|
||||
|
||||
// 是否为群主
|
||||
const isOwner = computed(() => currentUserRole.value === 2)
|
||||
|
||||
// 是否为管理员
|
||||
const isAdmin = computed(() => currentUserRole.value === 1)
|
||||
|
||||
// 是否可以编辑群公告
|
||||
const canEditAnnouncement = computed(() => isOwner.value || isAdmin.value)
|
||||
|
||||
// 是否可以编辑群名
|
||||
const canEditGroupName = computed(() => isOwner.value || isAdmin.value)
|
||||
|
||||
// 判断是否可以管理某个成员
|
||||
function canManageMember(member: GroupMember): boolean {
|
||||
// 群主可以管理所有成员(除了自己)
|
||||
if (isOwner.value && member.user_id !== authStore.user?.id) {
|
||||
return true
|
||||
}
|
||||
// 管理员可以管理普通成员
|
||||
if (isAdmin.value && member.role === 0) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 加载群成员
|
||||
async function loadMembers() {
|
||||
if (loadingMembers.value) return
|
||||
|
||||
loadingMembers.value = true
|
||||
try {
|
||||
const memberList = await groupApi.getGroupMembers(props.roomId)
|
||||
members.value = memberList as unknown as GroupMember[]
|
||||
} catch (error) {
|
||||
console.error('Failed to load group members:', error)
|
||||
toastStore.error('加载群成员失败')
|
||||
members.value = []
|
||||
} finally {
|
||||
loadingMembers.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载群信息(包括群名和公告)
|
||||
async function loadGroupInfo() {
|
||||
if (loadingGroupName.value) return
|
||||
|
||||
loadingGroupName.value = true
|
||||
try {
|
||||
const [groupInfo, annRes] = await Promise.all([
|
||||
groupApi.getGroup(props.roomId),
|
||||
groupApi.getGroupAnnouncement(props.roomId).catch(() => ({ announcement: '' }))
|
||||
])
|
||||
groupName.value = groupInfo.name || groupInfo.room_name || ''
|
||||
announcement.value = annRes.announcement || ''
|
||||
} catch (error) {
|
||||
console.error('Failed to load group info:', error)
|
||||
// 加载失败不影响使用
|
||||
} finally {
|
||||
loadingGroupName.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载群公告
|
||||
async function loadAnnouncement() {
|
||||
if (loadingAnnouncement.value) return
|
||||
|
||||
loadingAnnouncement.value = true
|
||||
try {
|
||||
const res = await groupApi.getGroupAnnouncement(props.roomId)
|
||||
announcement.value = res.announcement || ''
|
||||
} catch (error) {
|
||||
console.error('Failed to load announcement:', error)
|
||||
// 公告加载失败不影响使用
|
||||
} finally {
|
||||
loadingAnnouncement.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 开始编辑群公告
|
||||
function startEditAnnouncement() {
|
||||
editAnnouncementText.value = announcement.value
|
||||
isEditingAnnouncement.value = true
|
||||
}
|
||||
|
||||
// 取消编辑
|
||||
function cancelEditAnnouncement() {
|
||||
isEditingAnnouncement.value = false
|
||||
editAnnouncementText.value = ''
|
||||
}
|
||||
|
||||
// 保存群公告
|
||||
async function saveAnnouncement() {
|
||||
try {
|
||||
await groupApi.updateGroupAnnouncement(props.roomId, editAnnouncementText.value)
|
||||
announcement.value = editAnnouncementText.value
|
||||
isEditingAnnouncement.value = false
|
||||
toastStore.success('群公告已更新')
|
||||
} catch (error: any) {
|
||||
console.error('Failed to update announcement:', error)
|
||||
toastStore.error(error.message || '更新群公告失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 开始编辑群名
|
||||
function startEditGroupName() {
|
||||
editGroupNameText.value = groupName.value
|
||||
isEditingGroupName.value = true
|
||||
}
|
||||
|
||||
// 取消编辑群名
|
||||
function cancelEditGroupName() {
|
||||
isEditingGroupName.value = false
|
||||
editGroupNameText.value = ''
|
||||
}
|
||||
|
||||
// 保存群名
|
||||
async function saveGroupName() {
|
||||
if (!editGroupNameText.value.trim()) {
|
||||
toastStore.warning('群名称不能为空')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await groupApi.updateGroup(props.roomId, {
|
||||
name: editGroupNameText.value.trim()
|
||||
})
|
||||
groupName.value = editGroupNameText.value.trim()
|
||||
isEditingGroupName.value = false
|
||||
toastStore.success('群名称已更新')
|
||||
// 触发更新事件,让父组件刷新
|
||||
emit('group-updated')
|
||||
} catch (error: any) {
|
||||
console.error('Failed to update group name:', error)
|
||||
toastStore.error(error.message || '更新群名称失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 处理成员点击
|
||||
function handleMemberClick(member: GroupMember) {
|
||||
emit('member-clicked', member)
|
||||
}
|
||||
|
||||
// 移除成员
|
||||
async function handleRemoveMember(member: GroupMember) {
|
||||
if (!confirm(`确定要移除成员 "${member.user?.name || member.nickname || '未知'}" 吗?`)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await groupApi.removeGroupMember(props.roomId, member.user_id)
|
||||
toastStore.success('成员已移除')
|
||||
// 重新加载成员列表
|
||||
await loadMembers()
|
||||
emit('member-removed')
|
||||
} catch (error: any) {
|
||||
console.error('Failed to remove member:', error)
|
||||
toastStore.error(error.message || '移除成员失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 监听 roomId 变化
|
||||
watch(() => props.roomId, () => {
|
||||
if (props.roomId) {
|
||||
loadMembers()
|
||||
loadGroupInfo()
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
onMounted(() => {
|
||||
if (props.roomId) {
|
||||
loadMembers()
|
||||
loadGroupInfo()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
@apply bg-gray-700 rounded-full;
|
||||
}
|
||||
</style>
|
||||
|
||||
408
src/components/chat/GroupInfoPanel.vue
Normal file
408
src/components/chat/GroupInfoPanel.vue
Normal file
@@ -0,0 +1,408 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="show"
|
||||
class="fixed inset-0 bg-black/80 z-[60] flex items-center justify-center p-4 backdrop-blur-sm"
|
||||
@click.self="$emit('close')"
|
||||
>
|
||||
<div class="bg-panel rounded-2xl w-full max-w-2xl shadow-2xl border border-gray-700 overflow-hidden animate-fade-in max-h-[90vh] flex flex-col">
|
||||
<!-- 头部 -->
|
||||
<div class="px-6 py-4 border-b border-gray-800 flex justify-between items-center">
|
||||
<h2 class="text-xl font-bold text-white">群资料</h2>
|
||||
<button
|
||||
class="text-gray-400 hover:text-white transition text-xl"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="flex-1 flex items-center justify-center py-20">
|
||||
<div class="text-center">
|
||||
<i class="fas fa-circle-notch fa-spin text-4xl text-primary mb-4"></i>
|
||||
<p class="text-gray-400">加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<div v-else-if="groupInfo" class="flex-1 overflow-y-auto custom-scrollbar">
|
||||
<!-- 群基本信息 -->
|
||||
<div class="px-6 py-6 border-b border-gray-800">
|
||||
<div class="flex flex-col items-center mb-6">
|
||||
<Avatar
|
||||
:name="groupInfo.name"
|
||||
:avatar="groupInfo.avatar || groupInfo.name?.charAt(0)"
|
||||
size="xl"
|
||||
rounded="full"
|
||||
class="mb-4 border-4 border-gray-700"
|
||||
/>
|
||||
<h3 class="text-2xl font-bold text-white mb-2">{{ groupInfo.name }}</h3>
|
||||
<p class="text-sm text-gray-400">群ID: {{ groupInfo.room_id }}</p>
|
||||
<p class="text-sm text-gray-400 mt-1">成员数: {{ groupInfo.member_count || members.length }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 群操作按钮 -->
|
||||
<div class="flex gap-3 justify-center">
|
||||
<button
|
||||
v-if="canInvite"
|
||||
class="px-4 py-2 rounded-xl bg-primary hover:bg-indigo-600 text-white transition flex items-center gap-2"
|
||||
@click="showInviteModal = true"
|
||||
>
|
||||
<i class="fas fa-user-plus"></i>
|
||||
<span>邀请成员</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="canEdit"
|
||||
class="px-4 py-2 rounded-xl bg-gray-700 hover:bg-gray-600 text-white transition flex items-center gap-2"
|
||||
@click="showEditModal = true"
|
||||
>
|
||||
<i class="fas fa-edit"></i>
|
||||
<span>编辑群信息</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="isOwner"
|
||||
class="px-4 py-2 rounded-xl bg-red-600 hover:bg-red-700 text-white transition flex items-center gap-2"
|
||||
@click="handleDissolve"
|
||||
>
|
||||
<i class="fas fa-trash"></i>
|
||||
<span>解散群聊</span>
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="px-4 py-2 rounded-xl bg-red-600 hover:bg-red-700 text-white transition flex items-center gap-2"
|
||||
@click="handleQuit"
|
||||
>
|
||||
<i class="fas fa-sign-out-alt"></i>
|
||||
<span>退出群聊</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 成员列表 -->
|
||||
<div class="px-6 py-4">
|
||||
<h4 class="text-lg font-semibold text-white mb-4">群成员 ({{ members.length }})</h4>
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
<div
|
||||
v-for="member in members"
|
||||
:key="member.user_id"
|
||||
class="flex items-center gap-3 p-3 rounded-xl hover:bg-white/5 transition cursor-pointer group"
|
||||
@click="handleMemberClick(member)"
|
||||
>
|
||||
<Avatar
|
||||
:name="member.user?.name || member.nickname || '未知'"
|
||||
:avatar="member.user?.avatar || (member.user?.name || member.nickname)?.charAt(0)"
|
||||
size="sm"
|
||||
rounded="full"
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-white truncate">
|
||||
{{ member.user?.name || member.nickname || '未知' }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500">
|
||||
<span v-if="member.role === 2" class="text-yellow-400">群主</span>
|
||||
<span v-else-if="member.role === 1" class="text-blue-400">管理员</span>
|
||||
<span v-else>成员</span>
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
v-if="canManageMember(member)"
|
||||
class="opacity-0 group-hover:opacity-100 transition"
|
||||
>
|
||||
<button
|
||||
v-if="member.role !== 2"
|
||||
class="text-gray-400 hover:text-red-400 transition text-sm"
|
||||
@click.stop="handleRemoveMember(member)"
|
||||
>
|
||||
<i class="fas fa-user-minus"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 错误状态 -->
|
||||
<div v-else class="flex-1 flex items-center justify-center py-20">
|
||||
<div class="text-center">
|
||||
<i class="fas fa-exclamation-circle text-4xl text-red-500 mb-4"></i>
|
||||
<p class="text-gray-400">加载失败</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 邀请成员弹窗 -->
|
||||
<SelectContactsModal
|
||||
v-if="showInviteModal"
|
||||
:show="showInviteModal"
|
||||
:contacts="availableContacts"
|
||||
@close="showInviteModal = false"
|
||||
@create="handleInviteMembers"
|
||||
/>
|
||||
|
||||
<!-- 编辑群信息弹窗 -->
|
||||
<div
|
||||
v-if="showEditModal"
|
||||
class="fixed inset-0 bg-black/80 z-[70] flex items-center justify-center p-4 backdrop-blur-sm"
|
||||
@click.self="showEditModal = false"
|
||||
>
|
||||
<div class="bg-panel rounded-2xl w-full max-w-md shadow-2xl border border-gray-700 overflow-hidden animate-fade-in">
|
||||
<div class="px-6 py-4 border-b border-gray-800 flex justify-between items-center">
|
||||
<h3 class="text-lg font-bold text-white">编辑群信息</h3>
|
||||
<button class="text-gray-400 hover:text-white transition" @click="showEditModal = false">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="px-6 py-4 space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm text-gray-400 mb-2">群名称</label>
|
||||
<input
|
||||
v-model="editForm.name"
|
||||
type="text"
|
||||
class="w-full bg-input rounded-xl py-2 px-4 text-sm text-white focus:ring-2 ring-primary outline-none"
|
||||
placeholder="请输入群名称"
|
||||
maxlength="20"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-400 mb-2">群头像(首字母)</label>
|
||||
<input
|
||||
v-model="editForm.avatar"
|
||||
type="text"
|
||||
class="w-full bg-input rounded-xl py-2 px-4 text-sm text-white focus:ring-2 ring-primary outline-none"
|
||||
placeholder="输入单个字符作为头像"
|
||||
maxlength="1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-6 py-4 border-t border-gray-800 flex justify-end gap-3">
|
||||
<button
|
||||
class="px-4 py-2 rounded-xl bg-gray-700 hover:bg-gray-600 text-white transition"
|
||||
@click="showEditModal = false"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
class="px-4 py-2 rounded-xl bg-primary hover:bg-indigo-600 text-white transition"
|
||||
@click="handleUpdateGroup"
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import type { GroupInfo, GroupMember, Contact } from '@/types/api'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useChatStore } from '@/stores/chat'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import * as groupApi from '@/api/modules/room'
|
||||
import * as contactApi from '@/api/modules/contact'
|
||||
import Avatar from '@/components/common/Avatar.vue'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
roomId: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'close': []
|
||||
'updated': []
|
||||
'quit': []
|
||||
'dissolve': []
|
||||
}>()
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const chatStore = useChatStore()
|
||||
const toastStore = useToastStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const groupInfo = ref<GroupInfo | null>(null)
|
||||
const members = ref<GroupMember[]>([])
|
||||
const availableContacts = ref<Contact[]>([])
|
||||
const showInviteModal = ref(false)
|
||||
const showEditModal = ref(false)
|
||||
const editForm = ref({ name: '', avatar: '' })
|
||||
|
||||
// 当前用户角色
|
||||
const currentUserRole = computed(() => {
|
||||
if (!authStore.user) return -1
|
||||
const member = members.value.find(m => m.user_id === authStore.user!.id)
|
||||
return member?.role ?? -1
|
||||
})
|
||||
|
||||
// 是否为群主
|
||||
const isOwner = computed(() => currentUserRole.value === 2)
|
||||
|
||||
// 是否为管理员
|
||||
const isAdmin = computed(() => currentUserRole.value === 1)
|
||||
|
||||
// 是否可以邀请成员
|
||||
const canInvite = computed(() => isOwner.value || isAdmin.value)
|
||||
|
||||
// 是否可以编辑群信息
|
||||
const canEdit = computed(() => isOwner.value)
|
||||
|
||||
// 加载群信息
|
||||
async function loadGroupInfo() {
|
||||
if (!props.roomId) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const [group, memberList] = await Promise.all([
|
||||
groupApi.getGroup(props.roomId),
|
||||
groupApi.getGroupMembers(props.roomId)
|
||||
])
|
||||
groupInfo.value = group
|
||||
members.value = memberList
|
||||
|
||||
// 加载可用联系人(用于邀请)
|
||||
const contacts = await contactApi.getContacts()
|
||||
// 过滤掉已经是群成员的联系人
|
||||
const memberIds = new Set(memberList.map(m => m.user_id))
|
||||
availableContacts.value = contacts.filter(c => !memberIds.has(c.user_id))
|
||||
|
||||
// 初始化编辑表单
|
||||
editForm.value = {
|
||||
name: group.name || '',
|
||||
avatar: group.avatar || ''
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Failed to load group info:', error)
|
||||
toastStore.error('加载群信息失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 判断是否可以管理某个成员
|
||||
function canManageMember(member: GroupMember): boolean {
|
||||
if (!isOwner.value && !isAdmin.value) return false
|
||||
if (member.user_id === authStore.user?.id) return false // 不能管理自己
|
||||
if (member.role === 2) return false // 不能管理群主
|
||||
if (isAdmin.value && member.role === 1) return false // 管理员不能管理其他管理员
|
||||
return true
|
||||
}
|
||||
|
||||
// 处理成员点击
|
||||
function handleMemberClick(member: GroupMember) {
|
||||
// 可以扩展为显示成员详情
|
||||
}
|
||||
|
||||
// 移除成员
|
||||
async function handleRemoveMember(member: GroupMember) {
|
||||
if (!confirm(`确定要将 ${member.user?.name || member.nickname} 移出群聊吗?`)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await groupApi.removeGroupMember(props.roomId, member.user_id)
|
||||
toastStore.success('已移除成员')
|
||||
await loadGroupInfo()
|
||||
emit('updated')
|
||||
} catch (error: any) {
|
||||
toastStore.error('移除成员失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 邀请单个联系人
|
||||
async function handleInviteContact(contact: Contact) {
|
||||
try {
|
||||
await groupApi.inviteGroupMembers(props.roomId, { member_ids: [contact.user_id || contact.id] })
|
||||
toastStore.success('邀请成功')
|
||||
showInviteModal.value = false
|
||||
await loadGroupInfo()
|
||||
emit('updated')
|
||||
} catch (error: any) {
|
||||
toastStore.error('邀请失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 更新群信息
|
||||
async function handleUpdateGroup() {
|
||||
if (!editForm.value.name.trim()) {
|
||||
toastStore.warning('群名称不能为空')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await groupApi.updateGroup(props.roomId, {
|
||||
name: editForm.value.name.trim(),
|
||||
avatar: editForm.value.avatar || editForm.value.name.charAt(0).toUpperCase()
|
||||
})
|
||||
toastStore.success('更新成功')
|
||||
showEditModal.value = false
|
||||
await loadGroupInfo()
|
||||
emit('updated')
|
||||
} catch (error: any) {
|
||||
toastStore.error('更新失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 退出群聊
|
||||
async function handleQuit() {
|
||||
if (!confirm('确定要退出该群聊吗?')) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await groupApi.quitGroup(props.roomId)
|
||||
toastStore.success('已退出群聊')
|
||||
emit('quit')
|
||||
emit('close')
|
||||
} catch (error: any) {
|
||||
toastStore.error('退出失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 解散群聊
|
||||
async function handleDissolve() {
|
||||
if (!confirm('确定要解散该群聊吗?此操作不可恢复!')) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await groupApi.dissolveGroup(props.roomId)
|
||||
toastStore.success('群聊已解散')
|
||||
emit('dissolve')
|
||||
emit('close')
|
||||
} catch (error: any) {
|
||||
toastStore.error('解散失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 监听 show 变化,加载数据
|
||||
watch(() => props.show, (newVal) => {
|
||||
if (newVal) {
|
||||
loadGroupInfo()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
@apply bg-gray-700 rounded-full;
|
||||
}
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
<template>
|
||||
<div class="flex flex-col" :class="message.isSelf ? 'items-end' : 'items-start'">
|
||||
<!-- 系统/通知消息:居中灰色提示条样式 -->
|
||||
<div v-if="isSystemOrNotify" class="flex justify-center my-2">
|
||||
<div class="bg-gray-700/50 text-gray-400 text-xs px-4 py-2 rounded-full border border-gray-600/30 max-w-[80%] text-center">
|
||||
<i :class="systemIcon" class="mr-1.5"></i>
|
||||
<span>{{ systemContent }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 普通消息:原有样式 -->
|
||||
<div v-else class="flex flex-col" :class="message.isSelf ? 'items-end' : 'items-start'">
|
||||
<!-- 气泡主体 -->
|
||||
<div
|
||||
class="relative text-sm shadow-md transition-all duration-300 hover:shadow-lg group/bubble overflow-hidden"
|
||||
@@ -24,6 +33,11 @@
|
||||
|
||||
<!-- 文件消息 -->
|
||||
<FileBubble v-else-if="message.message_type === 8" :message="message" class="px-3 py-2" />
|
||||
|
||||
<!-- 未知类型消息 -->
|
||||
<div v-else class="px-4 py-3">
|
||||
<span class="text-gray-400 text-xs">[未知消息类型]</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部时间显示 (格式化) -->
|
||||
@@ -40,6 +54,8 @@
|
||||
import { computed } from 'vue'
|
||||
import type { ChatMessage } from '@/types/api'
|
||||
import { formatTime } from '@/utils/format'
|
||||
import { isSystemOrNotifyMessage, getMessageTypeIcon, getMessageSummary } from '@/utils/messageTypes'
|
||||
import { MessageType } from '@/types/message'
|
||||
import TextBubble from './bubble/Text.vue'
|
||||
import ImageBubble from './bubble/Image.vue'
|
||||
import AudioBubble from './bubble/Audio.vue'
|
||||
@@ -50,6 +66,15 @@ const props = defineProps<{
|
||||
message: ChatMessage
|
||||
}>()
|
||||
|
||||
// 判断是否为系统/通知消息
|
||||
const isSystemOrNotify = computed(() => isSystemOrNotifyMessage(props.message.message_type))
|
||||
|
||||
// 系统消息图标
|
||||
const systemIcon = computed(() => getMessageTypeIcon(props.message.message_type))
|
||||
|
||||
// 系统消息内容
|
||||
const systemContent = computed(() => getMessageSummary(props.message))
|
||||
|
||||
// 更现代的圆角逻辑
|
||||
const bubbleShapeClass = computed(() => {
|
||||
if (props.message.isSelf) {
|
||||
|
||||
@@ -43,9 +43,9 @@
|
||||
>
|
||||
<!-- 头像 -->
|
||||
<Avatar
|
||||
:name="msg.isSelf ? currentUser.name : (target.user?.name || target.remark_name)"
|
||||
:avatar="msg.isSelf ? currentUser.avatar : (target.user?.avatar || target.remark_name?.charAt(0))"
|
||||
:color="msg.isSelf ? generateColor(currentUser.id) : target.color"
|
||||
:name="getSenderName(msg)"
|
||||
:avatar="getSenderAvatar(msg)"
|
||||
:color="msg.isSelf ? generateColor(currentUser.id) : (getMemberColor(msg.sender_user_id) || target.color)"
|
||||
size="sm"
|
||||
rounded="lg"
|
||||
class="cursor-pointer hover:opacity-80 transition self-start mt-1 shadow-md"
|
||||
@@ -54,7 +54,16 @@
|
||||
|
||||
<!-- 消息内容区 -->
|
||||
<div class="flex flex-col max-w-[75%] md:max-w-[60%]">
|
||||
<!-- 群聊中显示发送者昵称(非自己发送的消息) -->
|
||||
<div
|
||||
v-if="isGroupChat && !msg.isSelf"
|
||||
class="text-[10px] text-gray-500 mb-1 px-1"
|
||||
>
|
||||
{{ getSenderDisplayName(msg) }}
|
||||
</div>
|
||||
<!-- 单聊或自己发送的消息 -->
|
||||
<div
|
||||
v-else-if="!isGroupChat || msg.isSelf"
|
||||
class="text-[10px] text-gray-500 mb-1 px-1"
|
||||
:class="{ 'text-right': msg.isSelf }"
|
||||
>
|
||||
@@ -69,7 +78,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import { generateColor } from '@/utils/format'
|
||||
import Avatar from '@/components/common/Avatar.vue'
|
||||
import MessageBubble from '@/components/chat/MessageBubble.vue'
|
||||
@@ -81,6 +90,7 @@ interface Props {
|
||||
target: Contact
|
||||
loadingHistory?: boolean
|
||||
hasMoreHistory?: boolean
|
||||
roomMembers?: Record<string, { name: string; avatar?: string }> // 群成员映射 roomId -> { name, avatar }
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
@@ -94,6 +104,50 @@ const emit = defineEmits<{
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
|
||||
// 判断是否为群聊
|
||||
const isGroupChat = computed(() => props.target.is_group || props.target.room_type === 'group')
|
||||
|
||||
// 获取发送者名称
|
||||
function getSenderName(msg: ChatMessage): string {
|
||||
if (msg.isSelf) {
|
||||
return props.currentUser.name
|
||||
}
|
||||
if (isGroupChat.value && props.roomMembers?.[msg.sender_user_id]) {
|
||||
return props.roomMembers[msg.sender_user_id].name
|
||||
}
|
||||
return props.target.user?.name || props.target.remark_name || '未知用户'
|
||||
}
|
||||
|
||||
// 获取发送者头像
|
||||
function getSenderAvatar(msg: ChatMessage): string {
|
||||
if (msg.isSelf) {
|
||||
return props.currentUser.avatar
|
||||
}
|
||||
if (isGroupChat.value && props.roomMembers?.[msg.sender_user_id]) {
|
||||
return props.roomMembers[msg.sender_user_id].avatar || getSenderName(msg).charAt(0)
|
||||
}
|
||||
return props.target.user?.avatar || props.target.remark_name?.charAt(0) || '?'
|
||||
}
|
||||
|
||||
// 获取发送者显示名称(群聊中显示)
|
||||
function getSenderDisplayName(msg: ChatMessage): string {
|
||||
if (msg.isSelf) {
|
||||
return 'Me'
|
||||
}
|
||||
if (isGroupChat.value && props.roomMembers?.[msg.sender_user_id]) {
|
||||
return props.roomMembers[msg.sender_user_id].name
|
||||
}
|
||||
return props.target.user?.name || props.target.remark_name || '未知用户'
|
||||
}
|
||||
|
||||
// 获取成员颜色
|
||||
function getMemberColor(userId: string): string | undefined {
|
||||
if (props.roomMembers?.[userId]) {
|
||||
return generateColor(userId)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
// 节流函数
|
||||
function throttle<T extends (...args: any[]) => any>(func: T, delay: number): T {
|
||||
let lastCall = 0
|
||||
|
||||
224
src/components/chat/SelectContactsModal.vue
Normal file
224
src/components/chat/SelectContactsModal.vue
Normal file
@@ -0,0 +1,224 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="show"
|
||||
class="fixed inset-0 bg-black/80 z-[60] flex items-center justify-center p-4 backdrop-blur-sm"
|
||||
@click.self="$emit('close')"
|
||||
>
|
||||
<div class="bg-panel rounded-2xl w-full max-w-md shadow-2xl border border-gray-700 overflow-hidden animate-fade-in max-h-[80vh] flex flex-col">
|
||||
<!-- 头部 -->
|
||||
<div class="px-6 py-4 border-b border-gray-800 flex justify-between items-center">
|
||||
<h2 class="text-xl font-bold text-white">选择联系人创建群聊</h2>
|
||||
<button
|
||||
class="text-gray-400 hover:text-white transition text-xl"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 搜索框 -->
|
||||
<div class="px-6 py-4 border-b border-gray-800">
|
||||
<div class="relative">
|
||||
<i class="fas fa-search absolute left-3 top-3 text-gray-500"></i>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
class="w-full bg-input rounded-xl py-2 pl-10 pr-4 text-sm text-white focus:ring-2 ring-primary outline-none transition placeholder-gray-500"
|
||||
placeholder="搜索联系人..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 已选联系人 -->
|
||||
<div v-if="selectedContacts.length > 0" class="px-6 py-3 border-b border-gray-800">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<div
|
||||
v-for="contact in selectedContacts"
|
||||
:key="contact.id"
|
||||
class="flex items-center gap-2 bg-primary/20 text-primary px-3 py-1.5 rounded-full text-sm"
|
||||
>
|
||||
<span>{{ contact.user?.name || contact.remark_name }}</span>
|
||||
<button
|
||||
class="hover:text-red-400 transition"
|
||||
@click="removeContact(contact.id)"
|
||||
>
|
||||
<i class="fas fa-times text-xs"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 联系人列表 -->
|
||||
<div class="flex-1 overflow-y-auto px-4 py-4 custom-scrollbar">
|
||||
<div v-if="filteredContacts.length === 0" class="text-center py-10 text-gray-500">
|
||||
<i class="fas fa-users-slash text-4xl mb-3 opacity-50"></i>
|
||||
<p class="text-sm">暂无联系人</p>
|
||||
</div>
|
||||
<div
|
||||
v-for="contact in filteredContacts"
|
||||
:key="contact.id"
|
||||
class="flex items-center gap-3 p-3 rounded-xl hover:bg-white/5 cursor-pointer transition"
|
||||
:class="{ 'bg-primary/10': isSelected(contact.id) }"
|
||||
@click="toggleContact(contact)"
|
||||
>
|
||||
<Avatar
|
||||
:name="contact.user?.name || contact.remark_name"
|
||||
:avatar="contact.user?.avatar || contact.remark_name?.charAt(0)"
|
||||
:color="contact.color"
|
||||
size="sm"
|
||||
rounded="full"
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-white truncate">
|
||||
{{ contact.user?.name || contact.remark_name }}
|
||||
</p>
|
||||
<p v-if="contact.user?.desc" class="text-xs text-gray-500 truncate">
|
||||
{{ contact.user.desc }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="w-5 h-5 rounded-full border-2 transition"
|
||||
:class="isSelected(contact.id) ? 'bg-primary border-primary' : 'border-gray-600'"
|
||||
>
|
||||
<i v-if="isSelected(contact.id)" class="fas fa-check text-white text-xs flex items-center justify-center h-full"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部操作区 -->
|
||||
<div class="px-6 py-4 border-t border-gray-800 flex justify-between items-center">
|
||||
<div class="text-sm text-gray-400">
|
||||
已选择 <span class="text-primary font-bold">{{ selectedContacts.length }}</span> 人
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
class="px-4 py-2 rounded-xl bg-gray-700 hover:bg-gray-600 text-white transition"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
class="px-4 py-2 rounded-xl bg-primary hover:bg-indigo-600 text-white transition disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:disabled="selectedContacts.length < 1 || !groupName.trim()"
|
||||
@click="handleCreate"
|
||||
>
|
||||
创建群聊
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 群名称输入(在底部操作区上方) -->
|
||||
<div class="px-6 py-3 border-t border-gray-800">
|
||||
<input
|
||||
v-model="groupName"
|
||||
type="text"
|
||||
class="w-full bg-input rounded-xl py-2 px-4 text-sm text-white focus:ring-2 ring-primary outline-none transition placeholder-gray-500"
|
||||
placeholder="请输入群名称(必填)"
|
||||
maxlength="20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import type { Contact } from '@/types/api'
|
||||
import Avatar from '@/components/common/Avatar.vue'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
contacts: Contact[]
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'close': []
|
||||
'create': [data: { member_ids: string[]; name: string; avatar?: string }]
|
||||
}>()
|
||||
|
||||
const searchQuery = ref('')
|
||||
const groupName = ref('')
|
||||
const selectedContacts = ref<Contact[]>([])
|
||||
|
||||
// 过滤联系人
|
||||
const filteredContacts = computed(() => {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
return props.contacts.filter(c => {
|
||||
const name = (c.user?.name || c.remark_name || '').toLowerCase()
|
||||
return name.includes(query)
|
||||
})
|
||||
})
|
||||
|
||||
// 判断联系人是否已选中
|
||||
function isSelected(contactId: string): boolean {
|
||||
return selectedContacts.value.some(c => c.id === contactId)
|
||||
}
|
||||
|
||||
// 切换联系人选择状态
|
||||
function toggleContact(contact: Contact) {
|
||||
const index = selectedContacts.value.findIndex(c => c.id === contact.id)
|
||||
if (index > -1) {
|
||||
selectedContacts.value.splice(index, 1)
|
||||
} else {
|
||||
selectedContacts.value.push(contact)
|
||||
}
|
||||
}
|
||||
|
||||
// 移除已选联系人
|
||||
function removeContact(contactId: string) {
|
||||
const index = selectedContacts.value.findIndex(c => c.id === contactId)
|
||||
if (index > -1) {
|
||||
selectedContacts.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// 创建群聊
|
||||
function handleCreate() {
|
||||
if (selectedContacts.value.length < 1 || !groupName.value.trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
const memberIds = selectedContacts.value.map(c => c.user_id || c.id)
|
||||
const avatar = groupName.value.charAt(0).toUpperCase() // 使用群名称首字母作为头像
|
||||
|
||||
emit('create', {
|
||||
member_ids: memberIds,
|
||||
name: groupName.value.trim(),
|
||||
avatar
|
||||
})
|
||||
|
||||
// 重置状态
|
||||
selectedContacts.value = []
|
||||
groupName.value = ''
|
||||
searchQuery.value = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
@apply bg-gray-700 rounded-full;
|
||||
}
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
203
src/components/contact/GroupChatCard.vue
Normal file
203
src/components/contact/GroupChatCard.vue
Normal file
@@ -0,0 +1,203 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<!-- 群聊基本信息 -->
|
||||
<div
|
||||
class="flex items-center p-2.5 rounded-xl transition-all duration-200 select-none cursor-pointer"
|
||||
:class="isSelected ? 'bg-primary text-white shadow-lg shadow-primary/20' : 'hover:bg-white/5 text-gray-300'"
|
||||
@click="handleCardClick"
|
||||
>
|
||||
<div class="relative shrink-0 mr-3">
|
||||
<Avatar :name="group.room_name" :avatar="group.room_avatar" size="sm" rounded="xl" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex justify-between items-center mb-0.5">
|
||||
<span class="font-bold text-sm truncate" :class="isSelected ? 'text-white' : 'text-gray-200'">
|
||||
{{ group.room_name || '未知群聊' }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- 未展开时显示群公告 -->
|
||||
<div v-if="!isExpanded" class="mt-1">
|
||||
<p v-if="loadingAnnouncement" class="text-xs opacity-50" :class="isSelected ? 'text-white/60' : 'text-gray-500'">
|
||||
<i class="fas fa-spinner fa-spin mr-1"></i>加载中...
|
||||
</p>
|
||||
<p v-else-if="announcement" class="text-xs truncate opacity-70 line-clamp-1" :class="isSelected ? 'text-white/80' : 'text-gray-500'" :title="announcement">
|
||||
<i class="fas fa-bullhorn mr-1 opacity-60"></i>{{ announcement }}
|
||||
</p>
|
||||
<p v-else class="text-xs opacity-50" :class="isSelected ? 'text-white/60' : 'text-gray-500'">
|
||||
<i class="fas fa-bullhorn mr-1 opacity-60"></i>暂无群公告
|
||||
</p>
|
||||
</div>
|
||||
<!-- 展开时显示最后一条消息 -->
|
||||
<p v-else class="text-xs truncate opacity-70" :class="isSelected ? 'text-white/80' : 'text-gray-500'">
|
||||
{{ group.last_message || '暂无消息' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 ml-2">
|
||||
<button
|
||||
class="p-1 hover:bg-white/10 rounded transition"
|
||||
@click.stop="toggleExpand"
|
||||
>
|
||||
<i
|
||||
class="fas transition-transform duration-200 text-xs"
|
||||
:class="[
|
||||
isExpanded ? 'fa-chevron-down' : 'fa-chevron-right',
|
||||
isSelected ? 'text-white' : 'text-gray-500'
|
||||
]"
|
||||
></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 成员列表(展开时显示) -->
|
||||
<Transition name="slide-down">
|
||||
<div v-if="isExpanded" class="pl-2 mt-2 space-y-2 max-h-96 overflow-y-auto custom-scrollbar">
|
||||
<div v-if="loadingMembers" class="text-center py-4 text-gray-500 text-xs">
|
||||
<i class="fas fa-spinner fa-spin mr-2"></i>
|
||||
加载中...
|
||||
</div>
|
||||
<div v-else-if="members.length === 0" class="text-center py-4 text-gray-500 text-xs">
|
||||
暂无成员
|
||||
</div>
|
||||
<div v-else class="space-y-1">
|
||||
<div
|
||||
v-for="member in displayMembers"
|
||||
:key="member.user_id"
|
||||
class="flex items-center gap-2 p-2 rounded-lg hover:bg-white/5 transition"
|
||||
>
|
||||
<Avatar
|
||||
:name="member.user?.name || member.nickname || '未知'"
|
||||
:avatar="member.user?.avatar"
|
||||
size="sm"
|
||||
rounded="full"
|
||||
/>
|
||||
<span class="text-xs text-gray-300 truncate flex-1">
|
||||
{{ member.user?.name || member.nickname || '未知' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import Avatar from '@/components/common/Avatar.vue'
|
||||
import * as groupApi from '@/api/modules/room'
|
||||
import type { GroupMember } from '@/types/api'
|
||||
|
||||
interface Props {
|
||||
group: {
|
||||
room_id: string
|
||||
room_name: string
|
||||
room_avatar?: string
|
||||
last_message?: string
|
||||
}
|
||||
isSelected?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
isSelected: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'click', group: Props['group']): void
|
||||
}>()
|
||||
|
||||
const isExpanded = ref(false)
|
||||
const loadingMembers = ref(false)
|
||||
const loadingAnnouncement = ref(false)
|
||||
const members = ref<GroupMember[]>([])
|
||||
const announcement = ref<string>('')
|
||||
|
||||
// 显示成员(展开时显示全部)
|
||||
const displayMembers = computed(() => {
|
||||
return members.value
|
||||
})
|
||||
|
||||
// 切换展开/收起
|
||||
function toggleExpand() {
|
||||
isExpanded.value = !isExpanded.value
|
||||
|
||||
// 展开时加载成员列表
|
||||
if (isExpanded.value && members.value.length === 0) {
|
||||
loadMembers()
|
||||
}
|
||||
}
|
||||
|
||||
// 加载群成员
|
||||
async function loadMembers() {
|
||||
if (loadingMembers.value) return
|
||||
|
||||
loadingMembers.value = true
|
||||
try {
|
||||
const memberList = await groupApi.getGroupMembers(props.group.room_id)
|
||||
members.value = memberList as unknown as GroupMember[]
|
||||
} catch (error) {
|
||||
console.error('Failed to load group members:', error)
|
||||
members.value = []
|
||||
} finally {
|
||||
loadingMembers.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 监听 group 变化,重置展开状态并重新加载数据
|
||||
watch(() => props.group.room_id, () => {
|
||||
isExpanded.value = false
|
||||
members.value = []
|
||||
announcement.value = ''
|
||||
loadAnnouncement()
|
||||
})
|
||||
|
||||
// 处理卡片点击
|
||||
function handleCardClick() {
|
||||
emit('click', props.group)
|
||||
}
|
||||
|
||||
// 加载群公告
|
||||
async function loadAnnouncement() {
|
||||
if (loadingAnnouncement.value) return
|
||||
|
||||
loadingAnnouncement.value = true
|
||||
try {
|
||||
const res = await groupApi.getGroupAnnouncement(props.group.room_id)
|
||||
announcement.value = res?.announcement || ''
|
||||
} catch (error) {
|
||||
console.error('Failed to load group announcement:', error)
|
||||
announcement.value = ''
|
||||
} finally {
|
||||
loadingAnnouncement.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 组件挂载时加载群公告
|
||||
onMounted(() => {
|
||||
// 延迟加载,避免同时加载太多请求
|
||||
setTimeout(() => {
|
||||
loadAnnouncement()
|
||||
}, 100)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
@apply bg-gray-700 rounded-full;
|
||||
}
|
||||
|
||||
.slide-down-enter-active,
|
||||
.slide-down-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
max-height: 500px;
|
||||
}
|
||||
|
||||
.slide-down-enter-from,
|
||||
.slide-down-leave-to {
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user