diff --git a/src/api/modules/contact.ts b/src/api/modules/contact.ts index 07b955b..b7e2e84 100644 --- a/src/api/modules/contact.ts +++ b/src/api/modules/contact.ts @@ -68,6 +68,8 @@ export function updateContact(id: string, data: { group_id?: number is_top?: boolean is_muted?: boolean + is_special_care?: boolean + is_blocked?: boolean }) { return request.post(`/contacts/update/${id}`, data) } diff --git a/src/api/modules/conversation.ts b/src/api/modules/conversation.ts new file mode 100644 index 0000000..d86f4bc --- /dev/null +++ b/src/api/modules/conversation.ts @@ -0,0 +1,37 @@ +import request from '../request' +import type { Conversation } from '@/types/conversation' + +/** + * 会话列表相关 API + */ + +// 获取会话列表 +export function getConversationList() { + return request.get('/conversations') +} + +// 重置未读 +export function resetUnread(targetId: string) { + return request.post('/conversations/reset-unread', { + target_id: targetId, + }) +} + +// 更新会话标记 +export function updateConversation(data: { + target_id: string + is_top?: boolean + is_muted?: boolean + is_special_care?: boolean +}) { + return request.post('/conversations/update', data) +} + +// 删除会话 +export function deleteConversation(targetId: string) { + return request.post('/conversations/delete', { + target_id: targetId, + }) +} + + diff --git a/src/stores/contact.ts b/src/stores/contact.ts index 01d11b8..cd21930 100644 --- a/src/stores/contact.ts +++ b/src/stores/contact.ts @@ -1,10 +1,15 @@ import { defineStore } from 'pinia' import { ref } from 'vue' -import type { FriendRequest, ContactGroup } from '@/types/api' +import type { FriendRequest, ContactGroup, Contact } from '@/types/api' + +export type LeftPanelMode = 'default' | 'friend-manager' | 'friend-notify' | 'group-notify' export const useContactStore = defineStore('contact', () => { const friendRequests = ref([]) const groups = ref([]) + const leftPanelMode = ref('default') + const selectedContact = ref(null) + const contactListTab = ref<'friends' | 'groups'>('friends') /** * 设置好友申请列表 @@ -64,9 +69,34 @@ export const useContactStore = defineStore('contact', () => { } } + /** + * 设置左侧面板模式 + */ + function setLeftPanelMode(mode: LeftPanelMode) { + leftPanelMode.value = mode + // 切换到管理/通知模式时,清除选中联系人 + if (mode !== 'default') { + selectedContact.value = null + } + } + + /** + * 设置选中的联系人 + */ + function setSelectedContact(contact: Contact | null) { + selectedContact.value = contact + // 选中联系人时,切换到默认模式 + if (contact) { + leftPanelMode.value = 'default' + } + } + return { friendRequests, groups, + leftPanelMode, + selectedContact, + contactListTab, setFriendRequests, addFriendRequest, removeFriendRequest, @@ -74,6 +104,8 @@ export const useContactStore = defineStore('contact', () => { addGroup, updateGroup, removeGroup, + setLeftPanelMode, + setSelectedContact, } }) diff --git a/src/stores/conversation.ts b/src/stores/conversation.ts new file mode 100644 index 0000000..a5ef8c1 --- /dev/null +++ b/src/stores/conversation.ts @@ -0,0 +1,111 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import type { Conversation } from '@/types/conversation' +import type { ChatMessage } from '@/types/api' +import * as conversationApi from '@/api/modules/conversation' + +export const useConversationStore = defineStore('conversation', () => { + const conversations = ref([]) + const loading = ref(false) + + // 计算总未读数 + const totalUnread = computed(() => + conversations.value.reduce((acc, c) => acc + (c.is_muted ? 0 : c.unread_count), 0) + ) + + /** + * 初始化加载会话列表 + */ + async function loadConversations() { + loading.value = true + try { + const list = await conversationApi.getConversationList() + // 后端返回的数据需要简单处理一下 + conversations.value = list.map(c => ({ + ...c, + name: c.target_user?.name || '未知用户', + avatar: c.target_user?.avatar || '', + })) + } catch (error) { + console.error('Fetch conversations failed:', error) + } finally { + loading.value = false + } + } + + /** + * 处理新消息 (发送或接收) + * 实时更新前端列表:这里只做前端 UI 层的兜底,后端已更新会话表 + */ + function handleMessageUpdate(message: ChatMessage, isSelf: boolean) { + const targetId = isSelf ? message.receiver_user_id : message.sender_user_id + if (!targetId) return + + let conv = conversations.value.find(c => c.target_id === targetId) + const summary = getMsgSummary(message) + const now = new Date(message.created_at || Date.now()).getTime() + + if (conv) { + conv.last_message = summary + conv.last_time = now + if (!isSelf && !conv.is_muted) { + conv.unread_count = (conv.unread_count || 0) + 1 + } + } else { + // 新会话直接重新拉取,保证与后端一致 + loadConversations() + return + } + + sortConversations() + } + + /** + * 增加未读数 (仅前端UI,用于收到消息且不在当前窗口时) + */ + function incrementUnread(targetId: string) { + const conv = conversations.value.find(c => c.target_id === targetId) + if (conv) { + conv.unread_count = (conv.unread_count || 0) + 1 + } + } + + /** + * 清除未读数 (同步后端) + */ + async function clearUnread(targetId: string) { + const conv = conversations.value.find(c => c.target_id === targetId) + if (conv && conv.unread_count > 0) { + conv.unread_count = 0 + // 调用后端接口 + try { + await conversationApi.resetUnread(targetId) + } catch (e) { console.error(e) } + } + } + + /** + * 排序 + */ + function sortConversations() { + conversations.value.sort((a, b) => { + if (a.is_top !== b.is_top) return a.is_top ? -1 : 1 + return b.last_time - a.last_time + }) + } + + function getMsgSummary(msg: ChatMessage): string { + const types: Record = { 1: '[图片]', 2: '[语音]', 3: '[视频]', 8: '[文件]', 6: '[通话]' } + return types[msg.message_type] || msg.content + } + + return { + conversations, + loading, + totalUnread, + loadConversations, + handleMessageUpdate, + incrementUnread, + clearUnread + } +}) diff --git a/src/types/api.ts b/src/types/api.ts index 8b7e519..8e4c62b 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -53,6 +53,8 @@ export interface Contact { group_id?: number is_top: boolean is_muted: boolean + is_special_care?: boolean + is_blocked?: boolean user?: User // 关联的用户信息 last_msg?: string last_time?: number diff --git a/src/types/conversation.ts b/src/types/conversation.ts new file mode 100644 index 0000000..3316309 --- /dev/null +++ b/src/types/conversation.ts @@ -0,0 +1,23 @@ +/** + * 聊天会话类型定义 + */ + +import type { User } from './api' + +export interface Conversation { + id: number // 数据库主键ID + user_id: string // 所属用户ID + target_id: string // 目标ID (好友ID) + type: number // 1:私聊 2:群聊 + name?: string // 前端辅助字段:显示名称 + avatar?: string // 前端辅助字段:头像 + unread_count: number // 未读数 + is_top: boolean // 是否置顶 + is_muted: boolean // 是否免打扰 + is_special_care?: boolean // 是否特别关心 + last_message: string // 最后一条消息 + last_time: number // 最后消息时间戳 (毫秒) + + // 关联数据 + target_user?: User +} diff --git a/src/views/chat/ChatView.vue b/src/views/chat/ChatView.vue index cafdef8..7d0b3db 100644 --- a/src/views/chat/ChatView.vue +++ b/src/views/chat/ChatView.vue @@ -48,52 +48,52 @@ v-model="searchQuery" type="text" class="w-full bg-input rounded-2xl py-2.5 pl-10 pr-4 text-sm text-white focus:ring-2 ring-primary outline-none transition placeholder-gray-500 shadow-inner" - placeholder="搜索联系人..." + placeholder="搜索会话或联系人..." />
- {{ contact.unread }} + {{ conv.unread_count }}
- {{ contact.remark_name || contact.user?.name || '未知' }} + {{ conv.name || '未知' }} - {{ formatTime(contact.last_time || Date.now()) }} + {{ formatTime(conv.last_time || Date.now()) }}
- - - {{ contact.lastMsg || contact.user?.desc || '' }} + + + {{ conv.last_message || '' }}
@@ -181,8 +181,51 @@

Design for Developer

- - + +
+ +
+ +
+ + +
+ +
+
+ +
+

NL-IM

+

Design for Developer

+
+ + + + + + + + + + + +
+
+ +

群通知功能开发中

+
+
+
+
@@ -212,6 +255,8 @@ import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue' import { useRouter } from 'vue-router' import { useAuthStore } from '@/stores/auth' import { useChatStore } from '@/stores/chat' +import { useConversationStore } from '@/stores/conversation' +import { useContactStore } from '@/stores/contact' import { useToastStore } from '@/stores/toast' import { useContextMenu } from '@/composables/useContextMenu' import { wsManager } from '@/api/websocket' @@ -220,6 +265,7 @@ import * as contactApi from '@/api/modules/contact' import * as attachmentApi from '@/api/modules/attachment' import { formatTime } from '@/utils/format' import type { Contact, ChatMessage } from '@/types/api' +import type { Conversation } from '@/types/conversation' import { useWebRTC } from '@/composables/useWebRTC' // Components... import Avatar from '@/components/common/Avatar.vue' @@ -229,11 +275,16 @@ import CallWindow from '@/components/call/CallWindow.vue' import MessageList from '@/components/chat/MessageList.vue' import MessageInput from '@/components/chat/MessageInput.vue' import ContactView from '@/views/contact/ContactView.vue' +import ContactCircle from '@/views/contact/ContactCircle.vue' +import ContactDetailCard from '@/views/contact/ContactDetailCard.vue' +import FriendNotifyView from '@/views/contact/FriendNotifyView.vue' const router = useRouter() const authStore = useAuthStore() const chatStore = useChatStore() +const conversationStore = useConversationStore() const toastStore = useToastStore() +const contactStore = useContactStore() const { showContextMenu } = useContextMenu() // WebRTC logic... @@ -261,10 +312,10 @@ const showScrollBottomBtn = ref(false) const unreadCount = ref(0) const isNearBottom = ref(true) -const filteredContacts = computed(() => { +const filteredConversations = computed(() => { const query = searchQuery.value.toLowerCase() - return chatStore.contacts.filter(contact => { - const name = (contact.remark_name || contact.user?.name || '').toLowerCase() + return conversationStore.conversations.filter(conv => { + const name = (conv.name || conv.target_user?.name || '').toLowerCase() return name.includes(query) }) }) @@ -334,6 +385,21 @@ async function selectChat(contact: Contact) { scrollToBottom(false) // 初始进入直接跳到底部 } +// 通过会话点击打开聊天 +async function selectChatByConversation(conv: Conversation) { + // 在联系人列表中找到对应联系人(用于沿用现有 chatStore 结构) + const contact = chatStore.contacts.find( + c => c.user_id === conv.target_id || c.id === conv.target_id, + ) + if (!contact) { + // 如果本地没有联系人数据,可以在这里补一次联系人列表加载;当前先简单返回 + return + } + await selectChat(contact) + // 清空该会话未读 + await conversationStore.clearUnread(conv.target_id) +} + // ... Send Logic (Text, File, Audio) ... // (保持大部分原有逻辑,但在发送成功后调用 scrollToBottom) @@ -374,6 +440,7 @@ function handleWebSocketMessage(message: ChatMessage) { if (contact) { chatStore.updateContactLastMsg(contact.id, getMsgSummary(message), Date.now()) + conversationStore.handleMessageUpdate(message, message.isSelf === true) if (!message.isSelf) { // 如果不是当前聊天窗口,或者是当前窗口但用户不在底部 if (!chatStore.currentTarget || chatStore.currentTarget.id !== contact.id) { @@ -441,6 +508,7 @@ onMounted(async () => { wsManager.onSignal(webrtc.handleSignaling) } await loadContacts() + await conversationStore.loadConversations() window.addEventListener('resize', () => isMobile.value = window.innerWidth < 768) }) onUnmounted(() => { diff --git a/src/views/contact/ContactCircle.vue b/src/views/contact/ContactCircle.vue index ccb9239..5a50dd0 100644 --- a/src/views/contact/ContactCircle.vue +++ b/src/views/contact/ContactCircle.vue @@ -1,191 +1,227 @@ diff --git a/src/views/contact/FriendNotifyView.vue b/src/views/contact/FriendNotifyView.vue new file mode 100644 index 0000000..bfd7b60 --- /dev/null +++ b/src/views/contact/FriendNotifyView.vue @@ -0,0 +1,125 @@ + + + + diff --git a/src/views/contact/README.md b/src/views/contact/README.md new file mode 100644 index 0000000..fcdea43 --- /dev/null +++ b/src/views/contact/README.md @@ -0,0 +1,113 @@ +# 联系人模块设计文档 + +## 概述 + +联系人模块采用 PC QQ 风格的布局设计,左侧为联系人侧边栏,右侧根据当前状态显示不同内容(默认欢迎页、好友资料卡、好友管理器、好友通知等)。 + +## 关键设计点 + +### 1. 状态管理 + +- **`useContactStore`**: 管理联系人相关的全局状态 + - `leftPanelMode`: 控制右侧面板显示模式(`'default' | 'friend-manager' | 'friend-notify' | 'group-notify'`) + - `selectedContact`: 当前选中的联系人 + - `contactListTab`: 联系人列表 Tab(`'friends' | 'groups'`) + - `friendRequests`: 好友申请列表 + - `groups`: 联系人分组列表 + +- **`useChatStore`**: 管理聊天相关的状态 + - `currentTarget`: 当前聊天对象(与 `contactStore.selectedContact` 可复用) + - `contacts`: 联系人列表 + +### 2. 组件结构 + +#### ContactCircle.vue(左侧联系人侧边栏) +- **功能**: + - 顶部搜索框:本地过滤联系人列表 + - 三个入口按钮:好友管理器、好友通知、群通知 + - 好友/群聊 Tab 切换 + - 按分组折叠/展开的联系人列表 + - 显示联系人状态图标(在线、置顶、免打扰、特别关心、拉黑) + +- **交互**: + - 点击联系人:设置 `contactStore.selectedContact`,右侧显示资料卡 + - 右键菜单:查看资料、修改备注、置顶、免打扰、特别关心、拉黑、删除好友 + +#### ContactDetailCard.vue(好友资料卡) +- **功能**: + - 显示好友详细信息(头像、昵称、签名、账号ID、备注、分组等) + - 操作按钮:发消息、语音通话、视频通话、更多操作 + - 更多操作:修改备注、设置分组、置顶、免打扰、特别关心、删除好友 + +- **交互**: + - 点击"发消息":切换到 Chat Tab,选中对应好友 + - 点击"语音/视频通话":切换到 Chat Tab,发起通话 + +#### ContactView.vue(好友管理器) +- **功能**: + - 搜索用户并添加好友 + - 显示好友申请列表(同意/拒绝) + +- **交互**: + - 搜索用户:调用 `contactApi.searchUsers` + - 添加好友:调用 `contactApi.addFriend`,成功后刷新联系人列表 + - 同意/拒绝申请:调用 `contactApi.acceptFriendRequest` / `rejectFriendRequest`,成功后刷新联系人列表和会话列表 + +#### FriendNotifyView.vue(好友通知视图) +- **功能**: + - 显示所有待处理的好友申请 + - 支持同意/拒绝操作 + +- **交互**: + - 同意申请:刷新联系人列表和会话列表 + - 拒绝申请:仅移除该条申请 + +### 3. 数据流 + +#### 添加好友流程 +1. 用户在 `ContactView.vue` 中搜索用户 +2. 点击"添加"按钮,调用 `contactApi.addFriend` +3. 成功后显示提示,对方收到好友申请 + +#### 同意好友申请流程 +1. 用户在 `FriendNotifyView.vue` 或 `ContactView.vue` 中看到申请 +2. 点击"同意",调用 `contactApi.acceptFriendRequest` +3. 成功后: + - 从申请列表中移除 + - 刷新联系人列表(`chatStore.setContacts`) + - 刷新会话列表(`conversationStore.loadConversations`) + +#### 选中联系人流程 +1. 用户在 `ContactCircle.vue` 中点击联系人 +2. 设置 `contactStore.setSelectedContact(contact)` +3. 右侧自动显示 `ContactDetailCard.vue` +4. 点击"发消息"或"视频通话"时: + - 设置 `chatStore.setCurrentTarget(contact)` + - 通过 emit 事件通知 `ChatView.vue` 切换到 Chat Tab + - 如果是通话,延迟 100ms 后发起通话 + +### 4. API 接口 + +- `GET /api/contacts`: 获取联系人列表 +- `GET /api/contacts/search`: 搜索用户 +- `POST /api/contacts/add-friend`: 添加好友(发送申请) +- `GET /api/contacts/friend-requests`: 获取好友申请列表 +- `POST /api/contacts/accept-request`: 接受好友申请 +- `POST /api/contacts/reject-request`: 拒绝好友申请 +- `GET /api/contacts/:id`: 获取联系人详情 +- `POST /api/contacts/update/:id`: 更新联系人信息(备注、分组、标记等) +- `POST /api/contacts/delete/:id`: 删除好友 + +### 5. 与会话模块的联动 + +- 联系人列表和会话列表是分离的,但共享 `chatStore.currentTarget` +- 在联系人页面点击"发消息"时,会切换到 Chat Tab 并选中对应好友 +- 在会话列表中点击会话时,会选中对应联系人(如果存在) + +### 6. 注意事项 + +- 所有联系人操作(添加、删除、更新)都需要刷新联系人列表 +- 接受好友申请后需要同时刷新联系人列表和会话列表 +- 联系人状态(置顶、免打扰、特别关心、拉黑)会同步到联系人列表和资料卡 +- 搜索框只做本地过滤,不调用后端接口 +