feat: IM 通讯录 + 发起聊天 + 群成员选择

- 侧边栏 Tab 切换:消息 / 通讯录
- 通讯录:搜索用户(直连 rag-backend),点击发起私聊
- 创建群聊 Modal 增加成员多选器(远程搜索用户)
- IM Store 新增 contacts/searchContacts/startPrivateChat
- 创建私聊:后端幂等(已存在则返回现有会话)
- 全部 Tailwind 语义类,暗黑模式兼容
This commit is contained in:
向宁 2026-05-17 17:50:46 +08:00
parent a67764a705
commit 059aebfa28
3 changed files with 269 additions and 79 deletions

View File

@ -1,6 +1,8 @@
import { RequestClient } from '@vben/request'; import { RequestClient } from '@vben/request';
import { useAccessStore } from '@vben/stores'; import { useAccessStore } from '@vben/stores';
import { requestClient } from '#/api/request';
const imRequestClient = new RequestClient({ const imRequestClient = new RequestClient({
baseURL: 'http://localhost:5212/api', baseURL: 'http://localhost:5212/api',
}); });
@ -29,14 +31,14 @@ export namespace ImApi {
export interface Conversation { export interface Conversation {
id: string; id: string;
type: number; type: number;
lastMessageContent: string | null; lastMessageContent: null | string;
lastMessageAt: string | null; lastMessageAt: null | string;
unreadCount: number; unreadCount: number;
groupName: string | null; groupName: null | string;
groupAvatar: string | null; groupAvatar: null | string;
otherUserId: string | null; otherUserId: null | string;
otherUserName: string | null; otherUserName: null | string;
otherUserAvatar: string | null; otherUserAvatar: null | string;
} }
export interface Message { export interface Message {
@ -44,22 +46,22 @@ export namespace ImApi {
conversationId: string; conversationId: string;
senderId: string; senderId: string;
senderName: string; senderName: string;
senderAvatar: string | null; senderAvatar: null | string;
contentType: number; contentType: number;
content: string; content: string;
metadata: MessageMetadata | null; metadata: MessageMetadata | null;
replyToId: string | null; replyToId: null | string;
createdAt: string; createdAt: string;
} }
export interface MessageMetadata { export interface MessageMetadata {
fileName?: string | null; fileName?: null | string;
fileSize?: number | null; fileSize?: null | number;
fileUrl?: string | null; fileUrl?: null | string;
imageUrl?: string | null; imageUrl?: null | string;
imageWidth?: number | null; imageWidth?: null | number;
imageHeight?: number | null; imageHeight?: null | number;
emojiCode?: string | null; emojiCode?: null | string;
} }
export interface MessageHistory { export interface MessageHistory {
@ -76,8 +78,8 @@ export namespace ImApi {
id: string; id: string;
conversationId: string; conversationId: string;
name: string; name: string;
avatar: string | null; avatar: null | string;
description: string | null; description: null | string;
ownerId: string; ownerId: string;
memberCount: number; memberCount: number;
createdAt: string; createdAt: string;
@ -86,10 +88,19 @@ export namespace ImApi {
export interface GroupMember { export interface GroupMember {
userId: string; userId: string;
userName: string; userName: string;
avatar: string | null; avatar: null | string;
role: number; role: number;
joinedAt: string; joinedAt: string;
} }
export interface User {
id: string;
username: string;
email: string;
isActive: boolean;
createdAt: string;
roles: string[];
}
} }
export async function getConversationsApi() { export async function getConversationsApi() {
@ -114,17 +125,17 @@ export async function getUnreadCountApi() {
} }
export async function createGroupApi(data: { export async function createGroupApi(data: {
name: string;
avatar?: string; avatar?: string;
description?: string; description?: string;
memberUserIds: string[]; memberUserIds: string[];
name: string;
}) { }) {
return imRequestClient.post<ImApi.GroupInfo>('/groups', data); return imRequestClient.post<ImApi.GroupInfo>('/groups', data);
} }
export async function updateGroupApi( export async function updateGroupApi(
groupId: string, groupId: string,
data: { name?: string; avatar?: string; description?: string }, data: { avatar?: string; description?: string; name?: string; },
) { ) {
return imRequestClient.put<ImApi.GroupInfo>(`/groups/${groupId}`, data); return imRequestClient.put<ImApi.GroupInfo>(`/groups/${groupId}`, data);
} }
@ -144,3 +155,17 @@ export async function removeGroupMemberApi(groupId: string, userId: string) {
export async function getGroupMembersApi(groupId: string) { export async function getGroupMembersApi(groupId: string) {
return imRequestClient.get<ImApi.GroupMember[]>(`/groups/${groupId}/members`); return imRequestClient.get<ImApi.GroupMember[]>(`/groups/${groupId}/members`);
} }
// 用户搜索 — 直连 rag-backend
export async function searchUsersApi(search?: string) {
const params: Record<string, any> = {};
if (search) params.search = search;
return requestClient.get<ImApi.User[]>('/users', { params });
}
// 创建或获取私聊会话 — IM 后端
export async function createPrivateConversationApi(targetUserId: string) {
return imRequestClient.post<ImApi.Conversation>('/conversations/private', {
targetUserId,
});
}

View File

@ -1,23 +1,30 @@
import { ref, computed } from 'vue';
import { defineStore } from 'pinia';
import type { ImApi } from '#/api/core/im'; import type { ImApi } from '#/api/core/im';
import { computed, ref } from 'vue';
import { useUserStore } from '@vben/stores';
import { defineStore } from 'pinia';
import { import {
createPrivateConversationApi,
getConversationsApi, getConversationsApi,
getMessageHistoryApi, getMessageHistoryApi,
getUnreadCountApi, getUnreadCountApi,
searchUsersApi,
} from '#/api/core/im'; } from '#/api/core/im';
import { signalRService } from '#/api/core/signalr'; import { signalRService } from '#/api/core/signalr';
export const useImStore = defineStore('im', () => { export const useImStore = defineStore('im', () => {
const conversations = ref<ImApi.Conversation[]>([]); const conversations = ref<ImApi.Conversation[]>([]);
const activeConversationId = ref<string | null>(null); const activeConversationId = ref<null | string>(null);
const messages = ref<Map<string, ImApi.Message[]>>(new Map()); const messages = ref<Map<string, ImApi.Message[]>>(new Map());
const unreadCounts = ref<Map<string, number>>(new Map()); const unreadCounts = ref<Map<string, number>>(new Map());
const onlineUsers = ref<Set<string>>(new Set()); const onlineUsers = ref<Set<string>>(new Set());
const connected = ref(false); const connected = ref(false);
const typingUsers = ref<Map<string, Set<string>>>(new Map()); const typingUsers = ref<Map<string, Set<string>>>(new Map());
const contacts = ref<ImApi.User[]>([]);
const contactsLoading = ref(false);
const totalUnread = computed(() => { const totalUnread = computed(() => {
let total = 0; let total = 0;
@ -50,7 +57,7 @@ export const useImStore = defineStore('im', () => {
if (conv) { if (conv) {
conv.lastMessageContent = conv.lastMessageContent =
message.contentType === 0 message.contentType === 0
? message.content.substring(0, 50) ? message.content.slice(0, 50)
: `[${['文本', '图片', '文件', '系统', '表情'][message.contentType] || '消息'}]`; : `[${['文本', '图片', '文件', '系统', '表情'][message.contentType] || '消息'}]`;
conv.lastMessageAt = message.createdAt; conv.lastMessageAt = message.createdAt;
} }
@ -145,10 +152,31 @@ export const useImStore = defineStore('im', () => {
await signalRService.typing(conversationId); await signalRService.typing(conversationId);
} }
function setActiveConversation(conversationId: string | null) { function setActiveConversation(conversationId: null | string) {
activeConversationId.value = conversationId; activeConversationId.value = conversationId;
} }
async function searchContacts(query?: string) {
contactsLoading.value = true;
try {
const userStore = useUserStore();
const data = await searchUsersApi(query);
contacts.value = data.filter(
(u) => u.id !== userStore.userInfo?.userId,
);
} finally {
contactsLoading.value = false;
}
}
async function startPrivateChat(targetUserId: string) {
const conversation = await createPrivateConversationApi(targetUserId);
await loadConversations();
setActiveConversation(conversation.id);
await loadMessages(conversation.id);
return conversation;
}
return { return {
// State // State
conversations, conversations,
@ -158,6 +186,8 @@ export const useImStore = defineStore('im', () => {
onlineUsers, onlineUsers,
connected, connected,
typingUsers, typingUsers,
contacts,
contactsLoading,
// Computed // Computed
totalUnread, totalUnread,
activeMessages, activeMessages,
@ -172,5 +202,7 @@ export const useImStore = defineStore('im', () => {
markAsRead, markAsRead,
typing, typing,
setActiveConversation, setActiveConversation,
searchContacts,
startPrivateChat,
}; };
}); });

View File

@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ImApi } from '#/api/core/im';
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'; import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue';
import { useUserStore } from '@vben/stores'; import { useUserStore } from '@vben/stores';
@ -17,8 +19,15 @@ const showCreateGroup = ref(false);
const showGroupManage = ref(false); const showGroupManage = ref(false);
const newGroupName = ref(''); const newGroupName = ref('');
const newGroupDesc = ref(''); const newGroupDesc = ref('');
const selectedMemberIds = ref<string[]>([]);
const memberSearchLoading = ref(false);
const messageListRef = ref<HTMLElement | null>(null); const messageListRef = ref<HTMLElement | null>(null);
// Sidebar tab
const sidebarTab = ref<'contacts' | 'messages'>('messages');
const contactSearchQuery = ref('');
let contactSearchTimer: ReturnType<typeof setTimeout> | null = null;
const activeConversationId = computed(() => imStore.activeConversationId); const activeConversationId = computed(() => imStore.activeConversationId);
const activeConversation = computed(() => imStore.activeConversation); const activeConversation = computed(() => imStore.activeConversation);
const activeMessages = computed(() => imStore.activeMessages); const activeMessages = computed(() => imStore.activeMessages);
@ -31,7 +40,7 @@ const sortedConversations = computed(() =>
}), }),
); );
function isSelfMessage(msg: any) { function isSelfMessage(msg: ImApi.Message) {
return msg.senderId === userStore.userInfo?.userId; return msg.senderId === userStore.userInfo?.userId;
} }
@ -84,6 +93,17 @@ async function handleSend(e?: any) {
} }
} }
async function handleStartChat(user: ImApi.User) {
try {
await imStore.startPrivateChat(user.id);
sidebarTab.value = 'messages';
await nextTick();
scrollToBottom();
} catch {
message.error('创建会话失败');
}
}
async function handleCreateGroup() { async function handleCreateGroup() {
if (!newGroupName.value.trim()) { if (!newGroupName.value.trim()) {
message.warning('请输入群名称'); message.warning('请输入群名称');
@ -93,18 +113,33 @@ async function handleCreateGroup() {
await createGroupApi({ await createGroupApi({
name: newGroupName.value, name: newGroupName.value,
description: newGroupDesc.value || undefined, description: newGroupDesc.value || undefined,
memberUserIds: [], memberUserIds: selectedMemberIds.value,
}); });
message.success('群聊创建成功'); message.success('群聊创建成功');
showCreateGroup.value = false; showCreateGroup.value = false;
newGroupName.value = ''; newGroupName.value = '';
newGroupDesc.value = ''; newGroupDesc.value = '';
selectedMemberIds.value = [];
await imStore.loadConversations(); await imStore.loadConversations();
} catch { } catch {
message.error('创建失败'); message.error('创建失败');
} }
} }
function handleContactSearch() {
if (contactSearchTimer) clearTimeout(contactSearchTimer);
contactSearchTimer = setTimeout(() => {
imStore.searchContacts(contactSearchQuery.value || undefined);
}, 300);
}
function handleMemberSearch(value: string) {
memberSearchLoading.value = true;
imStore.searchContacts(value || undefined).finally(() => {
memberSearchLoading.value = false;
});
}
function scrollToBottom() { function scrollToBottom() {
if (messageListRef.value) { if (messageListRef.value) {
messageListRef.value.scrollTop = messageListRef.value.scrollHeight; messageListRef.value.scrollTop = messageListRef.value.scrollHeight;
@ -122,6 +157,11 @@ async function onScroll() {
} }
} }
function switchToContacts() {
sidebarTab.value = 'contacts';
imStore.searchContacts();
}
onMounted(async () => { onMounted(async () => {
await imStore.connect(); await imStore.connect();
await imStore.loadConversations(); await imStore.loadConversations();
@ -138,57 +178,134 @@ onUnmounted(() => {
class="flex h-[calc(100vh-120px)] overflow-hidden rounded-lg border border-border bg-card" class="flex h-[calc(100vh-120px)] overflow-hidden rounded-lg border border-border bg-card"
> >
<!-- Sidebar --> <!-- Sidebar -->
<div <div class="flex w-[300px] shrink-0 flex-col border-r border-border">
class="flex w-[300px] shrink-0 flex-col border-r border-border" <!-- Tabs -->
> <div class="flex border-b border-border">
<div
class="flex items-center justify-between border-b border-border px-4 py-3"
>
<h3 class="m-0 p-0 text-base font-medium text-foreground">消息</h3>
<a-button type="primary" size="small" @click="showCreateGroup = true">
创建群聊
</a-button>
</div>
<div class="flex-1 overflow-y-auto">
<div <div
v-for="conv in sortedConversations" class="flex-1 cursor-pointer px-4 py-3 text-center text-sm transition-colors"
:key="conv.id" :class="
class="flex cursor-pointer items-center px-4 py-3 transition-colors hover:bg-accent" sidebarTab === 'messages'
:class="{ ? 'border-b-2 border-primary font-medium text-foreground'
'bg-primary/10 dark:bg-accent': conv.id === activeConversationId, : 'text-muted-foreground hover:text-foreground'
}" "
@click="selectConversation(conv.id)" @click="sidebarTab = 'messages'"
> >
<a-avatar v-if="conv.type === 2" :size="40"> 消息
{{ conv.groupName?.charAt(0) || '群' }} </div>
</a-avatar> <div
<a-avatar v-else :size="40"> class="flex-1 cursor-pointer px-4 py-3 text-center text-sm transition-colors"
{{ conv.otherUserName?.charAt(0) || 'U' }} :class="
</a-avatar> sidebarTab === 'contacts'
? 'border-b-2 border-primary font-medium text-foreground'
<div class="ml-3 flex-1 overflow-hidden"> : 'text-muted-foreground hover:text-foreground'
<div "
class="truncate text-sm font-medium text-foreground" @click="switchToContacts"
> >
{{ 通讯录
conv.type === 2
? conv.groupName
: conv.otherUserName || '私聊'
}}
</div>
<div class="mt-1 truncate text-xs text-muted-foreground">
{{ conv.lastMessageContent || '暂无消息' }}
</div>
</div>
<a-badge
v-if="conv.unreadCount > 0"
:count="conv.unreadCount"
:overflow-count="99"
/>
</div> </div>
</div> </div>
<!-- Messages panel -->
<template v-if="sidebarTab === 'messages'">
<div
class="flex items-center justify-between border-b border-border px-4 py-2"
>
<span class="text-sm text-muted-foreground">
{{ sortedConversations.length }} 个会话
</span>
<a-button
type="primary"
size="small"
@click="showCreateGroup = true"
>
创建群聊
</a-button>
</div>
<div class="flex-1 overflow-y-auto">
<div
v-for="conv in sortedConversations"
:key="conv.id"
class="flex cursor-pointer items-center px-4 py-3 transition-colors hover:bg-accent"
:class="{
'bg-primary/10 dark:bg-accent':
conv.id === activeConversationId,
}"
@click="selectConversation(conv.id)"
>
<a-avatar v-if="conv.type === 2" :size="40">
{{ conv.groupName?.charAt(0) || '群' }}
</a-avatar>
<a-avatar v-else :size="40">
{{ conv.otherUserName?.charAt(0) || 'U' }}
</a-avatar>
<div class="ml-3 flex-1 overflow-hidden">
<div class="truncate text-sm font-medium text-foreground">
{{
conv.type === 2
? conv.groupName
: conv.otherUserName || '私聊'
}}
</div>
<div class="mt-1 truncate text-xs text-muted-foreground">
{{ conv.lastMessageContent || '暂无消息' }}
</div>
</div>
<a-badge
v-if="conv.unreadCount > 0"
:count="conv.unreadCount"
:overflow-count="99"
/>
</div>
</div>
</template>
<!-- Contacts panel -->
<template v-else>
<div class="border-b border-border px-3 py-2">
<a-input-search
v-model:value="contactSearchQuery"
placeholder="搜索用户名或邮箱"
@search="imStore.searchContacts(contactSearchQuery || undefined)"
@change="handleContactSearch"
/>
</div>
<div class="flex-1 overflow-y-auto">
<a-spin :spinning="imStore.contactsLoading">
<div
v-for="user in imStore.contacts"
:key="user.id"
class="flex cursor-pointer items-center px-4 py-3 transition-colors hover:bg-accent"
@click="handleStartChat(user)"
>
<a-avatar :size="40">
{{ user.username.charAt(0).toUpperCase() }}
</a-avatar>
<div class="ml-3 flex-1 overflow-hidden">
<div class="truncate text-sm font-medium text-foreground">
{{ user.username }}
</div>
<div class="mt-1 truncate text-xs text-muted-foreground">
{{ user.email }}
</div>
</div>
<span
v-if="imStore.onlineUsers.has(user.id)"
class="mr-2 inline-block h-2 w-2 rounded-full bg-green-500"
/>
</div>
<div
v-if="
!imStore.contactsLoading && imStore.contacts.length === 0
"
class="px-4 py-8 text-center text-sm text-muted-foreground"
>
没有找到用户
</div>
</a-spin>
</div>
</template>
</div> </div>
<!-- Main chat area --> <!-- Main chat area -->
@ -259,9 +376,7 @@ onUnmounted(() => {
</div> </div>
</div> </div>
<div <div class="flex gap-2 border-t border-border px-4 py-3">
class="flex gap-2 border-t border-border px-4 py-3"
>
<a-textarea <a-textarea
v-model:value="inputMessage" v-model:value="inputMessage"
placeholder="输入消息..." placeholder="输入消息..."
@ -295,6 +410,24 @@ onUnmounted(() => {
<a-form-item label="群描述"> <a-form-item label="群描述">
<a-input v-model:value="newGroupDesc" /> <a-input v-model:value="newGroupDesc" />
</a-form-item> </a-form-item>
<a-form-item label="选择成员">
<a-select
v-model:value="selectedMemberIds"
mode="multiple"
placeholder="搜索并选择成员"
:filter-option="false"
:loading="memberSearchLoading"
@search="handleMemberSearch"
>
<a-select-option
v-for="user in imStore.contacts"
:key="user.id"
:value="user.id"
>
{{ user.username }} ({{ user.email }})
</a-select-option>
</a-select>
</a-form-item>
</a-form> </a-form>
</a-modal> </a-modal>
</div> </div>