群聊
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
import request from '../request'
|
||||
import type { Room } from '@/types/api'
|
||||
import type { Room, GroupInfo, GroupMember } from '@/types/api'
|
||||
|
||||
/**
|
||||
* 房间管理相关API
|
||||
*/
|
||||
|
||||
// 创建房间
|
||||
// 创建房间(单聊)
|
||||
export function createRoom(data: {
|
||||
room_type: 'p2p' | 'group'
|
||||
members: string[]
|
||||
@@ -20,3 +20,114 @@ export function getRoom(id: string) {
|
||||
return request.get<Room>(`/rooms/${id}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 群聊相关API
|
||||
*/
|
||||
|
||||
// 创建群聊
|
||||
export function createGroup(data: {
|
||||
name: string
|
||||
avatar?: string
|
||||
member_ids: string[]
|
||||
admin_ids?: string[]
|
||||
}) {
|
||||
return request.post<GroupInfo>('/groups', data)
|
||||
}
|
||||
|
||||
// 获取用户群聊列表(带分组信息)
|
||||
export function getUserGroups() {
|
||||
return request.get<Array<{
|
||||
room_id: string
|
||||
room_type: string
|
||||
room_name: string
|
||||
room_avatar: string
|
||||
owner_id: string
|
||||
creator_id: string
|
||||
category: 'joined' | 'created' | 'managed'
|
||||
role: number
|
||||
last_message_time?: string
|
||||
last_message?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}>>('/groups')
|
||||
}
|
||||
|
||||
// 获取群信息
|
||||
export function getGroup(roomId: string) {
|
||||
return request.get<GroupInfo>(`/groups/${roomId}`)
|
||||
}
|
||||
|
||||
// 获取群成员列表
|
||||
export function getGroupMembers(roomId: string) {
|
||||
return request.get<GroupMember[]>(`/groups/${roomId}/members`)
|
||||
}
|
||||
|
||||
// 邀请成员入群
|
||||
export function inviteGroupMembers(roomId: string, data: {
|
||||
member_ids: string[]
|
||||
}) {
|
||||
return request.post<void>(`/groups/${roomId}/members`, data)
|
||||
}
|
||||
|
||||
// 移除群成员
|
||||
export function removeGroupMember(roomId: string, userId: string) {
|
||||
return request.post<void>(`/groups/${roomId}/members/${userId}/remove`, {})
|
||||
}
|
||||
|
||||
// 修改群信息
|
||||
export function updateGroup(roomId: string, data: {
|
||||
name?: string
|
||||
avatar?: string
|
||||
}) {
|
||||
return request.post<void>(`/groups/${roomId}/update`, data)
|
||||
}
|
||||
|
||||
// 调整成员角色
|
||||
export function updateMemberRole(roomId: string, userId: string, data: {
|
||||
role: number // 0:成员 1:管理员 2:群主
|
||||
}) {
|
||||
return request.post<void>(`/groups/${roomId}/members/${userId}/role`, data)
|
||||
}
|
||||
|
||||
// 退出群聊
|
||||
export function quitGroup(roomId: string) {
|
||||
return request.post<void>(`/groups/${roomId}/quit`, {})
|
||||
}
|
||||
|
||||
// 解散群聊
|
||||
export function dissolveGroup(roomId: string) {
|
||||
return request.post<void>(`/groups/${roomId}/dissolve`, {})
|
||||
}
|
||||
|
||||
// 获取群公告
|
||||
export function getGroupAnnouncement(roomId: string) {
|
||||
return request.get<{ announcement: string }>(`/groups/${roomId}/announcement`)
|
||||
}
|
||||
|
||||
// 更新群公告
|
||||
export function updateGroupAnnouncement(roomId: string, announcement: string) {
|
||||
return request.post<void>(`/groups/${roomId}/announcement`, { announcement })
|
||||
}
|
||||
|
||||
// 获取群通知列表
|
||||
export function getGroupNotifications(params?: {
|
||||
page?: number
|
||||
page_size?: number
|
||||
is_read?: boolean
|
||||
}) {
|
||||
return request.get<{
|
||||
data: Array<{
|
||||
id: number
|
||||
room_id: string
|
||||
message_type: number
|
||||
content: string
|
||||
extra: string | object
|
||||
is_read?: boolean
|
||||
created_at: string
|
||||
}>
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}>('/group-notifications', { params })
|
||||
}
|
||||
|
||||
|
||||
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>
|
||||
|
||||
@@ -18,6 +18,10 @@ export const useChatStore = defineStore('chat', () => {
|
||||
currentTarget.value = contact
|
||||
if (contact) {
|
||||
contact.unread = 0
|
||||
// 如果是群聊,标记 is_group
|
||||
if (contact.room_type === 'group' || contact.room_id?.startsWith('group_')) {
|
||||
contact.is_group = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,9 +62,15 @@ export const useChatStore = defineStore('chat', () => {
|
||||
|
||||
/**
|
||||
* 更新联系人最后消息
|
||||
* 支持按 room_id 或 contactId 匹配(群聊优先使用 room_id)
|
||||
*/
|
||||
function updateContactLastMsg(contactId: string, lastMsg: string, lastTime: number) {
|
||||
const contact = contacts.value.find(c => c.id === contactId || c.user_id === contactId)
|
||||
function updateContactLastMsg(contactIdOrRoomId: string, lastMsg: string, lastTime: number) {
|
||||
// 优先按 room_id 匹配(群聊场景)
|
||||
let contact = contacts.value.find(c => c.room_id === contactIdOrRoomId)
|
||||
// 如果没有找到,再按 id 或 user_id 匹配(单聊场景)
|
||||
if (!contact) {
|
||||
contact = contacts.value.find(c => c.id === contactIdOrRoomId || c.user_id === contactIdOrRoomId)
|
||||
}
|
||||
if (contact) {
|
||||
contact.lastMsg = lastMsg
|
||||
contact.lastTime = lastTime
|
||||
@@ -69,10 +79,16 @@ export const useChatStore = defineStore('chat', () => {
|
||||
|
||||
/**
|
||||
* 增加联系人未读数
|
||||
* 支持按 room_id 或 contactId 匹配(群聊优先使用 room_id)
|
||||
*/
|
||||
function incrementUnread(contactId: string) {
|
||||
const contact = contacts.value.find(c => c.id === contactId || c.user_id === contactId)
|
||||
if (contact && contact.id !== currentTarget.value?.id) {
|
||||
function incrementUnread(contactIdOrRoomId: string) {
|
||||
// 优先按 room_id 匹配(群聊场景)
|
||||
let contact = contacts.value.find(c => c.room_id === contactIdOrRoomId)
|
||||
// 如果没有找到,再按 id 或 user_id 匹配(单聊场景)
|
||||
if (!contact) {
|
||||
contact = contacts.value.find(c => c.id === contactIdOrRoomId || c.user_id === contactIdOrRoomId)
|
||||
}
|
||||
if (contact && contact.id !== currentTarget.value?.id && contact.room_id !== currentTarget.value?.room_id) {
|
||||
contact.unread = (contact.unread || 0) + 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ref, computed } from 'vue'
|
||||
import type { Conversation } from '@/types/conversation'
|
||||
import type { ChatMessage } from '@/types/api'
|
||||
import * as conversationApi from '@/api/modules/conversation'
|
||||
import { getMessageSummary } from '@/utils/messageTypes'
|
||||
|
||||
export const useConversationStore = defineStore('conversation', () => {
|
||||
const conversations = ref<Conversation[]>([])
|
||||
@@ -21,11 +22,41 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
try {
|
||||
const list = await conversationApi.getConversationList()
|
||||
// 后端返回的数据需要简单处理一下
|
||||
conversations.value = list.map(c => ({
|
||||
...c,
|
||||
name: c.target_user?.name || '未知用户',
|
||||
avatar: c.target_user?.avatar || '',
|
||||
}))
|
||||
conversations.value = list.map((c: any) => {
|
||||
const isGroup = c.type === 2 // type: 1=私聊, 2=群聊
|
||||
const roomType = isGroup ? 'group' : 'p2p'
|
||||
|
||||
// 将后端的 room 字段映射为前端的 target_group
|
||||
const targetGroup = c.room || c.target_group
|
||||
|
||||
return {
|
||||
...c,
|
||||
room_type: roomType,
|
||||
is_group: isGroup,
|
||||
// 群聊显示群名称,单聊显示用户名称
|
||||
name: isGroup
|
||||
? (targetGroup?.room_name || targetGroup?.name || c.name || '未知群聊')
|
||||
: (c.target_user?.name || c.name || '未知用户'),
|
||||
avatar: isGroup
|
||||
? (targetGroup?.room_avatar || targetGroup?.avatar || c.avatar || '')
|
||||
: (c.target_user?.avatar || c.avatar || ''),
|
||||
// 群聊相关字段
|
||||
room_id: isGroup ? (c.room_id || c.target_id) : c.room_id,
|
||||
member_count: targetGroup?.member_count,
|
||||
owner_id: targetGroup?.owner_id,
|
||||
// 映射 room 为 target_group(前端期望的字段名)
|
||||
target_group: targetGroup ? {
|
||||
id: targetGroup.room_id || targetGroup.id,
|
||||
room_id: targetGroup.room_id,
|
||||
room_type: 'group',
|
||||
name: targetGroup.room_name || targetGroup.name,
|
||||
avatar: targetGroup.room_avatar || targetGroup.avatar,
|
||||
owner_id: targetGroup.owner_id,
|
||||
member_count: targetGroup.member_count,
|
||||
created_at: targetGroup.created_at,
|
||||
} : undefined,
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Fetch conversations failed:', error)
|
||||
} finally {
|
||||
@@ -39,11 +70,21 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
* @param isCurrentChat 是否是当前选中的聊天,如果是则不增加未读数
|
||||
*/
|
||||
function handleMessageUpdate(message: ChatMessage, isSelf: boolean, isCurrentChat: boolean = false) {
|
||||
const targetId = isSelf ? message.receiver_user_id : message.sender_user_id
|
||||
if (!targetId) return
|
||||
// 群聊和单聊统一使用 room_id 定位会话
|
||||
const roomId = message.room_id
|
||||
if (!roomId) return
|
||||
|
||||
let conv = conversations.value.find(c => c.target_id === targetId)
|
||||
const summary = getMsgSummary(message)
|
||||
// 优先按 room_id 匹配(群聊和单聊都支持)
|
||||
let conv = conversations.value.find(c => c.room_id === roomId)
|
||||
// 如果没有 room_id,回退到按 target_id 匹配(兼容旧逻辑)
|
||||
if (!conv) {
|
||||
const targetId = isSelf ? message.receiver_user_id : message.sender_user_id
|
||||
if (targetId) {
|
||||
conv = conversations.value.find(c => c.target_id === targetId)
|
||||
}
|
||||
}
|
||||
|
||||
const summary = getMessageSummary(message)
|
||||
const now = new Date(message.created_at || Date.now()).getTime()
|
||||
|
||||
if (conv) {
|
||||
@@ -64,9 +105,15 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
|
||||
/**
|
||||
* 增加未读数 (仅前端UI,用于收到消息且不在当前窗口时)
|
||||
* 支持按 room_id 或 target_id 匹配
|
||||
*/
|
||||
function incrementUnread(targetId: string) {
|
||||
const conv = conversations.value.find(c => c.target_id === targetId)
|
||||
function incrementUnread(targetIdOrRoomId: string) {
|
||||
// 优先按 room_id 匹配(群聊场景)
|
||||
let conv = conversations.value.find(c => c.room_id === targetIdOrRoomId)
|
||||
// 如果没有找到,再按 target_id 匹配(单聊场景)
|
||||
if (!conv) {
|
||||
conv = conversations.value.find(c => c.target_id === targetIdOrRoomId)
|
||||
}
|
||||
if (conv) {
|
||||
conv.unread_count = (conv.unread_count || 0) + 1
|
||||
}
|
||||
@@ -74,14 +121,20 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
|
||||
/**
|
||||
* 清除未读数 (同步后端)
|
||||
* 支持按 room_id 或 target_id 匹配
|
||||
*/
|
||||
async function clearUnread(targetId: string) {
|
||||
const conv = conversations.value.find(c => c.target_id === targetId)
|
||||
async function clearUnread(targetIdOrRoomId: string) {
|
||||
// 优先按 room_id 匹配(群聊场景)
|
||||
let conv = conversations.value.find(c => c.room_id === targetIdOrRoomId)
|
||||
// 如果没有找到,再按 target_id 匹配(单聊场景)
|
||||
if (!conv) {
|
||||
conv = conversations.value.find(c => c.target_id === targetIdOrRoomId)
|
||||
}
|
||||
if (conv && conv.unread_count > 0) {
|
||||
conv.unread_count = 0
|
||||
// 调用后端接口
|
||||
// 调用后端接口(后端可能需要 target_id,这里传入 target_id 或 room_id)
|
||||
try {
|
||||
await conversationApi.resetUnread(targetId)
|
||||
await conversationApi.resetUnread(conv.target_id || targetIdOrRoomId)
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
}
|
||||
@@ -97,8 +150,7 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
}
|
||||
|
||||
function getMsgSummary(msg: ChatMessage): string {
|
||||
const types: Record<number, string> = { 1: '[图片]', 2: '[语音]', 3: '[视频]', 8: '[文件]', 6: '[通话]' }
|
||||
return types[msg.message_type] || msg.content
|
||||
return getMessageSummary(msg)
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -50,13 +50,17 @@ export interface Contact {
|
||||
user_id: string
|
||||
contact_user_id: string
|
||||
room_id?: string // 房间ID(雪花ID)
|
||||
room_type?: 'p2p' | 'group' // 房间类型
|
||||
is_group?: boolean // 是否为群聊
|
||||
member_count?: number // 群成员数量(群聊时使用)
|
||||
owner_id?: string // 群主ID(群聊时使用)
|
||||
remark_name?: string
|
||||
group_id?: number
|
||||
is_top: boolean
|
||||
is_muted: boolean
|
||||
is_special_care?: boolean
|
||||
is_blocked?: boolean
|
||||
user?: User // 关联的用户信息
|
||||
user?: User // 关联的用户信息(单聊时使用)
|
||||
last_msg?: string
|
||||
last_time?: number
|
||||
unread?: number
|
||||
@@ -87,10 +91,13 @@ export interface ContactGroup {
|
||||
// 房间
|
||||
export interface Room {
|
||||
id: string
|
||||
room_id?: string // 房间ID(雪花ID,用于群聊)
|
||||
room_type: 'p2p' | 'group'
|
||||
name?: string
|
||||
avatar?: string
|
||||
members: string[]
|
||||
owner_id?: string // 群主ID
|
||||
member_count?: number // 成员数量
|
||||
created_at: string
|
||||
}
|
||||
|
||||
@@ -100,7 +107,7 @@ export interface ChatMessage {
|
||||
room_id: string
|
||||
sender_user_id: string
|
||||
receiver_user_id?: string
|
||||
message_type: number // 0:文本 1:图片 2:语音 3:视频 8:文件 6:信令
|
||||
message_type: number // 0:文本 1:图片 2:语音 3:视频 4:系统 5:好友通知 6:信令 7:群通知 8:文件 9:朋友圈通知
|
||||
content: string
|
||||
duration: number
|
||||
extra?: string | Record<string, any>
|
||||
@@ -144,3 +151,25 @@ export interface ICEServerConfig {
|
||||
credential?: string
|
||||
}
|
||||
|
||||
// 群成员信息
|
||||
export interface GroupMember {
|
||||
user_id: string
|
||||
room_id: string
|
||||
role: number // 0:成员 1:管理员 2:群主
|
||||
nickname?: string // 群名片
|
||||
joined_at?: string
|
||||
user?: User // 关联的用户信息
|
||||
}
|
||||
|
||||
// 群信息(扩展Room)
|
||||
export interface GroupInfo extends Room {
|
||||
room_id: string // 群ID(group_xxx格式)
|
||||
room_type: 'group'
|
||||
name: string // 群名称
|
||||
avatar?: string
|
||||
owner_id: string // 群主ID
|
||||
member_count: number // 成员数量
|
||||
members?: GroupMember[] // 成员列表
|
||||
created_at: string
|
||||
}
|
||||
|
||||
|
||||
@@ -7,8 +7,13 @@ import type { User } from './api'
|
||||
export interface Conversation {
|
||||
id: number // 数据库主键ID
|
||||
user_id: string // 所属用户ID
|
||||
target_id: string // 目标ID (好友ID)
|
||||
target_id: string // 目标ID (好友ID 或 群ID)
|
||||
type: number // 1:私聊 2:群聊
|
||||
room_type?: 'p2p' | 'group' // 房间类型(前端映射)
|
||||
room_id?: string // 房间ID(群聊时为 group_xxx,单聊时可为拼接ID)
|
||||
is_group?: boolean // 是否为群聊(前端辅助字段)
|
||||
member_count?: number // 群成员数量(群聊时使用)
|
||||
owner_id?: string // 群主ID(群聊时使用)
|
||||
name?: string // 前端辅助字段:显示名称
|
||||
avatar?: string // 前端辅助字段:头像
|
||||
unread_count: number // 未读数
|
||||
@@ -19,5 +24,6 @@ export interface Conversation {
|
||||
last_time: number // 最后消息时间戳 (毫秒)
|
||||
|
||||
// 关联数据
|
||||
target_user?: User
|
||||
target_user?: User // 单聊时的目标用户
|
||||
target_group?: Room // 群聊时的群信息(可选)
|
||||
}
|
||||
|
||||
@@ -3,12 +3,16 @@
|
||||
*/
|
||||
|
||||
export enum MessageType {
|
||||
TEXT = 0, // 文本
|
||||
IMAGE = 1, // 图片
|
||||
AUDIO = 2, // 语音
|
||||
VIDEO = 3, // 视频
|
||||
SIGNAL = 6, // WebRTC信令
|
||||
FILE = 8, // 文件
|
||||
TEXT = 0, // 文本
|
||||
IMAGE = 1, // 图片
|
||||
AUDIO = 2, // 语音
|
||||
VIDEO = 3, // 视频
|
||||
SYSTEM = 4, // 系统消息
|
||||
FRIEND_NOTIFY = 5, // 好友通知
|
||||
SIGNAL = 6, // WebRTC信令
|
||||
GROUP_NOTIFY = 7, // 群通知
|
||||
FILE = 8, // 文件
|
||||
MOMENTS_NOTIFY = 9, // 朋友圈通知(预留)
|
||||
}
|
||||
|
||||
export enum CallStatus {
|
||||
@@ -31,5 +35,10 @@ export interface MessageExtra {
|
||||
description?: string
|
||||
image?: string
|
||||
type?: 'video' | 'audio'
|
||||
// 系统/通知消息相关字段
|
||||
event?: string // 事件类型,如 'member_join', 'member_leave' 等
|
||||
operator_id?: string // 操作者ID
|
||||
target_id?: string // 目标用户ID
|
||||
attachment_id?: number // 附件ID
|
||||
}
|
||||
|
||||
|
||||
138
src/utils/messageTypes.ts
Normal file
138
src/utils/messageTypes.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* 消息类型映射工具
|
||||
* 统一管理消息类型的显示文案、图标和摘要生成逻辑
|
||||
*/
|
||||
|
||||
import type { ChatMessage } from '@/types/api'
|
||||
import { MessageType } from '@/types/message'
|
||||
|
||||
export interface MessageTypeConfig {
|
||||
label: string
|
||||
icon: string
|
||||
summaryFn: (msg: ChatMessage) => string
|
||||
}
|
||||
|
||||
/**
|
||||
* 消息类型配置映射表
|
||||
*/
|
||||
export const messageTypeConfigs: Record<number, MessageTypeConfig> = {
|
||||
[MessageType.TEXT]: {
|
||||
label: '文本消息',
|
||||
icon: 'fas fa-comment',
|
||||
summaryFn: (msg) => msg.content || ''
|
||||
},
|
||||
[MessageType.IMAGE]: {
|
||||
label: '图片',
|
||||
icon: 'fas fa-image',
|
||||
summaryFn: () => '[图片]'
|
||||
},
|
||||
[MessageType.AUDIO]: {
|
||||
label: '语音',
|
||||
icon: 'fas fa-microphone',
|
||||
summaryFn: () => '[语音]'
|
||||
},
|
||||
[MessageType.VIDEO]: {
|
||||
label: '视频',
|
||||
icon: 'fas fa-video',
|
||||
summaryFn: () => '[视频]'
|
||||
},
|
||||
[MessageType.SYSTEM]: {
|
||||
label: '系统消息',
|
||||
icon: 'fas fa-info-circle',
|
||||
summaryFn: (msg) => {
|
||||
const extra = typeof msg.extra === 'string' ? JSON.parse(msg.extra || '{}') : (msg.extra || {})
|
||||
return extra.title || msg.content || '[系统消息]'
|
||||
}
|
||||
},
|
||||
[MessageType.FRIEND_NOTIFY]: {
|
||||
label: '好友通知',
|
||||
icon: 'fas fa-user-plus',
|
||||
summaryFn: (msg) => {
|
||||
const extra = typeof msg.extra === 'string' ? JSON.parse(msg.extra || '{}') : (msg.extra || {})
|
||||
return extra.title || msg.content || '[好友通知]'
|
||||
}
|
||||
},
|
||||
[MessageType.SIGNAL]: {
|
||||
label: '通话',
|
||||
icon: 'fas fa-phone',
|
||||
summaryFn: () => '[通话]'
|
||||
},
|
||||
[MessageType.GROUP_NOTIFY]: {
|
||||
label: '群通知',
|
||||
icon: 'fas fa-users',
|
||||
summaryFn: (msg) => {
|
||||
const extra = typeof msg.extra === 'string' ? JSON.parse(msg.extra || '{}') : (msg.extra || {})
|
||||
// 根据事件类型生成更具体的文案
|
||||
if (extra.event === 'member_join') {
|
||||
return `用户加入了群聊`
|
||||
} else if (extra.event === 'member_leave') {
|
||||
return `用户退出了群聊`
|
||||
} else if (extra.event === 'member_remove') {
|
||||
return `用户被移出群聊`
|
||||
} else if (extra.event === 'group_update') {
|
||||
return `群信息已更新`
|
||||
} else if (extra.event === 'role_change') {
|
||||
return `成员角色已变更`
|
||||
}
|
||||
return extra.title || msg.content || '[群通知]'
|
||||
}
|
||||
},
|
||||
[MessageType.FILE]: {
|
||||
label: '文件',
|
||||
icon: 'fas fa-file',
|
||||
summaryFn: (msg) => {
|
||||
const extra = typeof msg.extra === 'string' ? JSON.parse(msg.extra || '{}') : (msg.extra || {})
|
||||
return extra.name ? `[文件] ${extra.name}` : '[文件]'
|
||||
}
|
||||
},
|
||||
[MessageType.MOMENTS_NOTIFY]: {
|
||||
label: '朋友圈通知',
|
||||
icon: 'fas fa-heart',
|
||||
summaryFn: (msg) => {
|
||||
const extra = typeof msg.extra === 'string' ? JSON.parse(msg.extra || '{}') : (msg.extra || {})
|
||||
return extra.title || msg.content || '[朋友圈通知]'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取消息摘要
|
||||
*/
|
||||
export function getMessageSummary(msg: ChatMessage): string {
|
||||
const config = messageTypeConfigs[msg.message_type]
|
||||
if (config) {
|
||||
return config.summaryFn(msg)
|
||||
}
|
||||
return msg.content || '[未知消息]'
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取消息类型标签
|
||||
*/
|
||||
export function getMessageTypeLabel(messageType: number): string {
|
||||
return messageTypeConfigs[messageType]?.label || '未知消息'
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取消息类型图标
|
||||
*/
|
||||
export function getMessageTypeIcon(messageType: number): string {
|
||||
return messageTypeConfigs[messageType]?.icon || 'fas fa-question-circle'
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为系统/通知类消息(需要特殊样式)
|
||||
*/
|
||||
export function isSystemOrNotifyMessage(messageType: number): boolean {
|
||||
return [
|
||||
MessageType.SYSTEM,
|
||||
MessageType.FRIEND_NOTIFY,
|
||||
MessageType.GROUP_NOTIFY,
|
||||
MessageType.MOMENTS_NOTIFY
|
||||
].includes(messageType)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -41,6 +41,15 @@
|
||||
class="w-full md:w-80 bg-panel border-r border-gray-800 flex flex-col z-10 h-full shrink-0"
|
||||
>
|
||||
<div class="p-5 border-b border-gray-800">
|
||||
<div class="flex gap-2 mb-2">
|
||||
<button
|
||||
class="flex-1 px-4 py-2 rounded-xl bg-primary hover:bg-indigo-600 text-white transition flex items-center justify-center gap-2 text-sm"
|
||||
@click="showCreateGroupModal = true"
|
||||
>
|
||||
<i class="fas fa-users"></i>
|
||||
<span>发起群聊</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="relative group">
|
||||
<i class="fas fa-search absolute left-4 top-3 text-gray-500 group-focus-within:text-primary transition"></i>
|
||||
<input
|
||||
@@ -81,8 +90,16 @@
|
||||
class="transition transform group-hover:scale-105 shadow-md"
|
||||
:class="{'ring-2 ring-pink-500/50': conv.is_special_care}"
|
||||
/>
|
||||
<!-- 特别关心标识(头像角标) -->
|
||||
<div v-if="conv.is_special_care" class="absolute -bottom-1 -right-1 bg-pink-500 text-white rounded-full p-0.5 border-2 border-panel shadow-sm">
|
||||
<!-- 群聊标识(头像右上角) -->
|
||||
<div v-if="conv.is_group || conv.room_type === 'group'" class="absolute -top-1 -right-1 w-4 h-4 bg-primary rounded-full flex items-center justify-center border-2 border-panel shadow-sm">
|
||||
<i class="fas fa-users text-[8px] text-white"></i>
|
||||
</div>
|
||||
<!-- 特别关心标识(头像角标,在群聊标识下方) -->
|
||||
<div v-if="conv.is_special_care && !(conv.is_group || conv.room_type === 'group')" class="absolute -bottom-1 -right-1 bg-pink-500 text-white rounded-full p-0.5 border-2 border-panel shadow-sm">
|
||||
<i class="fas fa-heart text-[8px] block"></i>
|
||||
</div>
|
||||
<!-- 特别关心标识(头像角标,群聊时在左下角) -->
|
||||
<div v-if="conv.is_special_care && (conv.is_group || conv.room_type === 'group')" class="absolute -bottom-1 -left-1 bg-pink-500 text-white rounded-full p-0.5 border-2 border-panel shadow-sm">
|
||||
<i class="fas fa-heart text-[8px] block"></i>
|
||||
</div>
|
||||
</div>
|
||||
@@ -92,7 +109,7 @@
|
||||
<div class="flex justify-between items-center mb-0.5">
|
||||
<!-- 昵称 -->
|
||||
<span
|
||||
class="font-semibold truncate text-sm transition flex items-center gap-1"
|
||||
class="font-semibold truncate text-sm transition"
|
||||
:class="conv.is_special_care ? 'text-pink-400' : 'text-gray-200 group-hover:text-white'"
|
||||
>
|
||||
{{ conv.displayName }}
|
||||
@@ -137,8 +154,10 @@
|
||||
<!-- 右侧主聊天区 -->
|
||||
<div
|
||||
v-if="chatStore.currentTarget && (currentTab === 'chat' && (!isMobile || chatVisible))"
|
||||
class="flex-1 flex flex-col bg-dark relative w-full h-full overflow-hidden"
|
||||
class="flex-1 flex bg-dark relative w-full h-full overflow-hidden"
|
||||
>
|
||||
<!-- 聊天内容区域 -->
|
||||
<div class="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||
<!-- 聊天头部 -->
|
||||
<div class="h-16 border-b border-gray-800 flex justify-between items-center px-6 bg-panel/90 backdrop-blur shrink-0 z-20 shadow-sm">
|
||||
<div class="flex items-center gap-3 cursor-pointer group" @click="backToList">
|
||||
@@ -153,15 +172,19 @@
|
||||
/>
|
||||
<div>
|
||||
<h3 class="font-bold text-sm md:text-base text-gray-100 flex items-center gap-2">
|
||||
{{ chatStore.currentTarget.remark_name || chatStore.currentTarget.user?.name }}
|
||||
<span class="w-2 h-2 bg-success rounded-full animate-pulse shadow-[0_0_8px_rgba(16,185,129,0.5)]"></span>
|
||||
{{ getCurrentTargetName() }}
|
||||
<span v-if="!isGroupChat" class="w-2 h-2 bg-success rounded-full animate-pulse shadow-[0_0_8px_rgba(16,185,129,0.5)]"></span>
|
||||
<span v-else class="text-xs text-gray-400">({{ chatStore.currentTarget.member_count || 0 }}人)</span>
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 text-gray-400">
|
||||
<button class="action-btn" title="语音通话" @click="startCall('audio')"><i class="fas fa-phone"></i></button>
|
||||
<button class="action-btn" title="视频通话" @click="startCall('video')"><i class="fas fa-video"></i></button>
|
||||
<!-- 单聊才显示通话按钮 -->
|
||||
<template v-if="!isGroupChat">
|
||||
<button class="action-btn" title="语音通话" @click="startCall('audio')"><i class="fas fa-phone"></i></button>
|
||||
<button class="action-btn" title="视频通话" @click="startCall('video')"><i class="fas fa-video"></i></button>
|
||||
</template>
|
||||
<button class="action-btn" @click.stop="showChatOptionsMenu($event, chatStore.currentTarget)"><i class="fas fa-ellipsis-v"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -175,6 +198,7 @@
|
||||
:target="chatStore.currentTarget"
|
||||
:loading-history="loadingHistory"
|
||||
:has-more-history="chatStore.currentTarget ? (hasMoreHistory[getRoomId(chatStore.currentTarget)] !== false) : true"
|
||||
:room-members="roomMembers"
|
||||
@scroll="handleScroll"
|
||||
@load-more="loadMoreMessages"
|
||||
/>
|
||||
@@ -203,6 +227,16 @@
|
||||
@record-stop="handleRecordStop"
|
||||
@record-cancel="handleRecordCancel"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 右侧群面板(仅群聊显示) -->
|
||||
<GroupChatPanel
|
||||
v-if="isGroupChat && chatStore.currentTarget?.room_id"
|
||||
:room-id="chatStore.currentTarget.room_id"
|
||||
@member-removed="handleGroupMemberRemoved"
|
||||
@member-clicked="handleGroupMemberClicked"
|
||||
@group-updated="handleGroupUpdated"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
@@ -224,7 +258,7 @@
|
||||
>
|
||||
<!-- 左侧联系人列表 -->
|
||||
<div class="w-80 border-r border-gray-800 shrink-0">
|
||||
<ContactCircle />
|
||||
<ContactCircle @select-chat="selectChat" />
|
||||
</div>
|
||||
|
||||
<!-- 右侧 -->
|
||||
@@ -252,6 +286,25 @@
|
||||
</div>
|
||||
<ContextMenu />
|
||||
<FileConfirmModal :show="fileModal.show" :type="fileModal.type" :preview="fileModal.preview" :name="fileModal.name" :size="fileModal.size" @close="fileModal.show = false" @confirm="confirmSendFile" />
|
||||
|
||||
<!-- 创建群聊弹窗 -->
|
||||
<SelectContactsModal
|
||||
:show="showCreateGroupModal"
|
||||
:contacts="chatStore.contacts"
|
||||
@close="showCreateGroupModal = false"
|
||||
@create="handleCreateGroup"
|
||||
/>
|
||||
|
||||
<!-- 群资料面板 -->
|
||||
<GroupInfoPanel
|
||||
v-if="chatStore.currentTarget?.is_group"
|
||||
:show="showGroupInfoPanel"
|
||||
:room-id="chatStore.currentTarget.room_id || ''"
|
||||
@close="showGroupInfoPanel = false"
|
||||
@updated="handleGroupUpdated"
|
||||
@quit="handleGroupQuit"
|
||||
@dissolve="handleGroupDissolve"
|
||||
/>
|
||||
|
||||
<!-- Profile Modal -->
|
||||
<div v-if="showProfileModal && authStore.user" class="fixed inset-0 bg-black/80 z-[60] flex items-center justify-center p-4 backdrop-blur-sm" @click.self="showProfileModal = false">
|
||||
@@ -286,7 +339,8 @@ import { wsManager } from '@/api/websocket'
|
||||
import * as messageApi from '@/api/modules/message'
|
||||
import * as contactApi from '@/api/modules/contact'
|
||||
import * as attachmentApi from '@/api/modules/attachment'
|
||||
import { formatTime } from '@/utils/format'
|
||||
import { formatTime, generateColor } from '@/utils/format'
|
||||
import { getMessageSummary } from '@/utils/messageTypes'
|
||||
import { storage } from '@/utils/storage'
|
||||
import type { Contact, ChatMessage } from '@/types/api'
|
||||
import type { Conversation } from '@/types/conversation'
|
||||
@@ -302,6 +356,10 @@ import ContactCircle from '@/views/contact/ContactCircle.vue'
|
||||
import ContactDetailCard from '@/views/contact/ContactDetailCard.vue'
|
||||
import FriendNotifyView from '@/views/contact/FriendNotifyView.vue'
|
||||
import GroupNotifyView from '@/views/contact/GroupNotifyView.vue'
|
||||
import SelectContactsModal from '@/components/chat/SelectContactsModal.vue'
|
||||
import GroupInfoPanel from '@/components/chat/GroupInfoPanel.vue'
|
||||
import GroupChatPanel from '@/components/chat/GroupChatPanel.vue'
|
||||
import * as groupApi from '@/api/modules/room'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
@@ -322,6 +380,9 @@ const isMobile = ref(window.innerWidth < 768)
|
||||
const showProfileModal = ref(false)
|
||||
const isRecording = ref(false)
|
||||
const fileModal = ref({ show: false, type: 0, preview: '', name: '', size: 0, file: null as File | null })
|
||||
const showCreateGroupModal = ref(false)
|
||||
const showGroupInfoPanel = ref(false)
|
||||
const roomMembers = ref<Record<string, { name: string; avatar?: string }>>({})
|
||||
|
||||
// Scroll Logic State
|
||||
const showScrollBottomBtn = ref(false)
|
||||
@@ -352,9 +413,13 @@ const filteredConversations = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
// 2. 搜索过滤
|
||||
// 2. 搜索过滤(支持群名称和用户名称)
|
||||
if (query) {
|
||||
convs = convs.filter(c => c.displayName.toLowerCase().includes(query))
|
||||
convs = convs.filter(c => {
|
||||
const name = c.displayName.toLowerCase()
|
||||
const memberCount = c.member_count ? `${c.member_count}人` : ''
|
||||
return name.includes(query) || memberCount.includes(query)
|
||||
})
|
||||
}
|
||||
|
||||
// 3. 排序:置顶优先 > 时间倒序
|
||||
@@ -378,6 +443,18 @@ function getRoomId(contact: Contact): string {
|
||||
return userIds.join('_')
|
||||
}
|
||||
|
||||
// 判断是否为群聊
|
||||
const isGroupChat = computed(() => chatStore.currentTarget?.is_group || chatStore.currentTarget?.room_type === 'group')
|
||||
|
||||
// 获取当前目标名称
|
||||
function getCurrentTargetName(): string {
|
||||
if (!chatStore.currentTarget) return ''
|
||||
if (isGroupChat.value) {
|
||||
return chatStore.currentTarget.remark_name || chatStore.currentTarget.user?.name || '群聊'
|
||||
}
|
||||
return chatStore.currentTarget.remark_name || chatStore.currentTarget.user?.name || '未知'
|
||||
}
|
||||
|
||||
// 滚动处理
|
||||
function handleScroll(e: Event) {
|
||||
const target = e.target as HTMLElement
|
||||
@@ -416,6 +493,11 @@ async function selectChat(contact: Contact) {
|
||||
chatVisible.value = true
|
||||
unreadCount.value = 0
|
||||
|
||||
// 如果是群聊,加载群成员信息
|
||||
if (contact.is_group || contact.room_type === 'group') {
|
||||
await loadGroupMembers(roomId)
|
||||
}
|
||||
|
||||
hasMoreHistory.value[roomId] = true
|
||||
|
||||
const existingMessages = chatStore.getRoomMessages(roomId)
|
||||
@@ -454,6 +536,23 @@ async function selectChat(contact: Contact) {
|
||||
}
|
||||
}
|
||||
|
||||
// 加载群成员信息
|
||||
async function loadGroupMembers(roomId: string) {
|
||||
try {
|
||||
const members = await groupApi.getGroupMembers(roomId)
|
||||
const membersMap: Record<string, { name: string; avatar?: string }> = {}
|
||||
members.forEach(m => {
|
||||
membersMap[m.user_id] = {
|
||||
name: m.user?.name || m.nickname || '未知',
|
||||
avatar: m.user?.avatar
|
||||
}
|
||||
})
|
||||
roomMembers.value[roomId] = membersMap
|
||||
} catch (error) {
|
||||
console.error('Failed to load group members:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMoreMessages() {
|
||||
if (!chatStore.currentTarget || loadingHistory.value) return
|
||||
|
||||
@@ -509,6 +608,33 @@ async function loadMoreMessages() {
|
||||
}
|
||||
|
||||
async function selectChatByConversation(conv: Conversation) {
|
||||
// 如果是群聊,从会话的 room 信息创建 Contact
|
||||
if (conv.type === 2 || conv.is_group) {
|
||||
const room = (conv as any).room || conv.target_group
|
||||
if (room) {
|
||||
const groupContact: Contact = {
|
||||
id: room.room_id || conv.target_id,
|
||||
user_id: room.room_id || conv.target_id,
|
||||
contact_user_id: room.room_id || conv.target_id,
|
||||
room_id: room.room_id || conv.room_id || conv.target_id,
|
||||
room_type: 'group',
|
||||
is_group: true,
|
||||
remark_name: room.room_name || room.name || '未知群聊',
|
||||
is_top: conv.is_top,
|
||||
is_muted: conv.is_muted,
|
||||
user: {
|
||||
id: room.room_id || conv.target_id,
|
||||
name: room.room_name || room.name || '未知群聊',
|
||||
avatar: room.room_avatar || room.avatar || '',
|
||||
} as any,
|
||||
}
|
||||
await selectChat(groupContact)
|
||||
await conversationStore.clearUnread(conv.target_id)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 私聊:从联系人列表查找
|
||||
const contact = chatStore.contacts.find(
|
||||
c => c.user_id === conv.target_id || c.id === conv.target_id,
|
||||
)
|
||||
@@ -522,15 +648,20 @@ async function selectChatByConversation(conv: Conversation) {
|
||||
async function sendMessage(type: number, content: string, extra: any = {}, duration = 0) {
|
||||
if (!chatStore.currentTarget) return
|
||||
const roomId = getRoomId(chatStore.currentTarget)
|
||||
const isGroup = chatStore.currentTarget.is_group || chatStore.currentTarget.room_type === 'group'
|
||||
|
||||
// 群聊时 receiver_user_id 应该为空或群ID,确保后端能正确识别为群聊
|
||||
const receiverUserId = isGroup ? '' : (chatStore.currentTarget.user_id || chatStore.currentTarget.id)
|
||||
|
||||
const payload = {
|
||||
sender_client_id: wsManager.getClientId() || '',
|
||||
receiver_user_id: chatStore.currentTarget.user_id || chatStore.currentTarget.id,
|
||||
receiver_user_id: receiverUserId,
|
||||
room_id: roomId, message_type: type, content, duration, extra: JSON.stringify(extra)
|
||||
}
|
||||
|
||||
const message: ChatMessage = {
|
||||
id: Date.now(), room_id: roomId, sender_user_id: authStore.user!.id,
|
||||
receiver_user_id: chatStore.currentTarget.user_id || chatStore.currentTarget.id,
|
||||
receiver_user_id: receiverUserId,
|
||||
message_type: type, content, duration, extra, created_at: new Date().toISOString(), isSelf: true
|
||||
}
|
||||
chatStore.addMessage(roomId, message)
|
||||
@@ -583,7 +714,7 @@ function handleWebSocketMessage(message: ChatMessage) {
|
||||
|
||||
function backToList() { if (isMobile.value) chatVisible.value = false }
|
||||
function handleLogout() { authStore.logout(); wsManager.disconnect(); router.push('/login') }
|
||||
function getMsgSummary(msg: ChatMessage): string { const types: Record<number, string> = { 1: '[图片]', 2: '[语音]', 3: '[视频]', 8: '[文件]' }; return types[msg.message_type] || msg.content }
|
||||
function getMsgSummary(msg: ChatMessage): string { return getMessageSummary(msg) }
|
||||
async function startCall(type: 'audio' | 'video') {
|
||||
if (!chatStore.currentTarget) return
|
||||
const receiverUserId = chatStore.currentTarget.user_id || chatStore.currentTarget.id
|
||||
@@ -650,7 +781,32 @@ async function handleMarkRead(conv: Conversation) {
|
||||
|
||||
// ----------------------------------------------
|
||||
|
||||
function showChatOptionsMenu(event: MouseEvent, contact: Contact) { showContextMenu(event, [{ label: '清空记录', icon: 'fas fa-eraser', danger: true, action: () => chatStore.clearRoomMessages(getRoomId(contact)) }]) }
|
||||
function showChatOptionsMenu(event: MouseEvent, contact: Contact) {
|
||||
const menuItems = [
|
||||
{
|
||||
label: contact.is_group ? '群资料' : '清空记录',
|
||||
icon: contact.is_group ? 'fas fa-info-circle' : 'fas fa-eraser',
|
||||
action: () => {
|
||||
if (contact.is_group) {
|
||||
showGroupInfoPanel.value = true
|
||||
} else {
|
||||
chatStore.clearRoomMessages(getRoomId(contact))
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
if (contact.is_group) {
|
||||
menuItems.push({
|
||||
label: '清空记录',
|
||||
icon: 'fas fa-eraser',
|
||||
danger: true,
|
||||
action: () => chatStore.clearRoomMessages(getRoomId(contact))
|
||||
})
|
||||
}
|
||||
|
||||
showContextMenu(event, menuItems)
|
||||
}
|
||||
function handleSendText() { if (!inputText.value.trim() || !chatStore.currentTarget) return; sendMessage(0, inputText.value); inputText.value = '' }
|
||||
function handleFileSelect(type: number, file: File) {
|
||||
fileModal.value = { show: true, type, preview: '', name: file.name, size: file.size, file }
|
||||
@@ -672,6 +828,96 @@ async function handleRecordStop(blob: Blob, duration: number) { isRecording.valu
|
||||
function handleRecordCancel() { isRecording.value = false }
|
||||
async function loadContacts() { try { const contacts = await contactApi.getContacts(); chatStore.setContacts(contacts) } catch (e) { console.error(e) } }
|
||||
|
||||
// 创建群聊
|
||||
async function handleCreateGroup(data: { member_ids: string[]; name: string; avatar?: string }) {
|
||||
try {
|
||||
// 添加当前用户到成员列表
|
||||
const memberIds = [...data.member_ids]
|
||||
if (!memberIds.includes(authStore.user!.id)) {
|
||||
memberIds.push(authStore.user!.id)
|
||||
}
|
||||
|
||||
const group = await groupApi.createGroup({
|
||||
name: data.name,
|
||||
avatar: data.avatar,
|
||||
member_ids: memberIds
|
||||
})
|
||||
|
||||
toastStore.success('群聊创建成功')
|
||||
showCreateGroupModal.value = false
|
||||
|
||||
// 重新加载会话列表
|
||||
await conversationStore.loadConversations()
|
||||
|
||||
// 刷新联系人列表
|
||||
const contacts = await contactApi.getContacts()
|
||||
chatStore.setContacts(contacts)
|
||||
|
||||
// 创建群聊联系人对象并进入聊天
|
||||
const groupContact: Contact = {
|
||||
id: group.room_id || group.id,
|
||||
user_id: group.room_id || group.id,
|
||||
contact_user_id: '',
|
||||
room_id: group.room_id || group.id,
|
||||
room_type: 'group',
|
||||
is_group: true,
|
||||
member_count: group.member_count || memberIds.length,
|
||||
owner_id: group.owner_id,
|
||||
remark_name: group.name,
|
||||
is_top: false,
|
||||
is_muted: false,
|
||||
user: undefined,
|
||||
color: generateColor(group.room_id || group.id)
|
||||
}
|
||||
|
||||
await selectChat(groupContact)
|
||||
} catch (error: any) {
|
||||
console.error('Failed to create group:', error)
|
||||
toastStore.error('创建群聊失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 群信息更新处理
|
||||
async function handleGroupUpdated() {
|
||||
// 重新加载会话列表和群成员
|
||||
await conversationStore.loadConversations()
|
||||
if (chatStore.currentTarget?.room_id) {
|
||||
await loadGroupMembers(chatStore.currentTarget.room_id)
|
||||
}
|
||||
}
|
||||
|
||||
// 退出群聊处理
|
||||
async function handleGroupQuit() {
|
||||
await conversationStore.loadConversations()
|
||||
chatStore.setCurrentTarget(null)
|
||||
storage.setSelectedConversation('')
|
||||
storage.setSelectedRoomId('')
|
||||
}
|
||||
|
||||
// 解散群聊处理
|
||||
async function handleGroupDissolve() {
|
||||
await conversationStore.loadConversations()
|
||||
chatStore.setCurrentTarget(null)
|
||||
storage.setSelectedConversation('')
|
||||
storage.setSelectedRoomId('')
|
||||
}
|
||||
|
||||
// 群成员移除处理
|
||||
async function handleGroupMemberRemoved() {
|
||||
// 重新加载群成员信息
|
||||
if (chatStore.currentTarget?.room_id) {
|
||||
await loadGroupMembers(chatStore.currentTarget.room_id)
|
||||
}
|
||||
// 刷新会话列表
|
||||
await conversationStore.loadConversations()
|
||||
}
|
||||
|
||||
// 群成员点击处理
|
||||
function handleGroupMemberClicked(member: any) {
|
||||
// 可以在这里实现点击成员后的操作,比如查看成员信息、@成员等
|
||||
console.log('Member clicked:', member)
|
||||
}
|
||||
|
||||
watch(currentTab, (newTab) => {
|
||||
storage.setCurrentTab(newTab)
|
||||
if (newTab === 'contact') {
|
||||
|
||||
@@ -2,14 +2,57 @@
|
||||
<div class="flex-1 flex flex-col bg-panel h-full select-none" @contextmenu.prevent="handleBlankContextMenu">
|
||||
<!-- 顶部搜索框 -->
|
||||
<div class="p-4 border-b border-gray-800/50">
|
||||
<div class="relative group">
|
||||
<i class="fas fa-search absolute left-3 top-1/2 -translate-y-1/2 text-gray-500 group-focus-within:text-primary transition-colors text-sm"></i>
|
||||
<input
|
||||
v-model="searchKeyword"
|
||||
type="text"
|
||||
class="w-full bg-black/20 hover:bg-black/30 focus:bg-black/40 border border-transparent focus:border-primary/30 rounded-xl py-2.5 pl-9 pr-3 text-sm text-white focus:ring-0 outline-none transition-all placeholder-gray-500"
|
||||
placeholder="搜索好友、群组..."
|
||||
/>
|
||||
<div class="flex gap-2">
|
||||
<div class="relative group flex-1">
|
||||
<i class="fas fa-search absolute left-3 top-1/2 -translate-y-1/2 text-gray-500 group-focus-within:text-primary transition-colors text-sm"></i>
|
||||
<input
|
||||
v-model="searchKeyword"
|
||||
type="text"
|
||||
class="w-full bg-black/20 hover:bg-black/30 focus:bg-black/40 border border-transparent focus:border-primary/30 rounded-xl py-2.5 pl-9 pr-3 text-sm text-white focus:ring-0 outline-none transition-all placeholder-gray-500"
|
||||
placeholder="搜索好友、群组..."
|
||||
/>
|
||||
</div>
|
||||
<!-- 加号按钮 -->
|
||||
<div class="relative">
|
||||
<button
|
||||
class="w-10 h-10 flex items-center justify-center bg-primary hover:bg-indigo-600 text-white rounded-xl transition-all shadow-lg shadow-primary/25 active:scale-95"
|
||||
@click="showActionMenu = !showActionMenu"
|
||||
title="快速操作"
|
||||
>
|
||||
<i class="fas fa-plus text-sm"></i>
|
||||
</button>
|
||||
|
||||
<!-- 下拉菜单 -->
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="showActionMenu"
|
||||
class="absolute top-full right-0 mt-2 w-48 bg-[#1e293b] border border-gray-700 rounded-xl shadow-2xl z-50 overflow-hidden"
|
||||
@click.stop
|
||||
>
|
||||
<button
|
||||
class="w-full px-4 py-3 text-left text-sm text-gray-200 hover:bg-white/5 transition flex items-center gap-3"
|
||||
@click="handleCreateGroup"
|
||||
>
|
||||
<i class="fas fa-users text-primary"></i>
|
||||
<span>创建群聊</span>
|
||||
</button>
|
||||
<button
|
||||
class="w-full px-4 py-3 text-left text-sm text-gray-200 hover:bg-white/5 transition flex items-center gap-3"
|
||||
@click="handleJoinGroup"
|
||||
>
|
||||
<i class="fas fa-sign-in-alt text-blue-400"></i>
|
||||
<span>加入群聊</span>
|
||||
</button>
|
||||
<button
|
||||
class="w-full px-4 py-3 text-left text-sm text-gray-200 hover:bg-white/5 transition flex items-center gap-3"
|
||||
@click="handleAddFriend"
|
||||
>
|
||||
<i class="fas fa-user-plus text-emerald-400"></i>
|
||||
<span>添加好友</span>
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -183,11 +226,97 @@
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- 群聊列表占位 -->
|
||||
<div v-else class="flex flex-col items-center justify-center h-full text-gray-500/50">
|
||||
<i class="fas fa-users text-4xl mb-4"></i>
|
||||
<p class="text-xs">群聊功能开发中...</p>
|
||||
</div>
|
||||
<!-- 群聊列表 -->
|
||||
<template v-else>
|
||||
<div v-if="filteredGroupChats.length === 0 && !searchKeyword" class="text-center py-12 text-gray-500/50">
|
||||
<i class="fas fa-users text-4xl mb-3"></i>
|
||||
<p class="text-xs">暂无群聊</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="filteredGroupChats.length === 0 && searchKeyword" class="text-center py-12 text-gray-500">
|
||||
<p class="text-xs">未找到匹配的群聊</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- 我创建的群 -->
|
||||
<div v-if="groupedGroupChats.created.length > 0" class="mb-1">
|
||||
<div
|
||||
class="flex items-center gap-2 px-3 py-2 text-xs font-bold text-gray-500 hover:text-gray-300 cursor-pointer transition-colors select-none"
|
||||
@click="toggleGroupCategoryCollapse('created')"
|
||||
>
|
||||
<i class="fas fa-chevron-right transition-transform duration-200" :class="{ 'rotate-90': !collapsedGroupCategories.includes('created') }"></i>
|
||||
<span>我创建的群 ({{ groupedGroupChats.created.length }})</span>
|
||||
</div>
|
||||
|
||||
<TransitionGroup
|
||||
name="list"
|
||||
tag="div"
|
||||
class="pl-2 space-y-1 overflow-hidden"
|
||||
v-show="!collapsedGroupCategories.includes('created')"
|
||||
>
|
||||
<GroupChatCard
|
||||
v-for="group in groupedGroupChats.created"
|
||||
:key="group.room_id"
|
||||
:group="group"
|
||||
:is-selected="contactStore.selectedContact?.room_id === group.room_id"
|
||||
@click="handleGroupChatClick(group)"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
|
||||
<!-- 我管理的群 -->
|
||||
<div v-if="groupedGroupChats.managed.length > 0" class="mb-1">
|
||||
<div
|
||||
class="flex items-center gap-2 px-3 py-2 text-xs font-bold text-gray-500 hover:text-gray-300 cursor-pointer transition-colors select-none"
|
||||
@click="toggleGroupCategoryCollapse('managed')"
|
||||
>
|
||||
<i class="fas fa-chevron-right transition-transform duration-200" :class="{ 'rotate-90': !collapsedGroupCategories.includes('managed') }"></i>
|
||||
<span>我管理的群 ({{ groupedGroupChats.managed.length }})</span>
|
||||
</div>
|
||||
|
||||
<TransitionGroup
|
||||
name="list"
|
||||
tag="div"
|
||||
class="pl-2 space-y-1 overflow-hidden"
|
||||
v-show="!collapsedGroupCategories.includes('managed')"
|
||||
>
|
||||
<GroupChatCard
|
||||
v-for="group in groupedGroupChats.managed"
|
||||
:key="group.room_id"
|
||||
:group="group"
|
||||
:is-selected="contactStore.selectedContact?.room_id === group.room_id"
|
||||
@click="handleGroupChatClick(group)"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
|
||||
<!-- 我加入的群 -->
|
||||
<div v-if="groupedGroupChats.joined.length > 0" class="mb-1">
|
||||
<div
|
||||
class="flex items-center gap-2 px-3 py-2 text-xs font-bold text-gray-500 hover:text-gray-300 cursor-pointer transition-colors select-none"
|
||||
@click="toggleGroupCategoryCollapse('joined')"
|
||||
>
|
||||
<i class="fas fa-chevron-right transition-transform duration-200" :class="{ 'rotate-90': !collapsedGroupCategories.includes('joined') }"></i>
|
||||
<span>我加入的群 ({{ groupedGroupChats.joined.length }})</span>
|
||||
</div>
|
||||
|
||||
<TransitionGroup
|
||||
name="list"
|
||||
tag="div"
|
||||
class="pl-2 space-y-1 overflow-hidden"
|
||||
v-show="!collapsedGroupCategories.includes('joined')"
|
||||
>
|
||||
<GroupChatCard
|
||||
v-for="group in groupedGroupChats.joined"
|
||||
:key="group.room_id"
|
||||
:group="group"
|
||||
:is-selected="contactStore.selectedContact?.room_id === group.room_id"
|
||||
@click="handleGroupChatClick(group)"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 右键菜单组件 -->
|
||||
@@ -259,32 +388,112 @@
|
||||
@cancel="showDeleteGroupConfirm = false"
|
||||
/>
|
||||
|
||||
<!-- 创建群聊弹窗 -->
|
||||
<SelectContactsModal
|
||||
:show="showCreateGroupModal"
|
||||
:contacts="chatStore.contacts"
|
||||
@close="showCreateGroupModal = false"
|
||||
@create="handleCreateGroupSubmit"
|
||||
/>
|
||||
|
||||
<!-- 加入群聊弹窗 -->
|
||||
<div
|
||||
v-if="showJoinGroupModal"
|
||||
class="fixed inset-0 bg-black/60 backdrop-blur-sm z-[100] flex items-center justify-center p-4"
|
||||
@click.self="showJoinGroupModal = false"
|
||||
>
|
||||
<div class="bg-[#1e293b] border border-gray-700 rounded-xl w-96 shadow-2xl animate-fade-in-up">
|
||||
<div class="p-4 border-b border-gray-700/50 font-bold text-white flex justify-between items-center">
|
||||
<span>加入群聊</span>
|
||||
<button
|
||||
class="text-gray-400 hover:text-white transition"
|
||||
@click="showJoinGroupModal = false"
|
||||
>
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-5 space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm text-gray-400 mb-2">群ID</label>
|
||||
<input
|
||||
v-model="joinGroupRoomId"
|
||||
type="text"
|
||||
class="w-full bg-black/30 text-white px-4 py-2 rounded-lg border border-gray-600 focus:border-primary focus:ring-1 focus:ring-primary outline-none transition"
|
||||
placeholder="请输入群ID(如:group_1234567890)"
|
||||
@keyup.enter="handleJoinGroupSubmit"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-2">请输入完整的群ID,例如:group_1234567890</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex border-t border-gray-700/50">
|
||||
<button
|
||||
class="flex-1 py-3 text-gray-400 hover:bg-white/5 transition rounded-bl-xl"
|
||||
@click="showJoinGroupModal = false"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 py-3 text-primary font-bold hover:bg-primary/10 transition rounded-br-xl"
|
||||
@click="handleJoinGroupSubmit"
|
||||
>
|
||||
加入
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, nextTick } from 'vue'
|
||||
import { ref, computed, onMounted, nextTick, onUnmounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useContactStore } from '@/stores/contact'
|
||||
import { useChatStore } from '@/stores/chat'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useContextMenu } from '@/composables/useContextMenu'
|
||||
import * as contactApi from '@/api/modules/contact'
|
||||
import * as groupApi from '@/api/modules/room'
|
||||
import * as conversationApi from '@/api/modules/conversation'
|
||||
import Avatar from '@/components/common/Avatar.vue'
|
||||
import ContextMenu from '@/components/common/ContextMenu.vue'
|
||||
import ConfirmModal from '@/components/common/ConfirmModal.vue'
|
||||
import SelectContactsModal from '@/components/chat/SelectContactsModal.vue'
|
||||
import GroupChatCard from '@/components/contact/GroupChatCard.vue'
|
||||
import type { Contact, ContactGroup } from '@/types/api'
|
||||
|
||||
const router = useRouter()
|
||||
const contactStore = useContactStore()
|
||||
const chatStore = useChatStore()
|
||||
const toastStore = useToastStore()
|
||||
const authStore = useAuthStore()
|
||||
const { showContextMenu } = useContextMenu()
|
||||
|
||||
// 状态
|
||||
const searchKeyword = ref('')
|
||||
const groups = ref<ContactGroup[]>([])
|
||||
const collapsedGroupIds = ref<number[]>([])
|
||||
const showActionMenu = ref(false)
|
||||
const showCreateGroupModal = ref(false)
|
||||
const showJoinGroupModal = ref(false)
|
||||
|
||||
// 群聊列表状态
|
||||
const groupChats = ref<Array<{
|
||||
room_id: string
|
||||
room_type: string
|
||||
room_name: string
|
||||
room_avatar: string
|
||||
owner_id: string
|
||||
creator_id: string
|
||||
category: 'joined' | 'created' | 'managed'
|
||||
role: number
|
||||
last_message_time?: string
|
||||
last_message?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}>>([])
|
||||
const collapsedGroupCategories = ref<string[]>([])
|
||||
|
||||
// 模态框状态
|
||||
const showGroupModal = ref(false)
|
||||
@@ -329,6 +538,33 @@ const groupedContacts = computed(() => {
|
||||
return map
|
||||
})
|
||||
|
||||
// 群聊分组计算
|
||||
const groupedGroupChats = computed(() => {
|
||||
const map: Record<string, typeof groupChats.value> = {
|
||||
'created': [],
|
||||
'managed': [],
|
||||
'joined': []
|
||||
}
|
||||
|
||||
groupChats.value.forEach(group => {
|
||||
if (map[group.category]) {
|
||||
map[group.category].push(group)
|
||||
}
|
||||
})
|
||||
|
||||
return map
|
||||
})
|
||||
|
||||
// 群聊搜索过滤
|
||||
const filteredGroupChats = computed(() => {
|
||||
const keyword = searchKeyword.value.toLowerCase().trim()
|
||||
if (!keyword) return groupChats.value
|
||||
return groupChats.value.filter(g => {
|
||||
const name = (g.room_name || '').toLowerCase()
|
||||
return name.includes(keyword) || g.room_id.includes(keyword)
|
||||
})
|
||||
})
|
||||
|
||||
// --- API & Actions ---
|
||||
|
||||
async function loadGroups() {
|
||||
@@ -341,6 +577,57 @@ async function loadGroups() {
|
||||
}
|
||||
}
|
||||
|
||||
// 加载群聊列表
|
||||
async function loadGroupChats() {
|
||||
try {
|
||||
console.log('Loading group chats...')
|
||||
const res = await groupApi.getUserGroups()
|
||||
console.log('Group chats response:', res)
|
||||
if (Array.isArray(res)) {
|
||||
groupChats.value = res
|
||||
console.log('Group chats loaded:', groupChats.value.length)
|
||||
} else {
|
||||
console.warn('Invalid response format, expected array:', res)
|
||||
groupChats.value = []
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Load group chats failed', e)
|
||||
groupChats.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function toggleGroupCategoryCollapse(category: string) {
|
||||
const idx = collapsedGroupCategories.value.indexOf(category)
|
||||
idx === -1 ? collapsedGroupCategories.value.push(category) : collapsedGroupCategories.value.splice(idx, 1)
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select-chat', contact: Contact): void
|
||||
}>()
|
||||
|
||||
function handleGroupChatClick(group: typeof groupChats.value[0]) {
|
||||
// 将群聊转换为 Contact 格式并选中
|
||||
const contact: Contact = {
|
||||
id: group.room_id,
|
||||
user_id: group.room_id,
|
||||
contact_user_id: group.room_id,
|
||||
room_id: group.room_id,
|
||||
room_type: 'group',
|
||||
is_group: true,
|
||||
remark_name: group.room_name,
|
||||
is_top: false,
|
||||
is_muted: false,
|
||||
user: {
|
||||
id: group.room_id,
|
||||
name: group.room_name,
|
||||
avatar: group.room_avatar,
|
||||
} as any,
|
||||
}
|
||||
contactStore.setSelectedContact(contact)
|
||||
// 触发切换到聊天视图
|
||||
emit('select-chat', contact)
|
||||
}
|
||||
|
||||
function toggleGroupCollapse(gid: number) {
|
||||
const idx = collapsedGroupIds.value.indexOf(gid)
|
||||
idx === -1 ? collapsedGroupIds.value.push(gid) : collapsedGroupIds.value.splice(idx, 1)
|
||||
@@ -564,8 +851,156 @@ async function submitMoveGroup(groupId: number) {
|
||||
}
|
||||
}
|
||||
|
||||
// 处理创建群聊
|
||||
function handleCreateGroup() {
|
||||
showActionMenu.value = false
|
||||
showCreateGroupModal.value = true
|
||||
}
|
||||
|
||||
// 处理加入群聊
|
||||
function handleJoinGroup() {
|
||||
showActionMenu.value = false
|
||||
showJoinGroupModal.value = true
|
||||
}
|
||||
|
||||
// 处理添加好友
|
||||
function handleAddFriend() {
|
||||
showActionMenu.value = false
|
||||
contactStore.setLeftPanelMode('friend-manager')
|
||||
// 切换到添加好友视图(ContactView)
|
||||
router.push('/contacts').catch(() => {})
|
||||
}
|
||||
|
||||
// 创建群聊处理
|
||||
async function handleCreateGroupSubmit(data: { member_ids: string[]; name: string; avatar?: string }) {
|
||||
try {
|
||||
// 添加当前用户到成员列表
|
||||
const memberIds = [...data.member_ids]
|
||||
if (!memberIds.includes(authStore.user!.id)) {
|
||||
memberIds.push(authStore.user!.id)
|
||||
}
|
||||
|
||||
await groupApi.createGroup({
|
||||
name: data.name,
|
||||
avatar: data.avatar,
|
||||
member_ids: memberIds
|
||||
})
|
||||
|
||||
toastStore.success('群聊创建成功')
|
||||
showCreateGroupModal.value = false
|
||||
|
||||
// 重新加载会话列表
|
||||
const conversations = (await conversationApi.getConversationList()) as unknown as any[]
|
||||
chatStore.setContacts(conversations.map((c: any) => ({
|
||||
id: c.target_id,
|
||||
user_id: c.target_id,
|
||||
contact_user_id: c.target_id,
|
||||
room_id: c.room_id,
|
||||
room_type: c.room_type,
|
||||
is_group: c.is_group,
|
||||
remark_name: c.display_name,
|
||||
is_top: c.is_top,
|
||||
is_muted: c.is_muted,
|
||||
unread: c.unread_count,
|
||||
last_msg: c.last_message,
|
||||
last_time: c.last_message_time ? new Date(c.last_message_time).getTime() : undefined
|
||||
})))
|
||||
|
||||
// 刷新联系人列表
|
||||
const contacts = (await contactApi.getContacts()) as unknown as any[]
|
||||
chatStore.setContacts(contacts)
|
||||
} catch (error: any) {
|
||||
console.error('Failed to create group:', error)
|
||||
toastStore.error('创建群聊失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 加入群聊处理
|
||||
const joinGroupRoomId = ref('')
|
||||
async function handleJoinGroupSubmit() {
|
||||
if (!joinGroupRoomId.value.trim()) {
|
||||
toastStore.warning('请输入群ID')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const roomId = joinGroupRoomId.value.trim()
|
||||
|
||||
// 先获取群信息验证群是否存在
|
||||
await groupApi.getGroup(roomId)
|
||||
|
||||
// 通过邀请成员接口加入群聊(需要后端支持,或者使用专门的加入接口)
|
||||
// 这里先尝试通过邀请自己来加入
|
||||
await groupApi.inviteGroupMembers(roomId, {
|
||||
member_ids: [authStore.user!.id]
|
||||
})
|
||||
|
||||
toastStore.success('已加入群聊')
|
||||
showJoinGroupModal.value = false
|
||||
joinGroupRoomId.value = ''
|
||||
|
||||
// 重新加载会话列表
|
||||
const conversations = (await conversationApi.getConversationList()) as unknown as any[]
|
||||
chatStore.setContacts(conversations.map((c: any) => ({
|
||||
id: c.target_id,
|
||||
user_id: c.target_id,
|
||||
contact_user_id: c.target_id,
|
||||
room_id: c.room_id,
|
||||
room_type: c.room_type,
|
||||
is_group: c.is_group,
|
||||
remark_name: c.display_name,
|
||||
is_top: c.is_top,
|
||||
is_muted: c.is_muted,
|
||||
unread: c.unread_count,
|
||||
last_msg: c.last_message,
|
||||
last_time: c.last_message_time ? new Date(c.last_message_time).getTime() : undefined
|
||||
})))
|
||||
|
||||
// 刷新联系人列表
|
||||
const contacts = (await contactApi.getContacts()) as unknown as Contact[]
|
||||
chatStore.setContacts(contacts)
|
||||
|
||||
// 如果当前在群聊tab,也刷新群聊列表
|
||||
if (contactStore.contactListTab === 'groups') {
|
||||
await loadGroupChats()
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Failed to join group:', error)
|
||||
toastStore.error(error.message || '加入群聊失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 点击外部关闭菜单
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
const target = event.target as HTMLElement
|
||||
if (!target.closest('.relative')) {
|
||||
showActionMenu.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 监听联系人列表tab切换,刷新对应列表
|
||||
watch(() => contactStore.contactListTab, (newTab) => {
|
||||
if (newTab === 'friends') {
|
||||
// 切换到好友tab时,刷新好友列表
|
||||
contactApi.getContacts().then((res) => {
|
||||
chatStore.setContacts((res as unknown as any[]) || [])
|
||||
}).catch((e) => {
|
||||
console.error('Refresh contacts failed', e)
|
||||
})
|
||||
} else if (newTab === 'groups') {
|
||||
// 切换到群聊tab时,刷新群聊列表
|
||||
loadGroupChats()
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadGroups()
|
||||
loadGroupChats()
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -582,4 +1017,14 @@ onMounted(() => {
|
||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
.animate-fade-in-up { animation: fadeInUp 0.2s ease-out forwards; }
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -16,29 +16,41 @@
|
||||
<!-- 更多菜单 Popover -->
|
||||
<transition name="popover">
|
||||
<div v-if="showMoreOptions" class="absolute top-full right-0 mt-2 w-48 bg-[#1e293b] border border-gray-700 rounded-xl shadow-2xl p-1.5 z-50 flex flex-col gap-0.5 origin-top-right">
|
||||
<button class="w-full text-left px-3 py-2.5 rounded-lg flex items-center gap-3 text-sm transition-colors text-gray-300 hover:bg-white/10 hover:text-white" @click="openEditRemark">
|
||||
<i class="fas fa-pen w-5 text-center opacity-70"></i><span>修改备注</span>
|
||||
</button>
|
||||
<button class="w-full text-left px-3 py-2.5 rounded-lg flex items-center gap-3 text-sm transition-colors text-gray-300 hover:bg-white/10 hover:text-white" @click="openGroupSelect">
|
||||
<i class="fas fa-folder-open w-5 text-center opacity-70"></i><span>移动分组</span>
|
||||
</button>
|
||||
<div class="h-px bg-gray-700 my-1 mx-2"></div>
|
||||
<button class="w-full text-left px-3 py-2.5 rounded-lg flex items-center gap-3 text-sm transition-colors text-gray-300 hover:bg-white/10 hover:text-white" @click="handleToggleTop">
|
||||
<i class="fas fa-thumbtack w-5 text-center opacity-70" :class="contact?.is_top ? 'text-primary' : ''"></i>
|
||||
<span>{{ contact?.is_top ? '取消置顶' : '置顶聊天' }}</span>
|
||||
</button>
|
||||
<button class="w-full text-left px-3 py-2.5 rounded-lg flex items-center gap-3 text-sm transition-colors text-gray-300 hover:bg-white/10 hover:text-white" @click="handleToggleMuted">
|
||||
<i class="fas fa-bell w-5 text-center opacity-70" :class="contact?.is_muted ? 'text-yellow-500' : ''"></i>
|
||||
<span>{{ contact?.is_muted ? '开启提醒' : '消息免打扰' }}</span>
|
||||
</button>
|
||||
<button class="w-full text-left px-3 py-2.5 rounded-lg flex items-center gap-3 text-sm transition-colors text-gray-300 hover:bg-white/10 hover:text-white" @click="handleToggleSpecialCare">
|
||||
<i class="far fa-star w-5 text-center opacity-70" :class="contact?.is_special_care ? 'text-yellow-400 fas' : ''"></i>
|
||||
<span>{{ contact?.is_special_care ? '取消特别关心' : '特别关心' }}</span>
|
||||
</button>
|
||||
<div class="h-px bg-gray-700 my-1 mx-2"></div>
|
||||
<button class="w-full text-left px-3 py-2.5 rounded-lg flex items-center gap-3 text-sm transition-colors text-red-400 hover:bg-red-500/10" @click="showDeleteConfirm = true">
|
||||
<i class="fas fa-trash-alt w-5 text-center opacity-70"></i><span>删除好友</span>
|
||||
</button>
|
||||
<template v-if="!isGroupChat">
|
||||
<button class="w-full text-left px-3 py-2.5 rounded-lg flex items-center gap-3 text-sm transition-colors text-gray-300 hover:bg-white/10 hover:text-white" @click="openEditRemark">
|
||||
<i class="fas fa-pen w-5 text-center opacity-70"></i><span>修改备注</span>
|
||||
</button>
|
||||
<button class="w-full text-left px-3 py-2.5 rounded-lg flex items-center gap-3 text-sm transition-colors text-gray-300 hover:bg-white/10 hover:text-white" @click="openGroupSelect">
|
||||
<i class="fas fa-folder-open w-5 text-center opacity-70"></i><span>移动分组</span>
|
||||
</button>
|
||||
<div class="h-px bg-gray-700 my-1 mx-2"></div>
|
||||
<button class="w-full text-left px-3 py-2.5 rounded-lg flex items-center gap-3 text-sm transition-colors text-gray-300 hover:bg-white/10 hover:text-white" @click="handleToggleTop">
|
||||
<i class="fas fa-thumbtack w-5 text-center opacity-70" :class="contact?.is_top ? 'text-primary' : ''"></i>
|
||||
<span>{{ contact?.is_top ? '取消置顶' : '置顶聊天' }}</span>
|
||||
</button>
|
||||
<button class="w-full text-left px-3 py-2.5 rounded-lg flex items-center gap-3 text-sm transition-colors text-gray-300 hover:bg-white/10 hover:text-white" @click="handleToggleMuted">
|
||||
<i class="fas fa-bell w-5 text-center opacity-70" :class="contact?.is_muted ? 'text-yellow-500' : ''"></i>
|
||||
<span>{{ contact?.is_muted ? '开启提醒' : '消息免打扰' }}</span>
|
||||
</button>
|
||||
<button class="w-full text-left px-3 py-2.5 rounded-lg flex items-center gap-3 text-sm transition-colors text-gray-300 hover:bg-white/10 hover:text-white" @click="handleToggleSpecialCare">
|
||||
<i class="far fa-star w-5 text-center opacity-70" :class="contact?.is_special_care ? 'text-yellow-400 fas' : ''"></i>
|
||||
<span>{{ contact?.is_special_care ? '取消特别关心' : '特别关心' }}</span>
|
||||
</button>
|
||||
<div class="h-px bg-gray-700 my-1 mx-2"></div>
|
||||
<button class="w-full text-left px-3 py-2.5 rounded-lg flex items-center gap-3 text-sm transition-colors text-red-400 hover:bg-red-500/10" @click="showDeleteConfirm = true">
|
||||
<i class="fas fa-trash-alt w-5 text-center opacity-70"></i><span>删除好友</span>
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button class="w-full text-left px-3 py-2.5 rounded-lg flex items-center gap-3 text-sm transition-colors text-gray-300 hover:bg-white/10 hover:text-white" @click="handleToggleTop">
|
||||
<i class="fas fa-thumbtack w-5 text-center opacity-70" :class="contact?.is_top ? 'text-primary' : ''"></i>
|
||||
<span>{{ contact?.is_top ? '取消置顶' : '置顶聊天' }}</span>
|
||||
</button>
|
||||
<button class="w-full text-left px-3 py-2.5 rounded-lg flex items-center gap-3 text-sm transition-colors text-gray-300 hover:bg-white/10 hover:text-white" @click="handleToggleMuted">
|
||||
<i class="fas fa-bell w-5 text-center opacity-70" :class="contact?.is_muted ? 'text-yellow-500' : ''"></i>
|
||||
<span>{{ contact?.is_muted ? '开启提醒' : '消息免打扰' }}</span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
@@ -59,35 +71,93 @@
|
||||
<div class="pt-14 pb-8 px-6 text-center flex flex-col items-center animate-fade-in">
|
||||
<div class="relative group cursor-pointer" @click="viewAvatar">
|
||||
<Avatar
|
||||
:name="contact.user?.name || contact.remark_name"
|
||||
:avatar="contact.user?.avatar"
|
||||
:name="isGroupChat ? (groupInfo?.name || contact.remark_name || contact.user?.name) : (contact.user?.name || contact.remark_name)"
|
||||
:avatar="isGroupChat ? (groupInfo?.avatar || contact.user?.avatar) : contact.user?.avatar"
|
||||
:color="contact.color"
|
||||
size="xl"
|
||||
rounded="full"
|
||||
class="border-[6px] border-[#111827] shadow-2xl relative z-10 transition-transform duration-300 group-hover:scale-105"
|
||||
/>
|
||||
<div v-if="contact.user?.is_online" class="absolute bottom-1 right-1 w-5 h-5 bg-emerald-500 rounded-full border-4 border-[#111827] z-20" title="在线"></div>
|
||||
<div v-if="!isGroupChat && contact.user?.is_online" class="absolute bottom-1 right-1 w-5 h-5 bg-emerald-500 rounded-full border-4 border-[#111827] z-20" title="在线"></div>
|
||||
<div v-if="isGroupChat" class="absolute bottom-1 right-1 w-5 h-5 bg-primary rounded-full border-4 border-[#111827] z-20 flex items-center justify-center" title="群聊">
|
||||
<i class="fas fa-users text-[8px] text-white"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="text-2xl font-bold text-white mt-5 flex items-center gap-2 justify-center select-text">
|
||||
{{ contact.remark_name || contact.user?.name }}
|
||||
<span v-if="contact.user?.gender" class="w-5 h-5 rounded-full flex items-center justify-center text-[10px]" :class="contact.user.gender === 1 ? 'bg-blue-500/20 text-blue-400' : 'bg-pink-500/20 text-pink-400'">
|
||||
{{ isGroupChat ? (groupInfo?.name || contact.remark_name || contact.user?.name) : (contact.remark_name || contact.user?.name) }}
|
||||
<span v-if="isGroupChat" class="w-5 h-5 rounded-full flex items-center justify-center text-[10px] bg-primary/20 text-primary">
|
||||
<i class="fas fa-users text-[10px]"></i>
|
||||
</span>
|
||||
<span v-else-if="contact.user?.gender" class="w-5 h-5 rounded-full flex items-center justify-center text-[10px]" :class="contact.user.gender === 1 ? 'bg-blue-500/20 text-blue-400' : 'bg-pink-500/20 text-pink-400'">
|
||||
<i :class="contact.user.gender === 1 ? 'fas fa-mars' : 'fas fa-venus'"></i>
|
||||
</span>
|
||||
</h3>
|
||||
|
||||
<p class="text-gray-400 text-sm mt-2 max-w-md truncate px-4 opacity-80">{{ contact.user?.desc || '暂无签名' }}</p>
|
||||
<p class="text-gray-400 text-sm mt-2 max-w-md truncate px-4 opacity-80">
|
||||
<span v-if="isGroupChat && groupAnnouncement">{{ groupAnnouncement }}</span>
|
||||
<span v-else-if="isGroupChat">暂无群公告</span>
|
||||
<span v-else>{{ contact.user?.desc || '暂无签名' }}</span>
|
||||
</p>
|
||||
|
||||
<div class="flex gap-3 mt-4 text-xs font-medium text-gray-400">
|
||||
<span class="bg-white/5 px-2.5 py-1 rounded-md border border-white/5 select-text">ID: {{ contact.user_id || contact.id }}</span>
|
||||
<span v-if="contact.user?.region" class="bg-white/5 px-2.5 py-1 rounded-md border border-white/5">{{ contact.user.region }}</span>
|
||||
<span class="bg-white/5 px-2.5 py-1 rounded-md border border-white/5 select-text">ID: {{ isGroupChat ? (contact.room_id || contact.id) : (contact.user_id || contact.id) }}</span>
|
||||
<span v-if="isGroupChat && groupInfo?.member_count" class="bg-white/5 px-2.5 py-1 rounded-md border border-white/5">
|
||||
{{ groupInfo.member_count }} 人
|
||||
</span>
|
||||
<span v-else-if="!isGroupChat && contact.user?.region" class="bg-white/5 px-2.5 py-1 rounded-md border border-white/5">{{ contact.user.region }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 详细信息列表 -->
|
||||
<div class="px-6 py-2 space-y-3 max-w-xl mx-auto w-full animate-slide-up">
|
||||
<div class="bg-white/5 rounded-2xl border border-white/5 overflow-hidden">
|
||||
<!-- 群聊信息 -->
|
||||
<div v-if="isGroupChat" class="bg-white/5 rounded-2xl border border-white/5 overflow-hidden">
|
||||
<!-- 群公告 -->
|
||||
<div class="flex items-start p-4 hover:bg-white/5 transition-colors group relative border-b border-white/5">
|
||||
<div class="w-9 h-9 rounded-lg bg-white/5 flex items-center justify-center text-gray-500 mr-4 shrink-0">
|
||||
<i class="fas fa-bullhorn"></i>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-[10px] text-gray-500 uppercase tracking-wider mb-0.5 font-bold">群公告</div>
|
||||
<div class="text-sm text-gray-200 font-medium break-words">{{ groupAnnouncement || '暂无群公告' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 群成员列表 -->
|
||||
<div class="p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="text-[10px] text-gray-500 uppercase tracking-wider font-bold">群成员 ({{ groupMembers.length }})</div>
|
||||
</div>
|
||||
<div class="space-y-2 max-h-96 overflow-y-auto custom-scrollbar">
|
||||
<div
|
||||
v-for="member in groupMembers"
|
||||
:key="member.user_id"
|
||||
class="flex items-center gap-3 p-2 rounded-lg hover:bg-white/5 transition"
|
||||
>
|
||||
<Avatar
|
||||
:name="member.user?.name || member.nickname || '未知'"
|
||||
:avatar="member.user?.avatar"
|
||||
size="sm"
|
||||
rounded="full"
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm text-gray-200 font-medium truncate">
|
||||
{{ member.user?.name || member.nickname || '未知' }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 truncate">
|
||||
<span v-if="member.role === 2" class="text-primary">群主</span>
|
||||
<span v-else-if="member.role === 1" class="text-yellow-500">管理员</span>
|
||||
<span v-else>成员</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 好友信息 -->
|
||||
<div v-else class="bg-white/5 rounded-2xl border border-white/5 overflow-hidden">
|
||||
<!-- 网名 -->
|
||||
<div class="flex items-center p-4 hover:bg-white/5 transition-colors group relative border-b border-white/5 last:border-0">
|
||||
<div class="w-9 h-9 rounded-lg bg-white/5 flex items-center justify-center text-gray-500 mr-4 shrink-0">
|
||||
@@ -163,10 +233,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部快捷操作栏 (3列布局) -->
|
||||
<!-- 底部快捷操作栏 -->
|
||||
<div class="mt-auto p-6 bg-gradient-to-t from-[#0f172a] via-[#0f172a]/80 to-transparent">
|
||||
<div class="grid grid-cols-3 gap-4 max-w-lg mx-auto w-full">
|
||||
<!-- 发消息 -->
|
||||
<div v-if="isGroupChat" class="max-w-lg mx-auto w-full">
|
||||
<!-- 群聊:只显示发消息按钮 -->
|
||||
<button class="w-full flex flex-col items-center justify-center gap-1.5 py-3.5 px-2 rounded-2xl transition-all duration-200 active:scale-95 group bg-primary text-white shadow-lg shadow-primary/25 hover:bg-primary-hover hover:-translate-y-0.5"
|
||||
@click="handleSendMessage"
|
||||
>
|
||||
<i class="fas fa-comment-dots text-xl"></i>
|
||||
<span class="text-xs font-bold">发消息</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="grid grid-cols-3 gap-4 max-w-lg mx-auto w-full">
|
||||
<!-- 好友:显示发消息、语音通话、视频通话 -->
|
||||
<button class="flex flex-col items-center justify-center gap-1.5 py-3.5 px-2 rounded-2xl transition-all duration-200 active:scale-95 group bg-primary text-white shadow-lg shadow-primary/25 hover:bg-primary-hover hover:-translate-y-0.5"
|
||||
@click="handleSendMessage"
|
||||
>
|
||||
@@ -198,7 +277,7 @@
|
||||
<div class="w-32 h-32 rounded-full bg-white/5 flex items-center justify-center mb-6">
|
||||
<i class="fas fa-user-astronaut text-5xl"></i>
|
||||
</div>
|
||||
<p class="text-lg font-medium">选择左侧好友查看详情</p>
|
||||
<p class="text-lg font-medium">选择左侧好友或群聊查看详情</p>
|
||||
</div>
|
||||
|
||||
<!-- 模态框组件 -->
|
||||
@@ -228,7 +307,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmModal :show="showDeleteConfirm" title="删除好友" message="确定要彻底删除该好友吗?此操作不可恢复。" type="danger" confirm-text="确认删除" @confirm="handleDeleteContact" @cancel="showDeleteConfirm = false" />
|
||||
<ConfirmModal v-if="!isGroupChat" :show="showDeleteConfirm" title="删除好友" message="确定要彻底删除该好友吗?此操作不可恢复。" type="danger" confirm-text="确认删除" @confirm="handleDeleteContact" @cancel="showDeleteConfirm = false" />
|
||||
<ConfirmModal :show="showRoomIdErrorModal" title="无法通话" message="无法获取通话线路 (RoomID missing)。" type="warning" :cancel-text="''" @confirm="showRoomIdErrorModal = false" />
|
||||
|
||||
</div>
|
||||
@@ -241,9 +320,10 @@ import { useContactStore } from '@/stores/contact'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import { useWebRTCStore } from '@/stores/webrtc'
|
||||
import * as contactApi from '@/api/modules/contact'
|
||||
import * as groupApi from '@/api/modules/room'
|
||||
import Avatar from '@/components/common/Avatar.vue'
|
||||
import ConfirmModal from '@/components/common/ConfirmModal.vue'
|
||||
import type { Contact, ContactGroup } from '@/types/api'
|
||||
import type { Contact, ContactGroup, GroupInfo, GroupMember } from '@/types/api'
|
||||
|
||||
const emit = defineEmits<{ switchToChat: [] }>()
|
||||
const chatStore = useChatStore()
|
||||
@@ -261,35 +341,61 @@ const showDeleteConfirm = ref(false)
|
||||
const showRoomIdErrorModal = ref(false)
|
||||
const remarkName = ref('')
|
||||
|
||||
// 群聊相关状态
|
||||
const groupInfo = ref<GroupInfo | null>(null)
|
||||
const groupMembers = ref<GroupMember[]>([])
|
||||
const groupAnnouncement = ref<string>('')
|
||||
|
||||
const currentGroup = computed(() => groups.value.find(g => g.id === contact.value?.group_id))
|
||||
const allGroups = computed(() => [{id: 0, group_name: '未分组'}, ...groups.value])
|
||||
const isGroupChat = computed(() => contact.value?.is_group || contact.value?.room_type === 'group')
|
||||
|
||||
// --- 核心逻辑:即时响应 ---
|
||||
watch(() => contactStore.selectedContact, async (newVal) => {
|
||||
if (!newVal) {
|
||||
contact.value = null
|
||||
groupInfo.value = null
|
||||
groupMembers.value = []
|
||||
groupAnnouncement.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
// 1. 立即显示 Store 中的数据(不用等API),解决“请选择好友”的延迟问题
|
||||
// 1. 立即显示 Store 中的数据(不用等API),解决"请选择好友"的延迟问题
|
||||
contact.value = { ...newVal }
|
||||
remarkName.value = newVal.remark_name || ''
|
||||
|
||||
// 2. 后台静默加载完整详情(获取手机号、Region等可能不在列表中的数据)
|
||||
// 2. 检查是否为群聊
|
||||
const isGroup = newVal.is_group || newVal.room_type === 'group'
|
||||
const roomId = newVal.room_id || newVal.id
|
||||
|
||||
// 3. 后台静默加载完整详情
|
||||
loading.value = true
|
||||
try {
|
||||
const [detail, groupList] = await Promise.all([
|
||||
contactApi.getContactDetail(newVal.id),
|
||||
contactApi.getGroups() // 同时刷新分组列表
|
||||
])
|
||||
if (isGroup) {
|
||||
// 群聊:获取群信息、群成员和群公告
|
||||
const [info, members, announcementRes] = await Promise.all([
|
||||
groupApi.getGroup(roomId),
|
||||
groupApi.getGroupMembers(roomId),
|
||||
groupApi.getGroupAnnouncement(roomId).catch(() => ({ announcement: '' })) // 群公告可能不存在,容错处理
|
||||
])
|
||||
groupInfo.value = info
|
||||
groupMembers.value = members
|
||||
groupAnnouncement.value = announcementRes.announcement || ''
|
||||
} else {
|
||||
// 好友:获取好友详情和分组列表
|
||||
const [detail, groupList] = await Promise.all([
|
||||
contactApi.getContactDetail(newVal.id),
|
||||
contactApi.getGroups() // 同时刷新分组列表
|
||||
])
|
||||
|
||||
// 3. 更新为完整数据
|
||||
contact.value = { ...newVal, ...detail }
|
||||
groups.value = groupList
|
||||
// 更新为完整数据
|
||||
contact.value = { ...newVal, ...detail }
|
||||
groups.value = groupList
|
||||
|
||||
// RoomID 容错
|
||||
if (!contact.value.room_id && newVal.room_id) {
|
||||
contact.value.room_id = newVal.room_id
|
||||
// RoomID 容错
|
||||
if (!contact.value.room_id && newVal.room_id) {
|
||||
contact.value.room_id = newVal.room_id
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error('Fetch detail failed, using basic info', e)
|
||||
|
||||
@@ -3,15 +3,69 @@
|
||||
<!-- 头部 -->
|
||||
<div class="p-6 border-b border-gray-800/50 flex justify-between items-center">
|
||||
<h2 class="text-xl font-bold text-white">群通知</h2>
|
||||
<button class="text-gray-400 hover:text-white transition" title="刷新">
|
||||
<i class="fas fa-sync-alt"></i>
|
||||
<button
|
||||
@click="loadNotifications"
|
||||
class="text-gray-400 hover:text-white transition"
|
||||
title="刷新"
|
||||
>
|
||||
<i class="fas fa-sync-alt" :class="{ 'fa-spin': loading }"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 列表区域 -->
|
||||
<div class="flex-1 overflow-y-auto p-4 custom-scrollbar">
|
||||
<div class="flex-1 overflow-y-auto p-4 custom-scrollbar" @scroll="handleScroll">
|
||||
<TransitionGroup name="list" tag="div" class="space-y-3 max-w-3xl mx-auto">
|
||||
<div
|
||||
v-for="notification in notifications"
|
||||
:key="notification.id"
|
||||
class="flex items-start p-4 bg-[#1f2937] rounded-xl border-l-4 shadow-md transition-all hover:bg-[#374151] border-primary"
|
||||
>
|
||||
<div class="w-10 h-10 rounded-full bg-primary/20 flex items-center justify-center shrink-0 mr-4">
|
||||
<i :class="getNotificationIcon(notification)" class="text-primary"></i>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex justify-between items-start mb-1">
|
||||
<span class="text-xs text-gray-500 font-medium">
|
||||
{{ getGroupName(notification.room_id) }}
|
||||
</span>
|
||||
<span class="text-xs text-gray-500">
|
||||
{{ formatTime(new Date(notification.created_at).getTime()) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-gray-200">
|
||||
{{ getNotificationContent(notification) }}
|
||||
</div>
|
||||
|
||||
<!-- 事件详情 -->
|
||||
<div v-if="getEventDetails(notification)" class="mt-2 text-xs text-gray-400 bg-black/20 px-2 py-1 rounded">
|
||||
{{ getEventDetails(notification) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
|
||||
<!-- 加载更多 -->
|
||||
<div v-if="hasMore && !loading" class="flex justify-center py-4">
|
||||
<button
|
||||
@click="loadMore"
|
||||
class="px-4 py-2 rounded-xl bg-gray-700 hover:bg-gray-600 text-white transition text-sm"
|
||||
>
|
||||
加载更多
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 加载中 -->
|
||||
<div v-if="loading && notifications.length === 0" class="flex 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-sm text-gray-400">加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div class="text-center py-20 text-gray-600">
|
||||
<div v-if="!loading && notifications.length === 0" class="text-center py-20 text-gray-600">
|
||||
<i class="fas fa-users-slash text-5xl mb-4 opacity-50"></i>
|
||||
<p class="text-sm">暂无群通知</p>
|
||||
</div>
|
||||
@@ -20,10 +74,164 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 暂时保留为空,后续在此处对接群通知 API
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import { useChatStore } from '@/stores/chat'
|
||||
import * as groupApi from '@/api/modules/room'
|
||||
import { formatTime } from '@/utils/format'
|
||||
import { getMessageSummary } from '@/utils/messageTypes'
|
||||
import type { ChatMessage } from '@/types/api'
|
||||
|
||||
const toastStore = useToastStore()
|
||||
const chatStore = useChatStore()
|
||||
|
||||
interface GroupNotification {
|
||||
id: number
|
||||
room_id: string
|
||||
message_type: number
|
||||
content: string
|
||||
extra: string | object
|
||||
is_read?: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const notifications = ref<GroupNotification[]>([])
|
||||
const loading = ref(false)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const total = ref(0)
|
||||
const hasMore = computed(() => notifications.value.length < total.value)
|
||||
|
||||
// 获取群名称(从联系人列表中查找)
|
||||
function getGroupName(roomId: string): string {
|
||||
const contact = chatStore.contacts.find(c => c.room_id === roomId)
|
||||
return contact?.remark_name || contact?.user?.name || roomId || '未知群聊'
|
||||
}
|
||||
|
||||
// 获取通知图标
|
||||
function getNotificationIcon(notification: GroupNotification): string {
|
||||
const extra = typeof notification.extra === 'string'
|
||||
? JSON.parse(notification.extra || '{}')
|
||||
: (notification.extra || {})
|
||||
|
||||
const event = extra.event
|
||||
switch (event) {
|
||||
case 'member_join':
|
||||
return 'fas fa-user-plus'
|
||||
case 'member_leave':
|
||||
return 'fas fa-user-minus'
|
||||
case 'member_remove':
|
||||
return 'fas fa-user-times'
|
||||
case 'group_dissolve':
|
||||
return 'fas fa-trash'
|
||||
case 'group_update':
|
||||
return 'fas fa-edit'
|
||||
case 'role_change':
|
||||
return 'fas fa-user-shield'
|
||||
default:
|
||||
return 'fas fa-bell'
|
||||
}
|
||||
}
|
||||
|
||||
// 获取通知内容
|
||||
function getNotificationContent(notification: GroupNotification): string {
|
||||
// 使用 messageTypes 工具函数生成摘要
|
||||
const msg: ChatMessage = {
|
||||
id: notification.id,
|
||||
room_id: notification.room_id,
|
||||
sender_user_id: '',
|
||||
message_type: notification.message_type,
|
||||
content: notification.content,
|
||||
extra: notification.extra,
|
||||
duration: 0,
|
||||
created_at: notification.created_at
|
||||
}
|
||||
return getMessageSummary(msg)
|
||||
}
|
||||
|
||||
// 获取事件详情
|
||||
function getEventDetails(notification: GroupNotification): string | null {
|
||||
const extra = typeof notification.extra === 'string'
|
||||
? JSON.parse(notification.extra || '{}')
|
||||
: (notification.extra || {})
|
||||
|
||||
const event = extra.event
|
||||
if (event === 'member_join' || event === 'member_leave' || event === 'member_remove') {
|
||||
if (extra.operator_id && extra.target_id) {
|
||||
return `操作者: ${extra.operator_id}, 目标: ${extra.target_id}`
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// 加载通知列表
|
||||
async function loadNotifications(reset = false) {
|
||||
if (loading.value) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
if (reset) {
|
||||
page.value = 1
|
||||
notifications.value = []
|
||||
}
|
||||
|
||||
const response = await groupApi.getGroupNotifications({
|
||||
page: page.value,
|
||||
page_size: pageSize.value
|
||||
})
|
||||
|
||||
if (reset) {
|
||||
notifications.value = response.data
|
||||
} else {
|
||||
notifications.value.push(...response.data)
|
||||
}
|
||||
|
||||
total.value = response.total
|
||||
} catch (error: any) {
|
||||
console.error('Failed to load group notifications:', error)
|
||||
toastStore.error('加载群通知失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载更多
|
||||
async function loadMore() {
|
||||
if (hasMore.value && !loading.value) {
|
||||
page.value++
|
||||
await loadNotifications(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 滚动加载
|
||||
function handleScroll(event: Event) {
|
||||
const target = event.target as HTMLElement
|
||||
const scrollTop = target.scrollTop
|
||||
const scrollHeight = target.scrollHeight
|
||||
const clientHeight = target.clientHeight
|
||||
|
||||
// 距离底部100px时加载更多
|
||||
if (scrollHeight - scrollTop - clientHeight < 100 && hasMore.value && !loading.value) {
|
||||
loadMore()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadNotifications(true)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.custom-scrollbar::-webkit-scrollbar { width: 4px; }
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb { @apply bg-gray-700 rounded-full; }
|
||||
|
||||
.list-enter-active,
|
||||
.list-leave-active {
|
||||
transition: all 0.4s ease;
|
||||
}
|
||||
.list-enter-from,
|
||||
.list-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -111,3 +111,9 @@
|
||||
- 联系人状态(置顶、免打扰、特别关心、拉黑)会同步到联系人列表和资料卡
|
||||
- 搜索框只做本地过滤,不调用后端接口
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user