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 { 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user