部分联系人信息和会话模块
This commit is contained in:
@@ -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<Contact>(`/contacts/update/${id}`, data)
|
||||
}
|
||||
|
||||
37
src/api/modules/conversation.ts
Normal file
37
src/api/modules/conversation.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import request from '../request'
|
||||
import type { Conversation } from '@/types/conversation'
|
||||
|
||||
/**
|
||||
* 会话列表相关 API
|
||||
*/
|
||||
|
||||
// 获取会话列表
|
||||
export function getConversationList() {
|
||||
return request.get<Conversation[]>('/conversations')
|
||||
}
|
||||
|
||||
// 重置未读
|
||||
export function resetUnread(targetId: string) {
|
||||
return request.post<void>('/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<void>('/conversations/update', data)
|
||||
}
|
||||
|
||||
// 删除会话
|
||||
export function deleteConversation(targetId: string) {
|
||||
return request.post<void>('/conversations/delete', {
|
||||
target_id: targetId,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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<FriendRequest[]>([])
|
||||
const groups = ref<ContactGroup[]>([])
|
||||
const leftPanelMode = ref<LeftPanelMode>('default')
|
||||
const selectedContact = ref<Contact | null>(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,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
111
src/stores/conversation.ts
Normal file
111
src/stores/conversation.ts
Normal file
@@ -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<Conversation[]>([])
|
||||
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<number, string> = { 1: '[图片]', 2: '[语音]', 3: '[视频]', 8: '[文件]', 6: '[通话]' }
|
||||
return types[msg.message_type] || msg.content
|
||||
}
|
||||
|
||||
return {
|
||||
conversations,
|
||||
loading,
|
||||
totalUnread,
|
||||
loadConversations,
|
||||
handleMessageUpdate,
|
||||
incrementUnread,
|
||||
clearUnread
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
|
||||
23
src/types/conversation.ts
Normal file
23
src/types/conversation.ts
Normal file
@@ -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
|
||||
}
|
||||
@@ -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="搜索会话或联系人..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto px-2 space-y-1 py-2 custom-scrollbar">
|
||||
<div
|
||||
v-for="contact in filteredContacts"
|
||||
:key="contact.id"
|
||||
v-for="conv in filteredConversations"
|
||||
:key="conv.id"
|
||||
class="flex items-center p-3 cursor-pointer rounded-2xl transition-all duration-200 relative group border border-transparent"
|
||||
:class="{
|
||||
'bg-gradient-to-r from-primary/20 to-transparent border-primary/30': chatStore.currentTarget?.id === contact.id,
|
||||
'hover:bg-white/5 hover:border-white/5': chatStore.currentTarget?.id !== contact.id,
|
||||
'bg-black/20': contact.is_top,
|
||||
'bg-gradient-to-r from-primary/20 to-transparent border-primary/30': chatStore.currentTarget && (chatStore.currentTarget.user_id === conv.target_id || chatStore.currentTarget.id === conv.target_id),
|
||||
'hover:bg-white/5 hover:border-white/5': !chatStore.currentTarget || (chatStore.currentTarget.user_id !== conv.target_id && chatStore.currentTarget.id !== conv.target_id),
|
||||
'bg-black/20': conv.is_top,
|
||||
}"
|
||||
@click="selectChat(contact)"
|
||||
@contextmenu.stop="showContactMenu($event, contact)"
|
||||
@click="selectChatByConversation(conv)"
|
||||
@contextmenu.stop="showConversationMenu($event, conv)"
|
||||
>
|
||||
<div class="relative shrink-0">
|
||||
<Avatar
|
||||
:name="contact.user?.name || contact.remark_name"
|
||||
:avatar="contact.user?.avatar || contact.remark_name?.charAt(0)"
|
||||
:color="contact.color"
|
||||
:name="conv.name"
|
||||
:avatar="conv.avatar || conv.name?.charAt(0)"
|
||||
:color="undefined"
|
||||
size="contact"
|
||||
rounded="xl"
|
||||
class="transition transform group-hover:scale-105 shadow-md"
|
||||
/>
|
||||
<div
|
||||
v-if="(contact.unread || 0) > 0"
|
||||
v-if="(conv.unread_count || 0) > 0"
|
||||
class="absolute -top-1.5 -right-1.5 bg-red-500 text-white text-[10px] min-w-[18px] h-[18px] flex items-center justify-center rounded-full border-2 border-panel font-bold shadow-sm animate-bounce"
|
||||
>
|
||||
{{ contact.unread }}
|
||||
{{ conv.unread_count }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0 ml-3">
|
||||
<div class="flex justify-between items-center mb-0.5">
|
||||
<span class="font-semibold truncate text-gray-200 text-sm group-hover:text-white transition">
|
||||
{{ contact.remark_name || contact.user?.name || '未知' }}
|
||||
{{ conv.name || '未知' }}
|
||||
</span>
|
||||
<span class="text-xs text-gray-500 group-hover:text-gray-400">{{ formatTime(contact.last_time || Date.now()) }}</span>
|
||||
<span class="text-xs text-gray-500 group-hover:text-gray-400">{{ formatTime(conv.last_time || Date.now()) }}</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-400 truncate flex items-center h-4 group-hover:text-gray-300">
|
||||
<i v-if="contact.is_top" class="fas fa-thumbtack text-yellow-500 mr-1 text-[10px]"></i>
|
||||
<span v-if="contact.is_muted" class="fas fa-bell-slash text-gray-600 mr-1 text-[10px]"></span>
|
||||
<span>{{ contact.lastMsg || contact.user?.desc || '' }}</span>
|
||||
<i v-if="conv.is_top" class="fas fa-thumbtack text-yellow-500 mr-1 text-[10px]"></i>
|
||||
<span v-if="conv.is_muted" class="fas fa-bell-slash text-gray-600 mr-1 text-[10px]"></span>
|
||||
<span>{{ conv.last_message || '' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -181,8 +181,51 @@
|
||||
<p class="text-sm mt-3 opacity-60 font-light">Design for Developer</p>
|
||||
</div>
|
||||
|
||||
<!-- 联系人页面 -->
|
||||
<ContactView v-if="currentTab === 'contact'" />
|
||||
<!-- 联系人页面:左侧联系人列表 + 右侧联系人操作区(QQ 风格) -->
|
||||
<div
|
||||
v-if="currentTab === 'contact'"
|
||||
class="flex flex-1 bg-panel h-full"
|
||||
>
|
||||
<!-- 左侧联系人列表(分组 & QQ 风格) -->
|
||||
<div class="w-80 border-r border-gray-800 shrink-0">
|
||||
<ContactCircle />
|
||||
</div>
|
||||
|
||||
<!-- 右侧:根据 leftPanelMode 和选中好友显示不同内容 -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<!-- 默认空页面 -->
|
||||
<div
|
||||
v-if="contactStore.leftPanelMode === 'default' && !contactStore.selectedContact"
|
||||
class="flex-1 flex flex-col items-center justify-center bg-dark text-gray-500 h-full"
|
||||
>
|
||||
<div class="w-32 h-32 bg-panel rounded-full flex items-center justify-center mb-6 shadow-2xl border border-gray-800 animate-float">
|
||||
<i class="fas fa-address-book text-5xl text-primary opacity-80"></i>
|
||||
</div>
|
||||
<p class="text-2xl font-medium text-gray-300 tracking-widest">NL-IM</p>
|
||||
<p class="text-sm mt-3 opacity-60 font-light">Design for Developer</p>
|
||||
</div>
|
||||
|
||||
<!-- 好友资料卡视图 -->
|
||||
<ContactDetailCard
|
||||
v-else-if="contactStore.selectedContact"
|
||||
@switch-to-chat="currentTab = 'chat'"
|
||||
/>
|
||||
|
||||
<!-- 好友管理器视图 -->
|
||||
<ContactView v-else-if="contactStore.leftPanelMode === 'friend-manager'" />
|
||||
|
||||
<!-- 好友通知视图 -->
|
||||
<FriendNotifyView v-else-if="contactStore.leftPanelMode === 'friend-notify'" />
|
||||
|
||||
<!-- 群通知视图(预留) -->
|
||||
<div v-else-if="contactStore.leftPanelMode === 'group-notify'" class="flex-1 flex items-center justify-center text-gray-400">
|
||||
<div class="text-center">
|
||||
<i class="fas fa-users text-6xl mb-4 opacity-50"></i>
|
||||
<p class="text-sm">群通知功能开发中</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ContextMenu />
|
||||
<CallWindow ref="callWindowRef" :call="webrtc.call" :target="chatStore.currentTarget" :is-mobile="isMobile" :local-stream="webrtc.localStream.value" :remote-stream="webrtc.remoteStream.value" @end-call="webrtc.endCall" @accept-call="webrtc.acceptCall" @toggle-mute="webrtc.toggleMute" @toggle-camera="webrtc.toggleCamera" @toggle-minimize="webrtc.call.minimized = !webrtc.call.minimized" />
|
||||
<FileConfirmModal :show="fileModal.show" :type="fileModal.type" :preview="fileModal.preview" :name="fileModal.name" :size="fileModal.size" @close="fileModal.show = false" @confirm="confirmSendFile" />
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -1,191 +1,227 @@
|
||||
<template>
|
||||
<div class="flex-1 flex flex-col bg-panel h-full">
|
||||
<!-- 顶部导航栏 -->
|
||||
<div class="p-4 border-b border-gray-800 flex justify-between items-center">
|
||||
<h2 class="text-xl font-bold text-white">好友圈</h2>
|
||||
<!-- 顶部搜索框 -->
|
||||
<div class="p-3 border-b border-gray-800">
|
||||
<div class="relative group">
|
||||
<i class="fas fa-search absolute left-3 top-2.5 text-gray-500 group-focus-within:text-primary transition text-sm"></i>
|
||||
<input
|
||||
v-model="searchKeyword"
|
||||
type="text"
|
||||
class="w-full bg-input rounded-lg py-2 pl-9 pr-3 text-sm text-white focus:ring-2 ring-primary outline-none transition placeholder-gray-500"
|
||||
placeholder="搜索好友或群聊..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 入口按钮行:好友管理器、好友通知、群通知 -->
|
||||
<div class="px-3 py-2 border-b border-gray-800 flex gap-2">
|
||||
<button
|
||||
class="px-4 py-2 bg-primary hover:bg-indigo-500 text-white rounded-lg transition text-sm"
|
||||
@click="showAddGroupModal = true"
|
||||
class="flex-1 flex flex-col items-center gap-1 py-2 px-2 rounded-lg transition"
|
||||
:class="contactStore.leftPanelMode === 'friend-manager' ? 'bg-primary/20 text-primary' : 'text-gray-400 hover:bg-white/5 hover:text-white'"
|
||||
@click="contactStore.setLeftPanelMode('friend-manager')"
|
||||
>
|
||||
<i class="fas fa-plus mr-2"></i>新建分组
|
||||
<i class="fas fa-user-friends text-base"></i>
|
||||
<span class="text-xs">好友管理器</span>
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 flex flex-col items-center gap-1 py-2 px-2 rounded-lg transition relative"
|
||||
:class="contactStore.leftPanelMode === 'friend-notify' ? 'bg-primary/20 text-primary' : 'text-gray-400 hover:bg-white/5 hover:text-white'"
|
||||
@click="contactStore.setLeftPanelMode('friend-notify')"
|
||||
>
|
||||
<i class="fas fa-user-plus text-base"></i>
|
||||
<span class="text-xs">好友通知</span>
|
||||
<span
|
||||
v-if="contactStore.friendRequests.length > 0"
|
||||
class="absolute top-0 right-1 bg-red-500 text-white text-[10px] min-w-[16px] h-4 flex items-center justify-center rounded-full px-1"
|
||||
>
|
||||
{{ contactStore.friendRequests.length }}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 flex flex-col items-center gap-1 py-2 px-2 rounded-lg transition"
|
||||
:class="contactStore.leftPanelMode === 'group-notify' ? 'bg-primary/20 text-primary' : 'text-gray-400 hover:bg-white/5 hover:text-white'"
|
||||
@click="contactStore.setLeftPanelMode('group-notify')"
|
||||
>
|
||||
<i class="fas fa-users text-base"></i>
|
||||
<span class="text-xs">群通知</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 分组标签栏 -->
|
||||
<div class="px-4 py-3 border-b border-gray-800 overflow-x-auto">
|
||||
<div class="flex gap-2 min-w-max">
|
||||
<button
|
||||
v-for="group in groups"
|
||||
:key="group.id"
|
||||
class="px-4 py-2 rounded-lg transition whitespace-nowrap"
|
||||
:class="
|
||||
currentGroupId === group.id
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-input text-gray-300 hover:bg-white/5'
|
||||
"
|
||||
@click="currentGroupId = group.id"
|
||||
>
|
||||
{{ group.group_name }}
|
||||
</button>
|
||||
<button
|
||||
class="px-4 py-2 rounded-lg transition whitespace-nowrap"
|
||||
:class="
|
||||
currentGroupId === null
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-input text-gray-300 hover:bg-white/5'
|
||||
"
|
||||
@click="currentGroupId = null"
|
||||
>
|
||||
全部
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 好友列表 -->
|
||||
<div class="flex-1 overflow-y-auto p-4">
|
||||
<div v-if="filteredContacts.length === 0" class="text-center py-12 text-gray-400">
|
||||
<i class="fas fa-users text-4xl mb-4 opacity-50"></i>
|
||||
<p>暂无好友</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-2">
|
||||
<!-- Tab 区域:好友 / 群聊 -->
|
||||
<div class="px-3 py-2 border-b border-gray-800 flex gap-1">
|
||||
<button
|
||||
class="flex-1 py-2 text-sm font-medium rounded-lg transition relative"
|
||||
:class="contactStore.contactListTab === 'friends' ? 'text-primary' : 'text-gray-400 hover:text-white'"
|
||||
@click="contactStore.contactListTab = 'friends'"
|
||||
>
|
||||
好友
|
||||
<div
|
||||
v-for="contact in filteredContacts"
|
||||
:key="contact.id"
|
||||
class="flex items-center p-3 bg-input rounded-lg hover:bg-white/5 transition cursor-pointer group"
|
||||
@click="handleContactClick(contact)"
|
||||
@contextmenu.stop="showContactMenu($event, contact)"
|
||||
>
|
||||
<div class="relative shrink-0">
|
||||
<Avatar
|
||||
:name="contact.user?.name || contact.remark_name"
|
||||
:avatar="contact.user?.avatar"
|
||||
:color="contact.color"
|
||||
size="md"
|
||||
rounded="xl"
|
||||
/>
|
||||
<div
|
||||
v-if="contact.user?.is_online"
|
||||
class="absolute bottom-0 right-0 w-3 h-3 bg-success rounded-full border-2 border-panel"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0 ml-3">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="font-semibold text-white text-sm truncate">
|
||||
{{ contact.remark_name || contact.user?.name || '未知' }}
|
||||
</span>
|
||||
<span v-if="contact.unread && contact.unread > 0" class="text-xs text-red-500 font-bold">
|
||||
{{ contact.unread }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-400 truncate">
|
||||
{{ contact.user?.desc || contact.last_msg || '暂无签名' }}
|
||||
</div>
|
||||
<div v-if="contact.last_time" class="text-xs text-gray-500 mt-1">
|
||||
{{ formatTime(contact.last_time) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 ml-2 opacity-0 group-hover:opacity-100 transition">
|
||||
<i v-if="contact.is_top" class="fas fa-thumbtack text-yellow-500 text-sm"></i>
|
||||
<i v-if="contact.is_muted" class="fas fa-bell-slash text-gray-500 text-sm"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
v-if="contactStore.contactListTab === 'friends'"
|
||||
class="absolute bottom-0 left-0 right-0 h-0.5 bg-primary rounded-t"
|
||||
></div>
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 py-2 text-sm font-medium rounded-lg transition relative"
|
||||
:class="contactStore.contactListTab === 'groups' ? 'text-primary' : 'text-gray-400 hover:text-white'"
|
||||
@click="contactStore.contactListTab = 'groups'"
|
||||
>
|
||||
群聊
|
||||
<div
|
||||
v-if="contactStore.contactListTab === 'groups'"
|
||||
class="absolute bottom-0 left-0 right-0 h-0.5 bg-primary rounded-t"
|
||||
></div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 新建分组模态框 -->
|
||||
<div
|
||||
v-if="showAddGroupModal"
|
||||
class="fixed inset-0 bg-black/80 z-[100] flex items-center justify-center p-4 backdrop-blur-sm"
|
||||
@click.self="showAddGroupModal = false"
|
||||
>
|
||||
<div class="bg-panel rounded-xl w-96 overflow-hidden shadow-2xl border border-gray-700">
|
||||
<div class="p-4 border-b border-gray-700 font-bold bg-gray-800/50 flex justify-between items-center">
|
||||
<span>新建分组</span>
|
||||
<i
|
||||
class="fas fa-times cursor-pointer hover:text-white text-gray-500"
|
||||
@click="showAddGroupModal = false"
|
||||
></i>
|
||||
<!-- 好友/群聊列表 -->
|
||||
<div class="flex-1 overflow-y-auto custom-scrollbar">
|
||||
<!-- 好友列表 -->
|
||||
<div v-if="contactStore.contactListTab === 'friends'">
|
||||
<div v-if="filteredContacts.length === 0" class="text-center py-12 text-gray-400">
|
||||
<i class="fas fa-users text-4xl mb-4 opacity-50"></i>
|
||||
<p class="text-sm">暂无好友</p>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<input
|
||||
v-model="newGroupName"
|
||||
type="text"
|
||||
class="w-full bg-input rounded-lg py-2 px-4 text-white focus:ring-2 ring-primary outline-none transition placeholder-gray-500"
|
||||
placeholder="请输入分组名称"
|
||||
@keyup.enter="handleCreateGroup"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex border-t border-gray-700">
|
||||
<button
|
||||
class="flex-1 py-3 text-gray-400 hover:bg-gray-700 transition"
|
||||
@click="showAddGroupModal = false"
|
||||
|
||||
<div v-else class="space-y-1 p-2">
|
||||
<!-- 未分组 -->
|
||||
<section v-if="groupedContacts[0] && groupedContacts[0].length">
|
||||
<header
|
||||
class="flex items-center justify-between text-xs text-gray-400 mb-1 px-2 py-1 cursor-pointer select-none hover:bg-white/5 rounded transition"
|
||||
@click="toggleGroupCollapse(0)"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<i :class="collapsedGroupIds.includes(0) ? 'fas fa-chevron-right text-[10px]' : 'fas fa-chevron-down text-[10px]'"></i>
|
||||
<span>未分组({{ groupedContacts[0].length }})</span>
|
||||
</div>
|
||||
</header>
|
||||
<div v-if="!collapsedGroupIds.includes(0)" class="space-y-0.5">
|
||||
<div
|
||||
v-for="contact in groupedContacts[0]"
|
||||
:key="contact.id"
|
||||
class="flex items-center p-2 rounded-lg transition cursor-pointer group"
|
||||
:class="
|
||||
contactStore.selectedContact?.id === contact.id
|
||||
? 'bg-primary/20 text-primary'
|
||||
: 'hover:bg-white/5'
|
||||
"
|
||||
@click="handleContactClick(contact)"
|
||||
@contextmenu.stop="showContactMenu($event, contact)"
|
||||
>
|
||||
<div class="relative shrink-0">
|
||||
<Avatar
|
||||
:name="contact.user?.name || contact.remark_name"
|
||||
:avatar="contact.user?.avatar"
|
||||
:color="contact.color"
|
||||
size="sm"
|
||||
rounded="xl"
|
||||
/>
|
||||
<div
|
||||
v-if="contact.user?.is_online"
|
||||
class="absolute bottom-0 right-0 w-2.5 h-2.5 bg-success rounded-full border-2 border-panel"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0 ml-2">
|
||||
<div class="flex items-center justify-between mb-0.5">
|
||||
<span class="font-medium text-white text-xs truncate">
|
||||
{{ contact.remark_name || contact.user?.name || '未知' }}
|
||||
</span>
|
||||
<span v-if="contact.unread && contact.unread > 0" class="text-[10px] text-red-500 font-bold ml-1">
|
||||
{{ contact.unread }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-[10px] text-gray-400 truncate">
|
||||
{{ contact.user?.desc || contact.last_msg || '暂无签名' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1 ml-1 opacity-0 group-hover:opacity-100 transition">
|
||||
<i v-if="contact.is_special_care" class="fas fa-star text-yellow-400 text-[10px]"></i>
|
||||
<i v-if="contact.is_top" class="fas fa-thumbtack text-yellow-500 text-[10px]"></i>
|
||||
<i v-if="contact.is_muted" class="fas fa-bell-slash text-gray-500 text-[10px]"></i>
|
||||
<i v-if="contact.is_blocked" class="fas fa-ban text-red-500 text-[10px]"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 其他分组 -->
|
||||
<section
|
||||
v-for="group in groups"
|
||||
:key="group.id"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 py-3 text-primary font-bold hover:bg-gray-700 transition border-l border-gray-700"
|
||||
@click="handleCreateGroup"
|
||||
>
|
||||
确认
|
||||
</button>
|
||||
<header
|
||||
class="flex items-center justify-between text-xs text-gray-400 mb-1 px-2 py-1 cursor-pointer select-none hover:bg-white/5 rounded transition"
|
||||
@click="toggleGroupCollapse(group.id)"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<i :class="collapsedGroupIds.includes(group.id) ? 'fas fa-chevron-right text-[10px]' : 'fas fa-chevron-down text-[10px]'"></i>
|
||||
<span>
|
||||
{{ group.group_name }}
|
||||
({{ (groupedContacts[group.id] || []).length }})
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
<div v-if="!collapsedGroupIds.includes(group.id)" class="space-y-0.5">
|
||||
<div
|
||||
v-for="contact in groupedContacts[group.id] || []"
|
||||
:key="contact.id"
|
||||
class="flex items-center p-2 rounded-lg transition cursor-pointer group"
|
||||
:class="
|
||||
contactStore.selectedContact?.id === contact.id
|
||||
? 'bg-primary/20 text-primary'
|
||||
: 'hover:bg-white/5'
|
||||
"
|
||||
@click="handleContactClick(contact)"
|
||||
@contextmenu.stop="showContactMenu($event, contact)"
|
||||
>
|
||||
<div class="relative shrink-0">
|
||||
<Avatar
|
||||
:name="contact.user?.name || contact.remark_name"
|
||||
:avatar="contact.user?.avatar"
|
||||
:color="contact.color"
|
||||
size="sm"
|
||||
rounded="xl"
|
||||
/>
|
||||
<div
|
||||
v-if="contact.user?.is_online"
|
||||
class="absolute bottom-0 right-0 w-2.5 h-2.5 bg-success rounded-full border-2 border-panel"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0 ml-2">
|
||||
<div class="flex items-center justify-between mb-0.5">
|
||||
<span class="font-medium text-white text-xs truncate">
|
||||
{{ contact.remark_name || contact.user?.name || '未知' }}
|
||||
</span>
|
||||
<span v-if="contact.unread && contact.unread > 0" class="text-[10px] text-red-500 font-bold ml-1">
|
||||
{{ contact.unread }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-[10px] text-gray-400 truncate">
|
||||
{{ contact.user?.desc || contact.last_msg || '暂无签名' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1 ml-1 opacity-0 group-hover:opacity-100 transition">
|
||||
<i v-if="contact.is_special_care" class="fas fa-star text-yellow-400 text-[10px]"></i>
|
||||
<i v-if="contact.is_top" class="fas fa-thumbtack text-yellow-500 text-[10px]"></i>
|
||||
<i v-if="contact.is_muted" class="fas fa-bell-slash text-gray-500 text-[10px]"></i>
|
||||
<i v-if="contact.is_blocked" class="fas fa-ban text-red-500 text-[10px]"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 编辑分组模态框 -->
|
||||
<div
|
||||
v-if="showEditGroupModal && editingGroup"
|
||||
class="fixed inset-0 bg-black/80 z-[100] flex items-center justify-center p-4 backdrop-blur-sm"
|
||||
@click.self="showEditGroupModal = false"
|
||||
>
|
||||
<div class="bg-panel rounded-xl w-96 overflow-hidden shadow-2xl border border-gray-700">
|
||||
<div class="p-4 border-b border-gray-700 font-bold bg-gray-800/50 flex justify-between items-center">
|
||||
<span>编辑分组</span>
|
||||
<i
|
||||
class="fas fa-times cursor-pointer hover:text-white text-gray-500"
|
||||
@click="showEditGroupModal = false"
|
||||
></i>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<input
|
||||
v-model="editingGroupName"
|
||||
type="text"
|
||||
class="w-full bg-input rounded-lg py-2 px-4 text-white focus:ring-2 ring-primary outline-none transition placeholder-gray-500"
|
||||
placeholder="请输入分组名称"
|
||||
@keyup.enter="handleUpdateGroup"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex border-t border-gray-700">
|
||||
<button
|
||||
class="flex-1 py-3 text-gray-400 hover:bg-gray-700 transition"
|
||||
@click="showEditGroupModal = false"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 py-3 text-primary font-bold hover:bg-gray-700 transition border-l border-gray-700"
|
||||
@click="handleUpdateGroup"
|
||||
>
|
||||
确认
|
||||
</button>
|
||||
</div>
|
||||
<!-- 群聊列表(暂时简单展示) -->
|
||||
<div v-else class="text-center py-12 text-gray-400">
|
||||
<i class="fas fa-users text-4xl mb-4 opacity-50"></i>
|
||||
<p class="text-sm">群聊功能开发中</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 删除确认模态框 -->
|
||||
<ConfirmModal
|
||||
:show="showDeleteConfirm"
|
||||
title="确认删除"
|
||||
message="确定要删除这个分组吗?分组内的好友不会被删除。"
|
||||
type="danger"
|
||||
confirm-text="删除"
|
||||
@confirm="confirmDeleteGroup"
|
||||
@cancel="showDeleteConfirm = false"
|
||||
/>
|
||||
|
||||
<!-- 右键菜单 -->
|
||||
<ContextMenu />
|
||||
</div>
|
||||
@@ -201,8 +237,6 @@ import { useContextMenu } from '@/composables/useContextMenu'
|
||||
import * as contactApi from '@/api/modules/contact'
|
||||
import Avatar from '@/components/common/Avatar.vue'
|
||||
import ContextMenu from '@/components/common/ContextMenu.vue'
|
||||
import ConfirmModal from '@/components/common/ConfirmModal.vue'
|
||||
import { formatTime } from '@/utils/format'
|
||||
import type { Contact, ContactGroup } from '@/types/api'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -211,104 +245,57 @@ const chatStore = useChatStore()
|
||||
const toastStore = useToastStore()
|
||||
const { showContextMenu } = useContextMenu()
|
||||
|
||||
const currentGroupId = ref<number | null>(null)
|
||||
const searchKeyword = ref('')
|
||||
const groups = ref<ContactGroup[]>([])
|
||||
const showAddGroupModal = ref(false)
|
||||
const showEditGroupModal = ref(false)
|
||||
const editingGroup = ref<ContactGroup | null>(null)
|
||||
const editingGroupName = ref('')
|
||||
const newGroupName = ref('')
|
||||
const showDeleteConfirm = ref(false)
|
||||
const deletingGroupId = ref<number | null>(null)
|
||||
const collapsedGroupIds = ref<number[]>([])
|
||||
|
||||
// 过滤后的联系人(根据搜索关键字)
|
||||
const filteredContacts = computed(() => {
|
||||
const contacts = chatStore.contacts
|
||||
if (currentGroupId.value === null) {
|
||||
return contacts
|
||||
const keyword = searchKeyword.value.toLowerCase().trim()
|
||||
if (!keyword) return contacts
|
||||
|
||||
return contacts.filter((c) => {
|
||||
const name = (c.remark_name || c.user?.name || '').toLowerCase()
|
||||
const desc = (c.user?.desc || '').toLowerCase()
|
||||
const userId = (c.user_id || c.id || '').toLowerCase()
|
||||
return name.includes(keyword) || desc.includes(keyword) || userId.includes(keyword)
|
||||
})
|
||||
})
|
||||
|
||||
// 分组 -> 联系人列表
|
||||
const groupedContacts = computed<Record<number, Contact[]>>(() => {
|
||||
const map: Record<number, Contact[]> = {}
|
||||
for (const c of filteredContacts.value) {
|
||||
const gid = c.group_id || 0
|
||||
if (!map[gid]) map[gid] = []
|
||||
map[gid].push(c)
|
||||
}
|
||||
return contacts.filter((c) => c.group_id === currentGroupId.value)
|
||||
return map
|
||||
})
|
||||
|
||||
async function loadGroups() {
|
||||
try {
|
||||
const groupsList = await contactApi.getGroups()
|
||||
groups.value = groupsList
|
||||
contactStore.setGroups(groupsList)
|
||||
} catch (error: any) {
|
||||
console.error('Failed to load groups:', error)
|
||||
toastStore.error(error.message || '加载分组失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateGroup() {
|
||||
if (!newGroupName.value.trim()) {
|
||||
toastStore.warning('请输入分组名称')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const group = await contactApi.createGroup({ group_name: newGroupName.value })
|
||||
groups.value.push(group)
|
||||
newGroupName.value = ''
|
||||
showAddGroupModal.value = false
|
||||
toastStore.success('分组创建成功')
|
||||
} catch (error: any) {
|
||||
toastStore.error(error.message || '创建失败')
|
||||
}
|
||||
}
|
||||
|
||||
function handleEditGroup(group: ContactGroup) {
|
||||
editingGroup.value = group
|
||||
editingGroupName.value = group.group_name
|
||||
showEditGroupModal.value = true
|
||||
}
|
||||
|
||||
async function handleUpdateGroup() {
|
||||
if (!editingGroup.value || !editingGroupName.value.trim()) {
|
||||
toastStore.warning('请输入分组名称')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await contactApi.updateGroup(editingGroup.value.id, {
|
||||
group_name: editingGroupName.value,
|
||||
})
|
||||
const index = groups.value.findIndex((g) => g.id === editingGroup.value!.id)
|
||||
if (index > -1) {
|
||||
groups.value[index].group_name = editingGroupName.value
|
||||
}
|
||||
showEditGroupModal.value = false
|
||||
editingGroup.value = null
|
||||
toastStore.success('分组更新成功')
|
||||
} catch (error: any) {
|
||||
toastStore.error(error.message || '更新失败')
|
||||
}
|
||||
}
|
||||
|
||||
function handleDeleteGroup(group: ContactGroup) {
|
||||
deletingGroupId.value = group.id
|
||||
showDeleteConfirm.value = true
|
||||
}
|
||||
|
||||
async function confirmDeleteGroup() {
|
||||
if (!deletingGroupId.value) return
|
||||
|
||||
try {
|
||||
await contactApi.deleteGroup(deletingGroupId.value)
|
||||
groups.value = groups.value.filter((g) => g.id !== deletingGroupId.value)
|
||||
if (currentGroupId.value === deletingGroupId.value) {
|
||||
currentGroupId.value = null
|
||||
}
|
||||
showDeleteConfirm.value = false
|
||||
deletingGroupId.value = null
|
||||
toastStore.success('分组删除成功')
|
||||
} catch (error: any) {
|
||||
toastStore.error(error.message || '删除失败')
|
||||
function toggleGroupCollapse(groupId: number) {
|
||||
const idx = collapsedGroupIds.value.indexOf(groupId)
|
||||
if (idx === -1) {
|
||||
collapsedGroupIds.value.push(groupId)
|
||||
} else {
|
||||
collapsedGroupIds.value.splice(idx, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function handleContactClick(contact: Contact) {
|
||||
chatStore.setCurrentTarget(contact)
|
||||
router.push('/chat')
|
||||
contactStore.setSelectedContact(contact)
|
||||
}
|
||||
|
||||
function showContactMenu(event: MouseEvent, contact: Contact) {
|
||||
@@ -317,7 +304,7 @@ function showContactMenu(event: MouseEvent, contact: Contact) {
|
||||
label: '查看资料',
|
||||
icon: 'fas fa-user',
|
||||
action: () => {
|
||||
router.push(`/contact/${contact.id}`)
|
||||
contactStore.setSelectedContact(contact)
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -354,6 +341,32 @@ function showContactMenu(event: MouseEvent, contact: Contact) {
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: contact.is_special_care ? '取消特别关心' : '特别关心',
|
||||
icon: 'fas fa-star',
|
||||
action: async () => {
|
||||
try {
|
||||
await contactApi.updateContact(contact.id, { is_special_care: !contact.is_special_care })
|
||||
contact.is_special_care = !contact.is_special_care
|
||||
toastStore.success(contact.is_special_care ? '已设为特别关心' : '已取消特别关心')
|
||||
} catch (error: any) {
|
||||
toastStore.error(error.message || '操作失败')
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: contact.is_blocked ? '取消拉黑' : '拉黑',
|
||||
icon: 'fas fa-ban',
|
||||
action: async () => {
|
||||
try {
|
||||
await contactApi.updateContact(contact.id, { is_blocked: !contact.is_blocked })
|
||||
contact.is_blocked = !contact.is_blocked
|
||||
toastStore.success(contact.is_blocked ? '已拉黑' : '已取消拉黑')
|
||||
} catch (error: any) {
|
||||
toastStore.error(error.message || '操作失败')
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '删除好友',
|
||||
icon: 'fas fa-trash',
|
||||
@@ -362,6 +375,9 @@ function showContactMenu(event: MouseEvent, contact: Contact) {
|
||||
try {
|
||||
await contactApi.deleteContact(contact.id)
|
||||
chatStore.contacts = chatStore.contacts.filter((c) => c.id !== contact.id)
|
||||
if (contactStore.selectedContact?.id === contact.id) {
|
||||
contactStore.setSelectedContact(null)
|
||||
}
|
||||
if (chatStore.currentTarget?.id === contact.id) {
|
||||
chatStore.setCurrentTarget(null)
|
||||
}
|
||||
@@ -379,3 +395,11 @@ onMounted(async () => {
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
@apply bg-gray-700 rounded-full;
|
||||
}
|
||||
</style>
|
||||
|
||||
446
src/views/contact/ContactDetailCard.vue
Normal file
446
src/views/contact/ContactDetailCard.vue
Normal file
@@ -0,0 +1,446 @@
|
||||
<template>
|
||||
<div class="flex-1 flex flex-col bg-panel h-full">
|
||||
<div v-if="loading" class="flex-1 flex items-center justify-center">
|
||||
<div class="text-gray-400">加载中...</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="contact" class="flex-1 overflow-y-auto">
|
||||
<!-- 用户信息卡片 -->
|
||||
<div class="p-6 text-center border-b border-gray-800">
|
||||
<Avatar
|
||||
:name="contact.user?.name || contact.remark_name"
|
||||
:avatar="contact.user?.avatar"
|
||||
:color="contact.color"
|
||||
size="xl"
|
||||
rounded="full"
|
||||
class="mx-auto border-4 border-panel shadow-xl mb-4"
|
||||
/>
|
||||
<h3 class="text-xl font-bold text-white mb-1">
|
||||
{{ contact.remark_name || contact.user?.name || '未知' }}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-400 mb-2">{{ contact.user?.desc || '暂无签名' }}</p>
|
||||
<div class="flex items-center justify-center gap-2 text-xs text-gray-500">
|
||||
<span v-if="contact.user?.is_online" class="flex items-center gap-1">
|
||||
<span class="w-2 h-2 bg-success rounded-full"></span>
|
||||
在线
|
||||
</span>
|
||||
<span v-else class="flex items-center gap-1">
|
||||
<span class="w-2 h-2 bg-gray-500 rounded-full"></span>
|
||||
离线
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 详细信息 -->
|
||||
<div class="p-4 space-y-3">
|
||||
<div class="flex items-center justify-between p-3 bg-input rounded-lg">
|
||||
<span class="text-sm text-gray-400">账号ID</span>
|
||||
<span class="text-sm text-white">{{ contact.user_id || contact.id }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-input rounded-lg">
|
||||
<span class="text-sm text-gray-400">备注名称</span>
|
||||
<span class="text-sm text-white">{{ contact.remark_name || '未设置' }}</span>
|
||||
</div>
|
||||
<div v-if="contact.user?.phone" class="flex items-center justify-between p-3 bg-input rounded-lg">
|
||||
<span class="text-sm text-gray-400">电话号码</span>
|
||||
<span class="text-sm text-white">{{ contact.user.phone }}</span>
|
||||
</div>
|
||||
<div v-if="contact.user?.email" class="flex items-center justify-between p-3 bg-input rounded-lg">
|
||||
<span class="text-sm text-gray-400">邮箱</span>
|
||||
<span class="text-sm text-white">{{ contact.user.email }}</span>
|
||||
</div>
|
||||
<div v-if="contact.user?.region" class="flex items-center justify-between p-3 bg-input rounded-lg">
|
||||
<span class="text-sm text-gray-400">地区</span>
|
||||
<span class="text-sm text-white">{{ contact.user.region }}</span>
|
||||
</div>
|
||||
<div v-if="currentGroup" class="flex items-center justify-between p-3 bg-input rounded-lg">
|
||||
<span class="text-sm text-gray-400">所属分组</span>
|
||||
<span class="text-sm text-white">{{ currentGroup.group_name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮区域 -->
|
||||
<div class="p-4 space-y-3 border-t border-gray-800 mt-auto">
|
||||
<button
|
||||
class="w-full py-3 bg-primary hover:bg-indigo-500 text-white rounded-lg transition font-medium"
|
||||
@click="handleSendMessage"
|
||||
>
|
||||
<i class="fas fa-comment mr-2"></i>发送消息
|
||||
</button>
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
class="flex-1 py-3 bg-input hover:bg-white/10 text-white rounded-lg transition"
|
||||
@click="handleAudioCall"
|
||||
>
|
||||
<i class="fas fa-phone mr-2"></i>语音通话
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 py-3 bg-input hover:bg-white/10 text-white rounded-lg transition"
|
||||
@click="handleVideoCall"
|
||||
>
|
||||
<i class="fas fa-video mr-2"></i>视频通话
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="w-full py-3 bg-input hover:bg-white/10 text-white rounded-lg transition"
|
||||
@click="showMoreOptions = !showMoreOptions"
|
||||
>
|
||||
<i class="fas fa-ellipsis-h mr-2"></i>更多操作
|
||||
</button>
|
||||
|
||||
<!-- 更多操作菜单 -->
|
||||
<div v-if="showMoreOptions" class="space-y-2 mt-2">
|
||||
<button
|
||||
class="w-full py-2 px-4 bg-input hover:bg-white/10 text-white rounded-lg transition text-sm text-left"
|
||||
@click="showEditRemarkModal = true"
|
||||
>
|
||||
<i class="fas fa-edit mr-2"></i>修改备注
|
||||
</button>
|
||||
<button
|
||||
class="w-full py-2 px-4 bg-input hover:bg-white/10 text-white rounded-lg transition text-sm text-left"
|
||||
@click="showGroupSelectModal = true"
|
||||
>
|
||||
<i class="fas fa-folder mr-2"></i>设置分组
|
||||
</button>
|
||||
<button
|
||||
class="w-full py-2 px-4 bg-input hover:bg-white/10 text-white rounded-lg transition text-sm text-left"
|
||||
@click="handleToggleTop"
|
||||
>
|
||||
<i class="fas fa-thumbtack mr-2"></i>{{ contact.is_top ? '取消置顶' : '置顶聊天' }}
|
||||
</button>
|
||||
<button
|
||||
class="w-full py-2 px-4 bg-input hover:bg-white/10 text-white rounded-lg transition text-sm text-left"
|
||||
@click="handleToggleMuted"
|
||||
>
|
||||
<i class="fas fa-bell-slash mr-2"></i>{{ contact.is_muted ? '取消免打扰' : '免打扰' }}
|
||||
</button>
|
||||
<button
|
||||
class="w-full py-2 px-4 bg-input hover:bg-white/10 text-white rounded-lg transition text-sm text-left"
|
||||
@click="handleToggleSpecialCare"
|
||||
>
|
||||
<i class="fas fa-star mr-2"></i>{{ contact.is_special_care ? '取消特别关心' : '特别关心' }}
|
||||
</button>
|
||||
<button
|
||||
class="w-full py-2 px-4 bg-input hover:bg-red-500/20 text-red-400 rounded-lg transition text-sm text-left"
|
||||
@click="showDeleteConfirm = true"
|
||||
>
|
||||
<i class="fas fa-trash mr-2"></i>删除好友
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 修改备注模态框 -->
|
||||
<div
|
||||
v-if="showEditRemarkModal"
|
||||
class="fixed inset-0 bg-black/80 z-[100] flex items-center justify-center p-4 backdrop-blur-sm"
|
||||
@click.self="showEditRemarkModal = false"
|
||||
>
|
||||
<div class="bg-panel rounded-xl w-96 overflow-hidden shadow-2xl border border-gray-700">
|
||||
<div class="p-4 border-b border-gray-700 font-bold bg-gray-800/50 flex justify-between items-center">
|
||||
<span>修改备注</span>
|
||||
<i
|
||||
class="fas fa-times cursor-pointer hover:text-white text-gray-500"
|
||||
@click="showEditRemarkModal = false"
|
||||
></i>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<input
|
||||
v-model="remarkName"
|
||||
type="text"
|
||||
class="w-full bg-input rounded-lg py-2 px-4 text-white focus:ring-2 ring-primary outline-none transition placeholder-gray-500"
|
||||
placeholder="请输入备注名称"
|
||||
@keyup.enter="handleUpdateRemark"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex border-t border-gray-700">
|
||||
<button
|
||||
class="flex-1 py-3 text-gray-400 hover:bg-gray-700 transition"
|
||||
@click="showEditRemarkModal = false"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 py-3 text-primary font-bold hover:bg-gray-700 transition border-l border-gray-700"
|
||||
@click="handleUpdateRemark"
|
||||
>
|
||||
确认
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分组选择模态框 -->
|
||||
<div
|
||||
v-if="showGroupSelectModal"
|
||||
class="fixed inset-0 bg-black/80 z-[100] flex items-center justify-center p-4 backdrop-blur-sm"
|
||||
@click.self="showGroupSelectModal = false"
|
||||
>
|
||||
<div class="bg-panel rounded-xl w-96 overflow-hidden shadow-2xl border border-gray-700 max-h-[80vh] flex flex-col">
|
||||
<div class="p-4 border-b border-gray-700 font-bold bg-gray-800/50 flex justify-between items-center">
|
||||
<span>选择分组</span>
|
||||
<i
|
||||
class="fas fa-times cursor-pointer hover:text-white text-gray-500"
|
||||
@click="showGroupSelectModal = false"
|
||||
></i>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto p-4">
|
||||
<div
|
||||
v-for="group in groups"
|
||||
:key="group.id"
|
||||
class="p-3 bg-input rounded-lg hover:bg-white/5 transition cursor-pointer mb-2"
|
||||
:class="{ 'bg-primary/20': contact?.group_id === group.id }"
|
||||
@click="handleSelectGroup(group.id)"
|
||||
>
|
||||
{{ group.group_name }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 删除确认模态框 -->
|
||||
<ConfirmModal
|
||||
:show="showDeleteConfirm"
|
||||
title="确认删除"
|
||||
message="确定要删除这个好友吗?删除后将无法接收对方的消息。"
|
||||
type="danger"
|
||||
confirm-text="删除"
|
||||
@confirm="handleDeleteContact"
|
||||
@cancel="showDeleteConfirm = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useChatStore } from '@/stores/chat'
|
||||
import { useContactStore } from '@/stores/contact'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import { useWebRTC } from '@/composables/useWebRTC'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import * as contactApi from '@/api/modules/contact'
|
||||
import Avatar from '@/components/common/Avatar.vue'
|
||||
import ConfirmModal from '@/components/common/ConfirmModal.vue'
|
||||
import type { Contact, ContactGroup } from '@/types/api'
|
||||
|
||||
const emit = defineEmits<{
|
||||
switchToChat: []
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
const chatStore = useChatStore()
|
||||
const contactStore = useContactStore()
|
||||
const toastStore = useToastStore()
|
||||
const authStore = useAuthStore()
|
||||
const webrtc = useWebRTC(authStore.user?.id || '', () => {})
|
||||
|
||||
const contact = ref<Contact | null>(null)
|
||||
const groups = ref<ContactGroup[]>([])
|
||||
const loading = ref(false)
|
||||
const showMoreOptions = ref(false)
|
||||
const showEditRemarkModal = ref(false)
|
||||
const showGroupSelectModal = ref(false)
|
||||
const showDeleteConfirm = ref(false)
|
||||
const remarkName = ref('')
|
||||
|
||||
const currentGroup = computed(() => {
|
||||
if (!contact.value?.group_id) return null
|
||||
return groups.value.find((g) => g.id === contact.value!.group_id) || null
|
||||
})
|
||||
|
||||
async function loadContactDetail() {
|
||||
if (!contactStore.selectedContact) {
|
||||
contact.value = null
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const detail = await contactApi.getContactDetail(contactStore.selectedContact.id)
|
||||
contact.value = detail
|
||||
remarkName.value = detail.remark_name || ''
|
||||
} catch (error: any) {
|
||||
console.error('Failed to load contact detail:', error)
|
||||
toastStore.error(error.message || '加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadGroups() {
|
||||
try {
|
||||
const groupsList = await contactApi.getGroups()
|
||||
groups.value = groupsList
|
||||
} catch (error: any) {
|
||||
console.error('Failed to load groups:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function handleSendMessage() {
|
||||
if (contact.value) {
|
||||
chatStore.setCurrentTarget(contact.value)
|
||||
// 通知父组件切换到 Chat Tab
|
||||
emit('switchToChat')
|
||||
}
|
||||
}
|
||||
|
||||
function handleAudioCall() {
|
||||
if (contact.value) {
|
||||
const receiverUserId = contact.value.user_id || contact.value.id
|
||||
chatStore.setCurrentTarget(contact.value)
|
||||
// 切换到 Chat Tab 后再发起通话
|
||||
emit('switchToChat')
|
||||
// 延迟一下确保 Tab 切换完成
|
||||
setTimeout(() => {
|
||||
webrtc.startCall('audio', receiverUserId)
|
||||
}, 100)
|
||||
}
|
||||
}
|
||||
|
||||
function handleVideoCall() {
|
||||
if (contact.value) {
|
||||
const receiverUserId = contact.value.user_id || contact.value.id
|
||||
chatStore.setCurrentTarget(contact.value)
|
||||
// 切换到 Chat Tab 后再发起通话
|
||||
emit('switchToChat')
|
||||
// 延迟一下确保 Tab 切换完成
|
||||
setTimeout(() => {
|
||||
webrtc.startCall('video', receiverUserId)
|
||||
}, 100)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpdateRemark() {
|
||||
if (!contact.value || !remarkName.value.trim()) {
|
||||
toastStore.warning('请输入备注名称')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await contactApi.updateContact(contact.value.id, { remark_name: remarkName.value })
|
||||
contact.value.remark_name = remarkName.value
|
||||
// 更新联系人列表中的备注
|
||||
const listContact = chatStore.contacts.find((c) => c.id === contact.value!.id)
|
||||
if (listContact) {
|
||||
listContact.remark_name = remarkName.value
|
||||
}
|
||||
if (contactStore.selectedContact) {
|
||||
contactStore.selectedContact.remark_name = remarkName.value
|
||||
}
|
||||
showEditRemarkModal.value = false
|
||||
toastStore.success('备注更新成功')
|
||||
} catch (error: any) {
|
||||
toastStore.error(error.message || '更新失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSelectGroup(groupId: number) {
|
||||
if (!contact.value) return
|
||||
|
||||
try {
|
||||
await contactApi.updateContact(contact.value.id, { group_id: groupId })
|
||||
contact.value.group_id = groupId
|
||||
// 更新联系人列表中的分组
|
||||
const listContact = chatStore.contacts.find((c) => c.id === contact.value!.id)
|
||||
if (listContact) {
|
||||
listContact.group_id = groupId
|
||||
}
|
||||
if (contactStore.selectedContact) {
|
||||
contactStore.selectedContact.group_id = groupId
|
||||
}
|
||||
showGroupSelectModal.value = false
|
||||
toastStore.success('分组设置成功')
|
||||
} catch (error: any) {
|
||||
toastStore.error(error.message || '设置失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleTop() {
|
||||
if (!contact.value) return
|
||||
|
||||
try {
|
||||
await contactApi.updateContact(contact.value.id, { is_top: !contact.value.is_top })
|
||||
contact.value.is_top = !contact.value.is_top
|
||||
// 更新联系人列表
|
||||
const listContact = chatStore.contacts.find((c) => c.id === contact.value!.id)
|
||||
if (listContact) {
|
||||
listContact.is_top = contact.value.is_top
|
||||
}
|
||||
if (contactStore.selectedContact) {
|
||||
contactStore.selectedContact.is_top = contact.value.is_top
|
||||
}
|
||||
toastStore.success(contact.value.is_top ? '已置顶' : '已取消置顶')
|
||||
} catch (error: any) {
|
||||
toastStore.error(error.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleMuted() {
|
||||
if (!contact.value) return
|
||||
|
||||
try {
|
||||
await contactApi.updateContact(contact.value.id, { is_muted: !contact.value.is_muted })
|
||||
contact.value.is_muted = !contact.value.is_muted
|
||||
// 更新联系人列表
|
||||
const listContact = chatStore.contacts.find((c) => c.id === contact.value!.id)
|
||||
if (listContact) {
|
||||
listContact.is_muted = contact.value.is_muted
|
||||
}
|
||||
if (contactStore.selectedContact) {
|
||||
contactStore.selectedContact.is_muted = contact.value.is_muted
|
||||
}
|
||||
toastStore.success(contact.value.is_muted ? '已开启免打扰' : '已关闭免打扰')
|
||||
} catch (error: any) {
|
||||
toastStore.error(error.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleSpecialCare() {
|
||||
if (!contact.value) return
|
||||
|
||||
try {
|
||||
await contactApi.updateContact(contact.value.id, { is_special_care: !contact.value.is_special_care })
|
||||
contact.value.is_special_care = !contact.value.is_special_care
|
||||
// 更新联系人列表
|
||||
const listContact = chatStore.contacts.find((c) => c.id === contact.value!.id)
|
||||
if (listContact) {
|
||||
listContact.is_special_care = contact.value.is_special_care
|
||||
}
|
||||
if (contactStore.selectedContact) {
|
||||
contactStore.selectedContact.is_special_care = contact.value.is_special_care
|
||||
}
|
||||
toastStore.success(contact.value.is_special_care ? '已设为特别关心' : '已取消特别关心')
|
||||
} catch (error: any) {
|
||||
toastStore.error(error.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteContact() {
|
||||
if (!contact.value) return
|
||||
|
||||
try {
|
||||
await contactApi.deleteContact(contact.value.id)
|
||||
chatStore.contacts = chatStore.contacts.filter((c) => c.id !== contact.value!.id)
|
||||
if (contactStore.selectedContact?.id === contact.value.id) {
|
||||
contactStore.setSelectedContact(null)
|
||||
}
|
||||
if (chatStore.currentTarget?.id === contact.value.id) {
|
||||
chatStore.setCurrentTarget(null)
|
||||
}
|
||||
toastStore.success('好友已删除')
|
||||
} catch (error: any) {
|
||||
toastStore.error(error.message || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 监听选中联系人变化
|
||||
watch(() => contactStore.selectedContact, () => {
|
||||
loadContactDetail()
|
||||
}, { immediate: true })
|
||||
|
||||
onMounted(async () => {
|
||||
await loadGroups()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -103,20 +103,40 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { useContactStore } from '@/stores/contact'
|
||||
import { useChatStore } from '@/stores/chat'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import * as contactApi from '@/api/modules/contact'
|
||||
import Avatar from '@/components/common/Avatar.vue'
|
||||
import type { User } from '@/types/api'
|
||||
|
||||
const contactStore = useContactStore()
|
||||
const chatStore = useChatStore()
|
||||
const conversationStore = useConversationStore()
|
||||
const toastStore = useToastStore()
|
||||
|
||||
const searchKeyword = ref('')
|
||||
const searchResults = ref<User[]>([])
|
||||
const friendRequests = ref(contactStore.friendRequests)
|
||||
|
||||
/**
|
||||
* 刷新联系人列表和会话列表
|
||||
*/
|
||||
async function refreshContactsAndConversations() {
|
||||
try {
|
||||
// 刷新联系人列表
|
||||
const contacts = await contactApi.getContacts()
|
||||
chatStore.setContacts(contacts)
|
||||
|
||||
// 刷新会话列表
|
||||
await conversationStore.loadConversations()
|
||||
} catch (error: any) {
|
||||
console.error('Failed to refresh contacts:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSearch() {
|
||||
if (!searchKeyword.value.trim()) return
|
||||
try {
|
||||
@@ -132,33 +152,57 @@ async function handleAddFriend(user: User) {
|
||||
try {
|
||||
await contactApi.addFriend({ to_user_id: user.id, message: '你好,我想加你为好友' })
|
||||
toastStore.success('好友申请已发送')
|
||||
} catch (error: any) { toastStore.error(error.message || '添加失败') }
|
||||
// 从搜索结果中移除已发送申请的用户(可选)
|
||||
// searchResults.value = searchResults.value.filter(u => u.id !== user.id)
|
||||
} catch (error: any) {
|
||||
toastStore.error(error.message || '添加失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAcceptRequest(requestId: number) {
|
||||
try {
|
||||
await contactApi.acceptFriendRequest(requestId)
|
||||
contactStore.removeFriendRequest(requestId)
|
||||
friendRequests.value = contactStore.friendRequests
|
||||
friendRequests.value = contactStore.friendRequests.filter((r) => r.id !== requestId)
|
||||
toastStore.success('已接受好友申请')
|
||||
} catch (error: any) { toastStore.error(error.message || '操作失败') }
|
||||
|
||||
// 刷新联系人列表和会话列表
|
||||
await refreshContactsAndConversations()
|
||||
|
||||
// 可选:提示是否开始聊天
|
||||
const request = contactStore.friendRequests.find((r) => r.id === requestId)
|
||||
if (request) {
|
||||
// TODO: 可以在这里添加一个提示,询问是否开始聊天
|
||||
}
|
||||
} catch (error: any) {
|
||||
toastStore.error(error.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRejectRequest(requestId: number) {
|
||||
try {
|
||||
await contactApi.rejectFriendRequest(requestId)
|
||||
contactStore.removeFriendRequest(requestId)
|
||||
friendRequests.value = contactStore.friendRequests
|
||||
friendRequests.value = contactStore.friendRequests.filter((r) => r.id !== requestId)
|
||||
toastStore.success('已拒绝好友申请')
|
||||
} catch (error: any) { toastStore.error(error.message || '操作失败') }
|
||||
} catch (error: any) {
|
||||
toastStore.error(error.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 监听好友申请列表变化
|
||||
watch(() => contactStore.friendRequests, (newRequests) => {
|
||||
friendRequests.value = newRequests
|
||||
}, { deep: true })
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const requests = await contactApi.getFriendRequests()
|
||||
contactStore.setFriendRequests(requests)
|
||||
friendRequests.value = requests
|
||||
} catch (error) { console.error(error) }
|
||||
} catch (error) {
|
||||
console.error('Failed to load friend requests:', error)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
125
src/views/contact/FriendNotifyView.vue
Normal file
125
src/views/contact/FriendNotifyView.vue
Normal file
@@ -0,0 +1,125 @@
|
||||
<template>
|
||||
<div class="flex-1 flex flex-col bg-panel h-full">
|
||||
<div class="p-4 border-b border-gray-800">
|
||||
<h2 class="text-xl font-bold text-white">好友通知</h2>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-4">
|
||||
<div v-if="friendRequests.length === 0" class="text-center py-20 text-gray-400">
|
||||
<i class="fas fa-user-plus text-6xl mb-4 opacity-50"></i>
|
||||
<p class="text-sm">暂无好友申请</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
v-for="request in friendRequests"
|
||||
:key="request.id"
|
||||
class="flex items-center p-4 bg-input/50 rounded-2xl border border-primary/20 hover:border-primary/50 transition relative overflow-hidden"
|
||||
>
|
||||
<div class="absolute left-0 top-0 bottom-0 w-1 bg-primary"></div>
|
||||
<Avatar
|
||||
:name="request.from_user?.name"
|
||||
:avatar="request.from_user?.avatar"
|
||||
size="md"
|
||||
rounded="xl"
|
||||
class="mr-4"
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-bold text-white">{{ request.from_user?.name }}</div>
|
||||
<div class="text-xs text-gray-400 mt-1 flex items-center gap-1">
|
||||
<i class="fas fa-quote-left text-[10px] opacity-50"></i>
|
||||
{{ request.message || '请求添加你为好友' }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 mt-1">
|
||||
{{ formatTime(new Date(request.created_at).getTime()) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="w-9 h-9 bg-success/20 hover:bg-success text-success hover:text-white rounded-lg transition flex items-center justify-center"
|
||||
@click="handleAcceptRequest(request.id)"
|
||||
title="接受"
|
||||
>
|
||||
<i class="fas fa-check"></i>
|
||||
</button>
|
||||
<button
|
||||
class="w-9 h-9 bg-danger/20 hover:bg-danger text-danger hover:text-white rounded-lg transition flex items-center justify-center"
|
||||
@click="handleRejectRequest(request.id)"
|
||||
title="拒绝"
|
||||
>
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { useContactStore } from '@/stores/contact'
|
||||
import { useChatStore } from '@/stores/chat'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import * as contactApi from '@/api/modules/contact'
|
||||
import Avatar from '@/components/common/Avatar.vue'
|
||||
import { formatTime } from '@/utils/format'
|
||||
import type { FriendRequest } from '@/types/api'
|
||||
|
||||
const contactStore = useContactStore()
|
||||
const chatStore = useChatStore()
|
||||
const toastStore = useToastStore()
|
||||
|
||||
const friendRequests = ref<FriendRequest[]>([])
|
||||
|
||||
async function loadFriendRequests() {
|
||||
try {
|
||||
const requests = await contactApi.getFriendRequests()
|
||||
contactStore.setFriendRequests(requests)
|
||||
friendRequests.value = requests
|
||||
} catch (error: any) {
|
||||
console.error('Failed to load friend requests:', error)
|
||||
toastStore.error(error.message || '加载失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAcceptRequest(requestId: number) {
|
||||
try {
|
||||
await contactApi.acceptFriendRequest(requestId)
|
||||
contactStore.removeFriendRequest(requestId)
|
||||
friendRequests.value = friendRequests.value.filter((r) => r.id !== requestId)
|
||||
toastStore.success('已接受好友申请')
|
||||
// 刷新联系人列表
|
||||
const contacts = await contactApi.getContacts()
|
||||
chatStore.setContacts(contacts)
|
||||
// 提示是否开始聊天
|
||||
const request = friendRequests.value.find((r) => r.id === requestId)
|
||||
if (request) {
|
||||
// TODO: 可以在这里添加一个提示,询问是否开始聊天
|
||||
}
|
||||
} catch (error: any) {
|
||||
toastStore.error(error.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRejectRequest(requestId: number) {
|
||||
try {
|
||||
await contactApi.rejectFriendRequest(requestId)
|
||||
contactStore.removeFriendRequest(requestId)
|
||||
friendRequests.value = friendRequests.value.filter((r) => r.id !== requestId)
|
||||
toastStore.success('已拒绝好友申请')
|
||||
} catch (error: any) {
|
||||
toastStore.error(error.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 监听好友申请列表变化
|
||||
watch(() => contactStore.friendRequests, (newRequests) => {
|
||||
friendRequests.value = newRequests
|
||||
}, { deep: true })
|
||||
|
||||
onMounted(async () => {
|
||||
await loadFriendRequests()
|
||||
})
|
||||
</script>
|
||||
|
||||
113
src/views/contact/README.md
Normal file
113
src/views/contact/README.md
Normal file
@@ -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. 注意事项
|
||||
|
||||
- 所有联系人操作(添加、删除、更新)都需要刷新联系人列表
|
||||
- 接受好友申请后需要同时刷新联系人列表和会话列表
|
||||
- 联系人状态(置顶、免打扰、特别关心、拉黑)会同步到联系人列表和资料卡
|
||||
- 搜索框只做本地过滤,不调用后端接口
|
||||
|
||||
Reference in New Issue
Block a user