diff --git a/src/api/modules/room.ts b/src/api/modules/room.ts index b65f0ab..62041e9 100644 --- a/src/api/modules/room.ts +++ b/src/api/modules/room.ts @@ -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(`/rooms/${id}`) } +/** + * 群聊相关API + */ + +// 创建群聊 +export function createGroup(data: { + name: string + avatar?: string + member_ids: string[] + admin_ids?: string[] +}) { + return request.post('/groups', data) +} + +// 获取用户群聊列表(带分组信息) +export function getUserGroups() { + return request.get>('/groups') +} + +// 获取群信息 +export function getGroup(roomId: string) { + return request.get(`/groups/${roomId}`) +} + +// 获取群成员列表 +export function getGroupMembers(roomId: string) { + return request.get(`/groups/${roomId}/members`) +} + +// 邀请成员入群 +export function inviteGroupMembers(roomId: string, data: { + member_ids: string[] +}) { + return request.post(`/groups/${roomId}/members`, data) +} + +// 移除群成员 +export function removeGroupMember(roomId: string, userId: string) { + return request.post(`/groups/${roomId}/members/${userId}/remove`, {}) +} + +// 修改群信息 +export function updateGroup(roomId: string, data: { + name?: string + avatar?: string +}) { + return request.post(`/groups/${roomId}/update`, data) +} + +// 调整成员角色 +export function updateMemberRole(roomId: string, userId: string, data: { + role: number // 0:成员 1:管理员 2:群主 +}) { + return request.post(`/groups/${roomId}/members/${userId}/role`, data) +} + +// 退出群聊 +export function quitGroup(roomId: string) { + return request.post(`/groups/${roomId}/quit`, {}) +} + +// 解散群聊 +export function dissolveGroup(roomId: string) { + return request.post(`/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(`/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 }) +} + diff --git a/src/components/chat/GroupChatPanel.vue b/src/components/chat/GroupChatPanel.vue new file mode 100644 index 0000000..d629256 --- /dev/null +++ b/src/components/chat/GroupChatPanel.vue @@ -0,0 +1,390 @@ + + + + + + diff --git a/src/components/chat/GroupInfoPanel.vue b/src/components/chat/GroupInfoPanel.vue new file mode 100644 index 0000000..587acce --- /dev/null +++ b/src/components/chat/GroupInfoPanel.vue @@ -0,0 +1,408 @@ + + + + + + diff --git a/src/components/chat/MessageBubble.vue b/src/components/chat/MessageBubble.vue index f7641c3..e46a999 100644 --- a/src/components/chat/MessageBubble.vue +++ b/src/components/chat/MessageBubble.vue @@ -1,5 +1,14 @@ + + + + + + + diff --git a/src/components/contact/GroupChatCard.vue b/src/components/contact/GroupChatCard.vue new file mode 100644 index 0000000..0a3fa5a --- /dev/null +++ b/src/components/contact/GroupChatCard.vue @@ -0,0 +1,203 @@ + + + + + + diff --git a/src/stores/chat.ts b/src/stores/chat.ts index dc9237f..3a51e5c 100644 --- a/src/stores/chat.ts +++ b/src/stores/chat.ts @@ -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 } } diff --git a/src/stores/conversation.ts b/src/stores/conversation.ts index b0ae86a..535fd94 100644 --- a/src/stores/conversation.ts +++ b/src/stores/conversation.ts @@ -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([]) @@ -21,11 +22,41 @@ export const useConversationStore = defineStore('conversation', () => { try { const list = await conversationApi.getConversationList() // 后端返回的数据需要简单处理一下 - conversations.value = list.map(c => ({ - ...c, - name: c.target_user?.name || '未知用户', - avatar: c.target_user?.avatar || '', - })) + conversations.value = list.map((c: any) => { + const isGroup = c.type === 2 // type: 1=私聊, 2=群聊 + const roomType = isGroup ? 'group' : 'p2p' + + // 将后端的 room 字段映射为前端的 target_group + const targetGroup = c.room || c.target_group + + return { + ...c, + room_type: roomType, + is_group: isGroup, + // 群聊显示群名称,单聊显示用户名称 + name: isGroup + ? (targetGroup?.room_name || targetGroup?.name || c.name || '未知群聊') + : (c.target_user?.name || c.name || '未知用户'), + avatar: isGroup + ? (targetGroup?.room_avatar || targetGroup?.avatar || c.avatar || '') + : (c.target_user?.avatar || c.avatar || ''), + // 群聊相关字段 + room_id: isGroup ? (c.room_id || c.target_id) : c.room_id, + member_count: targetGroup?.member_count, + owner_id: targetGroup?.owner_id, + // 映射 room 为 target_group(前端期望的字段名) + target_group: targetGroup ? { + id: targetGroup.room_id || targetGroup.id, + room_id: targetGroup.room_id, + room_type: 'group', + name: targetGroup.room_name || targetGroup.name, + avatar: targetGroup.room_avatar || targetGroup.avatar, + owner_id: targetGroup.owner_id, + member_count: targetGroup.member_count, + created_at: targetGroup.created_at, + } : undefined, + } + }) } catch (error) { console.error('Fetch conversations failed:', error) } finally { @@ -39,11 +70,21 @@ export const useConversationStore = defineStore('conversation', () => { * @param isCurrentChat 是否是当前选中的聊天,如果是则不增加未读数 */ function handleMessageUpdate(message: ChatMessage, isSelf: boolean, isCurrentChat: boolean = false) { - const targetId = isSelf ? message.receiver_user_id : message.sender_user_id - if (!targetId) return + // 群聊和单聊统一使用 room_id 定位会话 + const roomId = message.room_id + if (!roomId) return - let conv = conversations.value.find(c => c.target_id === targetId) - const summary = getMsgSummary(message) + // 优先按 room_id 匹配(群聊和单聊都支持) + let conv = conversations.value.find(c => c.room_id === roomId) + // 如果没有 room_id,回退到按 target_id 匹配(兼容旧逻辑) + if (!conv) { + const targetId = isSelf ? message.receiver_user_id : message.sender_user_id + if (targetId) { + conv = conversations.value.find(c => c.target_id === targetId) + } + } + + const summary = getMessageSummary(message) const now = new Date(message.created_at || Date.now()).getTime() if (conv) { @@ -64,9 +105,15 @@ export const useConversationStore = defineStore('conversation', () => { /** * 增加未读数 (仅前端UI,用于收到消息且不在当前窗口时) + * 支持按 room_id 或 target_id 匹配 */ - function incrementUnread(targetId: string) { - const conv = conversations.value.find(c => c.target_id === targetId) + function incrementUnread(targetIdOrRoomId: string) { + // 优先按 room_id 匹配(群聊场景) + let conv = conversations.value.find(c => c.room_id === targetIdOrRoomId) + // 如果没有找到,再按 target_id 匹配(单聊场景) + if (!conv) { + conv = conversations.value.find(c => c.target_id === targetIdOrRoomId) + } if (conv) { conv.unread_count = (conv.unread_count || 0) + 1 } @@ -74,14 +121,20 @@ export const useConversationStore = defineStore('conversation', () => { /** * 清除未读数 (同步后端) + * 支持按 room_id 或 target_id 匹配 */ - async function clearUnread(targetId: string) { - const conv = conversations.value.find(c => c.target_id === targetId) + async function clearUnread(targetIdOrRoomId: string) { + // 优先按 room_id 匹配(群聊场景) + let conv = conversations.value.find(c => c.room_id === targetIdOrRoomId) + // 如果没有找到,再按 target_id 匹配(单聊场景) + if (!conv) { + conv = conversations.value.find(c => c.target_id === targetIdOrRoomId) + } if (conv && conv.unread_count > 0) { conv.unread_count = 0 - // 调用后端接口 + // 调用后端接口(后端可能需要 target_id,这里传入 target_id 或 room_id) try { - await conversationApi.resetUnread(targetId) + await conversationApi.resetUnread(conv.target_id || targetIdOrRoomId) } catch (e) { console.error(e) } } } @@ -97,8 +150,7 @@ export const useConversationStore = defineStore('conversation', () => { } function getMsgSummary(msg: ChatMessage): string { - const types: Record = { 1: '[图片]', 2: '[语音]', 3: '[视频]', 8: '[文件]', 6: '[通话]' } - return types[msg.message_type] || msg.content + return getMessageSummary(msg) } return { diff --git a/src/types/api.ts b/src/types/api.ts index c1c9af1..2bacab9 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -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 @@ -144,3 +151,25 @@ export interface ICEServerConfig { credential?: string } +// 群成员信息 +export interface GroupMember { + user_id: string + room_id: string + role: number // 0:成员 1:管理员 2:群主 + nickname?: string // 群名片 + joined_at?: string + user?: User // 关联的用户信息 +} + +// 群信息(扩展Room) +export interface GroupInfo extends Room { + room_id: string // 群ID(group_xxx格式) + room_type: 'group' + name: string // 群名称 + avatar?: string + owner_id: string // 群主ID + member_count: number // 成员数量 + members?: GroupMember[] // 成员列表 + created_at: string +} + diff --git a/src/types/conversation.ts b/src/types/conversation.ts index 3316309..27f2972 100644 --- a/src/types/conversation.ts +++ b/src/types/conversation.ts @@ -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 // 群聊时的群信息(可选) } diff --git a/src/types/message.ts b/src/types/message.ts index fa4e23a..28fb89c 100644 --- a/src/types/message.ts +++ b/src/types/message.ts @@ -3,12 +3,16 @@ */ export enum MessageType { - TEXT = 0, // 文本 - IMAGE = 1, // 图片 - AUDIO = 2, // 语音 - VIDEO = 3, // 视频 - SIGNAL = 6, // WebRTC信令 - FILE = 8, // 文件 + TEXT = 0, // 文本 + IMAGE = 1, // 图片 + AUDIO = 2, // 语音 + VIDEO = 3, // 视频 + SYSTEM = 4, // 系统消息 + FRIEND_NOTIFY = 5, // 好友通知 + SIGNAL = 6, // WebRTC信令 + GROUP_NOTIFY = 7, // 群通知 + FILE = 8, // 文件 + MOMENTS_NOTIFY = 9, // 朋友圈通知(预留) } export enum CallStatus { @@ -31,5 +35,10 @@ export interface MessageExtra { description?: string image?: string type?: 'video' | 'audio' + // 系统/通知消息相关字段 + event?: string // 事件类型,如 'member_join', 'member_leave' 等 + operator_id?: string // 操作者ID + target_id?: string // 目标用户ID + attachment_id?: number // 附件ID } diff --git a/src/utils/messageTypes.ts b/src/utils/messageTypes.ts new file mode 100644 index 0000000..32ceec5 --- /dev/null +++ b/src/utils/messageTypes.ts @@ -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 = { + [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) +} + + + + + diff --git a/src/views/chat/ChatView.vue b/src/views/chat/ChatView.vue index 870b1bc..c335ab6 100644 --- a/src/views/chat/ChatView.vue +++ b/src/views/chat/ChatView.vue @@ -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" >
+
+ +
- -
+ +
+ +
+ +
+ +
+ +
@@ -92,7 +109,7 @@
{{ conv.displayName }} @@ -137,8 +154,10 @@
+ +
@@ -153,15 +172,19 @@ />

- {{ chatStore.currentTarget.remark_name || chatStore.currentTarget.user?.name }} - + {{ getCurrentTargetName() }} + + ({{ chatStore.currentTarget.member_count || 0 }}人)

- - + +
@@ -175,6 +198,7 @@ :target="chatStore.currentTarget" :loading-history="loadingHistory" :has-more-history="chatStore.currentTarget ? (hasMoreHistory[getRoomId(chatStore.currentTarget)] !== false) : true" + :room-members="roomMembers" @scroll="handleScroll" @load-more="loadMoreMessages" /> @@ -203,6 +227,16 @@ @record-stop="handleRecordStop" @record-cancel="handleRecordCancel" /> +
+ + +
@@ -224,7 +258,7 @@ >
- +
@@ -252,6 +286,25 @@
+ + + + + +
@@ -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>({}) // 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 = {} + 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 = { 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') { diff --git a/src/views/contact/ContactCircle.vue b/src/views/contact/ContactCircle.vue index c1d128b..f78d3fd 100644 --- a/src/views/contact/ContactCircle.vue +++ b/src/views/contact/ContactCircle.vue @@ -2,14 +2,57 @@
-
- - +
+
+ + +
+ +
+ + + + +
+ + + +
+
+
@@ -183,11 +226,97 @@ - -
- -

群聊功能开发中...

-
+ +
@@ -259,32 +388,112 @@ @cancel="showDeleteGroupConfirm = false" /> + + + + +
+
+
+ 加入群聊 + +
+
+
+ + +

请输入完整的群ID,例如:group_1234567890

+
+
+
+ + +
+
+
+
@@ -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); +} diff --git a/src/views/contact/ContactDetailCard.vue b/src/views/contact/ContactDetailCard.vue index 0605afb..461b87c 100644 --- a/src/views/contact/ContactDetailCard.vue +++ b/src/views/contact/ContactDetailCard.vue @@ -16,29 +16,41 @@
- - -
- - - -
- + +
@@ -59,35 +71,93 @@
-
+
+
+ +

- {{ contact.remark_name || contact.user?.name }} - + {{ isGroupChat ? (groupInfo?.name || contact.remark_name || contact.user?.name) : (contact.remark_name || contact.user?.name) }} + + + +

-

{{ contact.user?.desc || '暂无签名' }}

+

+ {{ groupAnnouncement }} + 暂无群公告 + {{ contact.user?.desc || '暂无签名' }} +

- ID: {{ contact.user_id || contact.id }} - {{ contact.user.region }} + ID: {{ isGroupChat ? (contact.room_id || contact.id) : (contact.user_id || contact.id) }} + + {{ groupInfo.member_count }} 人 + + {{ contact.user.region }}
-
+ +
+ +
+
+ +
+
+
群公告
+
{{ groupAnnouncement || '暂无群公告' }}
+
+
+ +
+
+
群成员 ({{ groupMembers.length }})
+
+
+
+ +
+
+ {{ member.user?.name || member.nickname || '未知' }} +
+
+ 群主 + 管理员 + 成员 +
+
+
+
+
+
+ + +
@@ -163,10 +233,19 @@
- +
-
- +
+ + +
+
+
@@ -228,7 +307,7 @@
- +
@@ -241,9 +320,10 @@ import { useContactStore } from '@/stores/contact' import { useToastStore } from '@/stores/toast' import { useWebRTCStore } from '@/stores/webrtc' import * as contactApi from '@/api/modules/contact' +import * as groupApi from '@/api/modules/room' import Avatar from '@/components/common/Avatar.vue' import ConfirmModal from '@/components/common/ConfirmModal.vue' -import type { Contact, ContactGroup } from '@/types/api' +import type { Contact, ContactGroup, GroupInfo, GroupMember } from '@/types/api' const emit = defineEmits<{ switchToChat: [] }>() const chatStore = useChatStore() @@ -261,35 +341,61 @@ const showDeleteConfirm = ref(false) const showRoomIdErrorModal = ref(false) const remarkName = ref('') +// 群聊相关状态 +const groupInfo = ref(null) +const groupMembers = ref([]) +const groupAnnouncement = ref('') + const currentGroup = computed(() => groups.value.find(g => g.id === contact.value?.group_id)) const allGroups = computed(() => [{id: 0, group_name: '未分组'}, ...groups.value]) +const isGroupChat = computed(() => contact.value?.is_group || contact.value?.room_type === 'group') // --- 核心逻辑:即时响应 --- watch(() => contactStore.selectedContact, async (newVal) => { if (!newVal) { contact.value = null + groupInfo.value = null + groupMembers.value = [] + groupAnnouncement.value = '' return } - // 1. 立即显示 Store 中的数据(不用等API),解决“请选择好友”的延迟问题 + // 1. 立即显示 Store 中的数据(不用等API),解决"请选择好友"的延迟问题 contact.value = { ...newVal } remarkName.value = newVal.remark_name || '' - // 2. 后台静默加载完整详情(获取手机号、Region等可能不在列表中的数据) + // 2. 检查是否为群聊 + const isGroup = newVal.is_group || newVal.room_type === 'group' + const roomId = newVal.room_id || newVal.id + + // 3. 后台静默加载完整详情 loading.value = true try { - const [detail, groupList] = await Promise.all([ - contactApi.getContactDetail(newVal.id), - contactApi.getGroups() // 同时刷新分组列表 - ]) + if (isGroup) { + // 群聊:获取群信息、群成员和群公告 + const [info, members, announcementRes] = await Promise.all([ + groupApi.getGroup(roomId), + groupApi.getGroupMembers(roomId), + groupApi.getGroupAnnouncement(roomId).catch(() => ({ announcement: '' })) // 群公告可能不存在,容错处理 + ]) + groupInfo.value = info + groupMembers.value = members + groupAnnouncement.value = announcementRes.announcement || '' + } else { + // 好友:获取好友详情和分组列表 + const [detail, groupList] = await Promise.all([ + contactApi.getContactDetail(newVal.id), + contactApi.getGroups() // 同时刷新分组列表 + ]) - // 3. 更新为完整数据 - contact.value = { ...newVal, ...detail } - groups.value = groupList + // 更新为完整数据 + contact.value = { ...newVal, ...detail } + groups.value = groupList - // RoomID 容错 - if (!contact.value.room_id && newVal.room_id) { - contact.value.room_id = newVal.room_id + // RoomID 容错 + if (!contact.value.room_id && newVal.room_id) { + contact.value.room_id = newVal.room_id + } } } catch (e: any) { console.error('Fetch detail failed, using basic info', e) diff --git a/src/views/contact/GroupNotifyView.vue b/src/views/contact/GroupNotifyView.vue index be42740..6439d11 100644 --- a/src/views/contact/GroupNotifyView.vue +++ b/src/views/contact/GroupNotifyView.vue @@ -3,15 +3,69 @@

群通知

-
-
+
+ +
+
+ +
+ +
+
+ + {{ getGroupName(notification.room_id) }} + + + {{ formatTime(new Date(notification.created_at).getTime()) }} + +
+ +
+ {{ getNotificationContent(notification) }} +
+ + +
+ {{ getEventDetails(notification) }} +
+
+
+
+ + +
+ +
+ + +
+
+ +

加载中...

+
+
+ -
+

暂无群通知

@@ -20,10 +74,164 @@ diff --git a/src/views/contact/README.md b/src/views/contact/README.md index fcdea43..33418f7 100644 --- a/src/views/contact/README.md +++ b/src/views/contact/README.md @@ -111,3 +111,9 @@ - 联系人状态(置顶、免打扰、特别关心、拉黑)会同步到联系人列表和资料卡 - 搜索框只做本地过滤,不调用后端接口 + + + + + +