This commit is contained in:
2025-12-05 13:13:12 +08:00
parent 8010d1d8a5
commit c259092b5a
18 changed files with 2800 additions and 124 deletions

View File

@@ -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 })
}

View 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>

View 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>

View File

@@ -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) {

View File

@@ -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

View 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>

View 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>

View File

@@ -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
}
}

View File

@@ -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 => ({
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,
name: c.target_user?.name || '未知用户',
avatar: c.target_user?.avatar || '',
}))
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 {

View File

@@ -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 // 群IDgroup_xxx格式
room_type: 'group'
name: string // 群名称
avatar?: string
owner_id: string // 群主ID
member_count: number // 成员数量
members?: GroupMember[] // 成员列表
created_at: string
}

View File

@@ -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 // 群聊时的群信息(可选)
}

View File

@@ -7,8 +7,12 @@ export enum MessageType {
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
View 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)
}

View File

@@ -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">
<!-- 单聊才显示通话按钮 -->
<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"
/>
@@ -205,6 +229,16 @@
/>
</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>
<!-- 空状态 -->
<div
v-else-if="currentTab === 'chat'"
@@ -224,7 +258,7 @@
>
<!-- 左侧联系人列表 -->
<div class="w-80 border-r border-gray-800 shrink-0">
<ContactCircle />
<ContactCircle @select-chat="selectChat" />
</div>
<!-- 右侧 -->
@@ -253,6 +287,25 @@
<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">
<div class="bg-panel rounded-2xl w-80 shadow-2xl border border-gray-700 overflow-hidden animate-fade-in">
@@ -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') {

View File

@@ -2,7 +2,8 @@
<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">
<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"
@@ -11,6 +12,48 @@
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>
<!-- 群聊列表 -->
<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="请输入群IDgroup_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>

View File

@@ -16,6 +16,7 @@
<!-- 更多菜单 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">
<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>
@@ -39,6 +40,17 @@
<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,29 +341,54 @@ 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 {
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
@@ -291,6 +396,7 @@ watch(() => contactStore.selectedContact, async (newVal) => {
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)
} finally {

View File

@@ -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>

View File

@@ -111,3 +111,9 @@
- 联系人状态(置顶、免打扰、特别关心、拉黑)会同步到联系人列表和资料卡
- 搜索框只做本地过滤,不调用后端接口