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 { useAccessStore } from '@vben/stores';
import { requestClient } from '#/api/request';
const imRequestClient = new RequestClient({
baseURL: 'http://localhost:5212/api',
});
@ -29,14 +31,14 @@ export namespace ImApi {
export interface Conversation {
id: string;
type: number;
lastMessageContent: string | null;
lastMessageAt: string | null;
lastMessageContent: null | string;
lastMessageAt: null | string;
unreadCount: number;
groupName: string | null;
groupAvatar: string | null;
otherUserId: string | null;
otherUserName: string | null;
otherUserAvatar: string | null;
groupName: null | string;
groupAvatar: null | string;
otherUserId: null | string;
otherUserName: null | string;
otherUserAvatar: null | string;
}
export interface Message {
@ -44,22 +46,22 @@ export namespace ImApi {
conversationId: string;
senderId: string;
senderName: string;
senderAvatar: string | null;
senderAvatar: null | string;
contentType: number;
content: string;
metadata: MessageMetadata | null;
replyToId: string | null;
replyToId: null | string;
createdAt: string;
}
export interface MessageMetadata {
fileName?: string | null;
fileSize?: number | null;
fileUrl?: string | null;
imageUrl?: string | null;
imageWidth?: number | null;
imageHeight?: number | null;
emojiCode?: string | null;
fileName?: null | string;
fileSize?: null | number;
fileUrl?: null | string;
imageUrl?: null | string;
imageWidth?: null | number;
imageHeight?: null | number;
emojiCode?: null | string;
}
export interface MessageHistory {
@ -76,8 +78,8 @@ export namespace ImApi {
id: string;
conversationId: string;
name: string;
avatar: string | null;
description: string | null;
avatar: null | string;
description: null | string;
ownerId: string;
memberCount: number;
createdAt: string;
@ -86,10 +88,19 @@ export namespace ImApi {
export interface GroupMember {
userId: string;
userName: string;
avatar: string | null;
avatar: null | string;
role: number;
joinedAt: string;
}
export interface User {
id: string;
username: string;
email: string;
isActive: boolean;
createdAt: string;
roles: string[];
}
}
export async function getConversationsApi() {
@ -114,17 +125,17 @@ export async function getUnreadCountApi() {
}
export async function createGroupApi(data: {
name: string;
avatar?: string;
description?: string;
memberUserIds: string[];
name: string;
}) {
return imRequestClient.post<ImApi.GroupInfo>('/groups', data);
}
export async function updateGroupApi(
groupId: string,
data: { name?: string; avatar?: string; description?: string },
data: { avatar?: string; description?: string; name?: string; },
) {
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) {
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 { computed, ref } from 'vue';
import { useUserStore } from '@vben/stores';
import { defineStore } from 'pinia';
import {
createPrivateConversationApi,
getConversationsApi,
getMessageHistoryApi,
getUnreadCountApi,
searchUsersApi,
} from '#/api/core/im';
import { signalRService } from '#/api/core/signalr';
export const useImStore = defineStore('im', () => {
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 unreadCounts = ref<Map<string, number>>(new Map());
const onlineUsers = ref<Set<string>>(new Set());
const connected = ref(false);
const typingUsers = ref<Map<string, Set<string>>>(new Map());
const contacts = ref<ImApi.User[]>([]);
const contactsLoading = ref(false);
const totalUnread = computed(() => {
let total = 0;
@ -50,7 +57,7 @@ export const useImStore = defineStore('im', () => {
if (conv) {
conv.lastMessageContent =
message.contentType === 0
? message.content.substring(0, 50)
? message.content.slice(0, 50)
: `[${['文本', '图片', '文件', '系统', '表情'][message.contentType] || '消息'}]`;
conv.lastMessageAt = message.createdAt;
}
@ -145,10 +152,31 @@ export const useImStore = defineStore('im', () => {
await signalRService.typing(conversationId);
}
function setActiveConversation(conversationId: string | null) {
function setActiveConversation(conversationId: null | string) {
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 {
// State
conversations,
@ -158,6 +186,8 @@ export const useImStore = defineStore('im', () => {
onlineUsers,
connected,
typingUsers,
contacts,
contactsLoading,
// Computed
totalUnread,
activeMessages,
@ -172,5 +202,7 @@ export const useImStore = defineStore('im', () => {
markAsRead,
typing,
setActiveConversation,
searchContacts,
startPrivateChat,
};
});

View File

@ -1,4 +1,6 @@
<script setup lang="ts">
import type { ImApi } from '#/api/core/im';
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue';
import { useUserStore } from '@vben/stores';
@ -17,8 +19,15 @@ const showCreateGroup = ref(false);
const showGroupManage = ref(false);
const newGroupName = ref('');
const newGroupDesc = ref('');
const selectedMemberIds = ref<string[]>([]);
const memberSearchLoading = ref(false);
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 activeConversation = computed(() => imStore.activeConversation);
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;
}
@ -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() {
if (!newGroupName.value.trim()) {
message.warning('请输入群名称');
@ -93,18 +113,33 @@ async function handleCreateGroup() {
await createGroupApi({
name: newGroupName.value,
description: newGroupDesc.value || undefined,
memberUserIds: [],
memberUserIds: selectedMemberIds.value,
});
message.success('群聊创建成功');
showCreateGroup.value = false;
newGroupName.value = '';
newGroupDesc.value = '';
selectedMemberIds.value = [];
await imStore.loadConversations();
} catch {
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() {
if (messageListRef.value) {
messageListRef.value.scrollTop = messageListRef.value.scrollHeight;
@ -122,6 +157,11 @@ async function onScroll() {
}
}
function switchToContacts() {
sidebarTab.value = 'contacts';
imStore.searchContacts();
}
onMounted(async () => {
await imStore.connect();
await imStore.loadConversations();
@ -138,57 +178,134 @@ onUnmounted(() => {
class="flex h-[calc(100vh-120px)] overflow-hidden rounded-lg border border-border bg-card"
>
<!-- Sidebar -->
<div
class="flex w-[300px] shrink-0 flex-col border-r 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 class="flex w-[300px] shrink-0 flex-col border-r border-border">
<!-- Tabs -->
<div class="flex border-b border-border">
<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)"
class="flex-1 cursor-pointer px-4 py-3 text-center text-sm transition-colors"
:class="
sidebarTab === 'messages'
? 'border-b-2 border-primary font-medium text-foreground'
: 'text-muted-foreground hover:text-foreground'
"
@click="sidebarTab = 'messages'"
>
<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
class="flex-1 cursor-pointer px-4 py-3 text-center text-sm transition-colors"
:class="
sidebarTab === 'contacts'
? 'border-b-2 border-primary font-medium text-foreground'
: 'text-muted-foreground hover:text-foreground'
"
@click="switchToContacts"
>
通讯录
</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>
<!-- Main chat area -->
@ -259,9 +376,7 @@ onUnmounted(() => {
</div>
</div>
<div
class="flex gap-2 border-t border-border px-4 py-3"
>
<div class="flex gap-2 border-t border-border px-4 py-3">
<a-textarea
v-model:value="inputMessage"
placeholder="输入消息..."
@ -295,6 +410,24 @@ onUnmounted(() => {
<a-form-item label="群描述">
<a-input v-model:value="newGroupDesc" />
</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-modal>
</div>