feat: IM 通讯录 + 发起聊天 + 群成员选择
- 侧边栏 Tab 切换:消息 / 通讯录 - 通讯录:搜索用户(直连 rag-backend),点击发起私聊 - 创建群聊 Modal 增加成员多选器(远程搜索用户) - IM Store 新增 contacts/searchContacts/startPrivateChat - 创建私聊:后端幂等(已存在则返回现有会话) - 全部 Tailwind 语义类,暗黑模式兼容
This commit is contained in:
parent
a67764a705
commit
059aebfa28
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user