联系人UI升级
This commit is contained in:
@@ -1,234 +1,269 @@
|
||||
<template>
|
||||
<div class="flex-1 flex flex-col bg-panel h-full">
|
||||
<div class="flex-1 flex flex-col bg-panel h-full select-none" @contextmenu.prevent="handleBlankContextMenu">
|
||||
<!-- 顶部搜索框 -->
|
||||
<div class="p-3 border-b border-gray-800">
|
||||
<div class="p-4 border-b border-gray-800/50">
|
||||
<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>
|
||||
<i class="fas fa-search absolute left-3 top-1/2 -translate-y-1/2 text-gray-500 group-focus-within:text-primary transition-colors 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="搜索好友或群聊..."
|
||||
v-model="searchKeyword"
|
||||
type="text"
|
||||
class="w-full bg-black/20 hover:bg-black/30 focus:bg-black/40 border border-transparent focus:border-primary/30 rounded-xl py-2.5 pl-9 pr-3 text-sm text-white focus:ring-0 outline-none transition-all placeholder-gray-500"
|
||||
placeholder="搜索好友、群组..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 入口按钮行:好友管理器、好友通知、群通知 -->
|
||||
<div class="px-3 py-2 border-b border-gray-800 flex gap-2">
|
||||
<!-- 入口按钮行 -->
|
||||
<div class="px-2 py-3 border-b border-gray-800/50 flex gap-1">
|
||||
<button
|
||||
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')"
|
||||
v-for="mode in navModes"
|
||||
:key="mode.key"
|
||||
class="flex-1 flex flex-col items-center gap-1.5 py-2 rounded-xl transition-all duration-200 group relative"
|
||||
:class="contactStore.leftPanelMode === mode.key ? 'bg-primary/10 text-primary' : 'text-gray-400 hover:bg-white/5 hover:text-gray-200'"
|
||||
@click="contactStore.setLeftPanelMode(mode.key)"
|
||||
>
|
||||
<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>
|
||||
|
||||
<!-- 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-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 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 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"
|
||||
<div class="relative">
|
||||
<i :class="[mode.icon, 'text-lg transition-transform group-active:scale-90']"></i>
|
||||
<span
|
||||
v-if="mode.badge > 0"
|
||||
class="absolute -top-1.5 -right-2 bg-red-500 text-white text-[10px] font-bold min-w-[16px] h-4 flex items-center justify-center rounded-full px-0.5 border-2 border-panel shadow-sm"
|
||||
>
|
||||
<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>
|
||||
{{ mode.badge > 99 ? '99+' : mode.badge }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-[11px] font-medium">{{ mode.label }}</span>
|
||||
</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>
|
||||
<!-- Tab 区域 (QQ PC 风格单选按钮布局) -->
|
||||
<div class="px-4 py-3 border-b border-gray-800/50">
|
||||
<div class="flex bg-black/20 p-1 rounded-lg">
|
||||
<button
|
||||
v-for="tab in ['friends', 'groups']"
|
||||
:key="tab"
|
||||
class="flex-1 py-1.5 text-xs font-medium rounded-md transition-all duration-200 relative z-10"
|
||||
:class="contactStore.contactListTab === tab ? 'bg-white/10 text-white shadow-sm ring-1 ring-white/5' : 'text-gray-400 hover:text-gray-200'"
|
||||
@click="contactStore.contactListTab = tab as 'friends' | 'groups'"
|
||||
>
|
||||
{{ tab === 'friends' ? '好友' : '群聊' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右键菜单 -->
|
||||
<!-- 列表区域 -->
|
||||
<div class="flex-1 overflow-y-auto custom-scrollbar p-2 space-y-2">
|
||||
<!-- 好友列表 -->
|
||||
<template v-if="contactStore.contactListTab === 'friends'">
|
||||
<div v-if="filteredContacts.length === 0 && !searchKeyword" class="text-center py-12 text-gray-500/50">
|
||||
<i class="fas fa-wind text-4xl mb-3"></i>
|
||||
<p class="text-xs">暂无联系人</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="filteredContacts.length === 0 && searchKeyword" class="text-center py-12 text-gray-500">
|
||||
<p class="text-xs">未找到匹配的好友</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- 默认分组/未分组 -->
|
||||
<div v-if="groupedContacts[0]?.length">
|
||||
<div
|
||||
class="flex items-center gap-2 px-3 py-2 text-xs font-bold text-gray-500 hover:text-gray-300 cursor-pointer transition-colors select-none"
|
||||
@click="toggleGroupCollapse(0)"
|
||||
@contextmenu.stop="showGroupMenu($event, { id: 0, group_name: '未分组' } as any)"
|
||||
>
|
||||
<i class="fas fa-chevron-right transition-transform duration-200" :class="{ 'rotate-90': !collapsedGroupIds.includes(0) }"></i>
|
||||
<span>未分组 ({{ groupedContacts[0].length }})</span>
|
||||
</div>
|
||||
|
||||
<TransitionGroup
|
||||
name="list"
|
||||
tag="div"
|
||||
class="pl-2 space-y-1 overflow-hidden"
|
||||
v-show="!collapsedGroupIds.includes(0)"
|
||||
>
|
||||
<div
|
||||
v-for="contact in groupedContacts[0]"
|
||||
:key="contact.id"
|
||||
class="w-full cursor-pointer"
|
||||
@click="handleContactClick(contact)"
|
||||
@contextmenu.stop="showContactMenu($event, contact)"
|
||||
>
|
||||
<!-- 内联 ContactItem -->
|
||||
<div class="flex items-center p-2.5 rounded-xl transition-all duration-200 select-none"
|
||||
:class="contactStore.selectedContact?.id === contact.id ? 'bg-primary text-white shadow-lg shadow-primary/20' : 'hover:bg-white/5 text-gray-300'"
|
||||
>
|
||||
<div class="relative shrink-0 mr-3">
|
||||
<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.5 -right-0.5 w-3 h-3 bg-emerald-500 rounded-full border-2 border-[#1e293b]"></div>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex justify-between items-center mb-0.5">
|
||||
<span class="font-bold text-sm truncate" :class="contactStore.selectedContact?.id === contact.id ? 'text-white' : 'text-gray-200'">
|
||||
{{ contact.remark_name || contact.user?.name || '未知用户' }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs truncate opacity-70" :class="contactStore.selectedContact?.id === contact.id ? 'text-white/80' : 'text-gray-500'">
|
||||
{{ contact.user?.desc || '暂无签名' }}
|
||||
</p>
|
||||
</div>
|
||||
<!-- 图标状态 -->
|
||||
<div class="flex flex-col gap-1 items-end ml-2">
|
||||
<span v-if="contact.unread && contact.unread > 0" class="bg-red-500 text-white text-[10px] px-1.5 rounded-full min-w-[18px] text-center font-bold shadow-sm mb-1">
|
||||
{{ contact.unread }}
|
||||
</span>
|
||||
<div class="flex gap-1 text-[10px] opacity-70" :class="contactStore.selectedContact?.id === contact.id ? 'text-white' : 'text-gray-500'">
|
||||
<i v-if="contact.is_top" class="fas fa-thumbtack"></i>
|
||||
<i v-if="contact.is_muted" class="fas fa-bell-slash"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 内联结束 -->
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
|
||||
<!-- 自定义分组 -->
|
||||
<div v-for="group in groups" :key="group.id" class="mb-1">
|
||||
<div
|
||||
class="flex items-center gap-2 px-3 py-2 text-xs font-bold text-gray-500 hover:text-gray-300 cursor-pointer transition-colors select-none group-header"
|
||||
@click="toggleGroupCollapse(group.id)"
|
||||
@contextmenu.stop="showGroupMenu($event, group)"
|
||||
>
|
||||
<i class="fas fa-chevron-right transition-transform duration-200" :class="{ 'rotate-90': !collapsedGroupIds.includes(group.id) }"></i>
|
||||
<span class="truncate max-w-[180px]">{{ group.group_name }}</span>
|
||||
<span class="text-gray-600 ml-auto text-[10px]">{{ (groupedContacts[group.id] || []).length }}</span>
|
||||
</div>
|
||||
|
||||
<TransitionGroup
|
||||
name="list"
|
||||
tag="div"
|
||||
class="pl-2 space-y-1 overflow-hidden"
|
||||
v-show="!collapsedGroupIds.includes(group.id)"
|
||||
>
|
||||
<div
|
||||
v-for="contact in (groupedContacts[group.id] || [])"
|
||||
:key="contact.id"
|
||||
class="w-full cursor-pointer"
|
||||
@click="handleContactClick(contact)"
|
||||
@contextmenu.stop="showContactMenu($event, contact)"
|
||||
>
|
||||
<!-- 内联 ContactItem -->
|
||||
<div class="flex items-center p-2.5 rounded-xl transition-all duration-200 select-none"
|
||||
:class="contactStore.selectedContact?.id === contact.id ? 'bg-primary text-white shadow-lg shadow-primary/20' : 'hover:bg-white/5 text-gray-300'"
|
||||
>
|
||||
<div class="relative shrink-0 mr-3">
|
||||
<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.5 -right-0.5 w-3 h-3 bg-emerald-500 rounded-full border-2 border-[#1e293b]"></div>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex justify-between items-center mb-0.5">
|
||||
<span class="font-bold text-sm truncate" :class="contactStore.selectedContact?.id === contact.id ? 'text-white' : 'text-gray-200'">
|
||||
{{ contact.remark_name || contact.user?.name || '未知用户' }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs truncate opacity-70" :class="contactStore.selectedContact?.id === contact.id ? 'text-white/80' : 'text-gray-500'">
|
||||
{{ contact.user?.desc || '暂无签名' }}
|
||||
</p>
|
||||
</div>
|
||||
<!-- 图标状态 -->
|
||||
<div class="flex flex-col gap-1 items-end ml-2">
|
||||
<span v-if="contact.unread && contact.unread > 0" class="bg-red-500 text-white text-[10px] px-1.5 rounded-full min-w-[18px] text-center font-bold shadow-sm mb-1">
|
||||
{{ contact.unread }}
|
||||
</span>
|
||||
<div class="flex gap-1 text-[10px] opacity-70" :class="contactStore.selectedContact?.id === contact.id ? 'text-white' : 'text-gray-500'">
|
||||
<i v-if="contact.is_top" class="fas fa-thumbtack"></i>
|
||||
<i v-if="contact.is_muted" class="fas fa-bell-slash"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 内联结束 -->
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- 群聊列表占位 -->
|
||||
<div v-else class="flex flex-col items-center justify-center h-full text-gray-500/50">
|
||||
<i class="fas fa-users text-4xl mb-4"></i>
|
||||
<p class="text-xs">群聊功能开发中...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右键菜单组件 -->
|
||||
<ContextMenu />
|
||||
|
||||
<!-- 弹窗:分组管理 (新增/重命名) -->
|
||||
<div v-if="showGroupModal" class="fixed inset-0 bg-black/60 backdrop-blur-sm z-[100] flex items-center justify-center p-4" @click.self="closeGroupModal">
|
||||
<div class="bg-[#1e293b] border border-gray-700 rounded-xl w-80 shadow-2xl animate-fade-in-up">
|
||||
<div class="p-4 border-b border-gray-700/50 font-bold text-white flex justify-between">
|
||||
<span>{{ groupModalType === 'create' ? '新建分组' : '重命名分组' }}</span>
|
||||
<i class="fas fa-times cursor-pointer hover:text-primary transition" @click="closeGroupModal"></i>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<input
|
||||
v-model="groupModalName"
|
||||
type="text"
|
||||
ref="groupInputRef"
|
||||
class="w-full bg-black/30 text-white px-4 py-2 rounded-lg border border-gray-600 focus:border-primary focus:ring-1 focus:ring-primary outline-none transition"
|
||||
placeholder="请输入分组名称"
|
||||
@keyup.enter="handleGroupModalSubmit"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex border-t border-gray-700/50">
|
||||
<button class="flex-1 py-3 text-gray-400 hover:bg-white/5 transition rounded-bl-xl" @click="closeGroupModal">取消</button>
|
||||
<button class="flex-1 py-3 text-primary font-bold hover:bg-primary/10 transition rounded-br-xl" @click="handleGroupModalSubmit">确定</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 弹窗:修改备注 -->
|
||||
<div v-if="showRemarkModal" class="fixed inset-0 bg-black/60 backdrop-blur-sm z-[100] flex items-center justify-center p-4" @click.self="showRemarkModal = false">
|
||||
<div class="bg-[#1e293b] border border-gray-700 rounded-xl w-80 shadow-2xl animate-fade-in-up">
|
||||
<div class="p-4 border-b border-gray-700/50 font-bold text-white">修改备注</div>
|
||||
<div class="p-5">
|
||||
<input v-model="tempRemark" type="text" class="w-full bg-black/30 text-white px-4 py-2 rounded-lg border border-gray-600 focus:border-primary outline-none" placeholder="输入新备注" @keyup.enter="submitRemark" />
|
||||
</div>
|
||||
<div class="flex border-t border-gray-700/50">
|
||||
<button class="flex-1 py-3 text-gray-400 hover:bg-white/5" @click="showRemarkModal = false">取消</button>
|
||||
<button class="flex-1 py-3 text-primary font-bold hover:bg-primary/10" @click="submitRemark">确定</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 弹窗:选择分组 (移动好友) -->
|
||||
<div v-if="showMoveGroupModal" class="fixed inset-0 bg-black/60 backdrop-blur-sm z-[100] flex items-center justify-center p-4" @click.self="showMoveGroupModal = false">
|
||||
<div class="bg-[#1e293b] border border-gray-700 rounded-xl w-80 shadow-2xl animate-fade-in-up flex flex-col max-h-[400px]">
|
||||
<div class="p-4 border-b border-gray-700/50 font-bold text-white">选择分组</div>
|
||||
<div class="flex-1 overflow-y-auto p-2 custom-scrollbar">
|
||||
<div
|
||||
v-for="g in [{id: 0, group_name: '未分组'}, ...groups]"
|
||||
:key="g.id"
|
||||
class="p-3 rounded-lg hover:bg-white/5 cursor-pointer text-sm text-gray-300 flex justify-between items-center transition"
|
||||
:class="{'text-primary bg-primary/10': targetContact?.group_id === g.id}"
|
||||
@click="submitMoveGroup(g.id)"
|
||||
>
|
||||
<span>{{ g.group_name }}</span>
|
||||
<i v-if="targetContact?.group_id === g.id" class="fas fa-check"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmModal
|
||||
:show="showDeleteGroupConfirm"
|
||||
title="删除分组"
|
||||
message="确定要删除该分组吗?组内好友将移动至'未分组'。"
|
||||
type="danger"
|
||||
@confirm="handleDeleteGroup"
|
||||
@cancel="showDeleteGroupConfirm = false"
|
||||
/>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useContactStore } from '@/stores/contact'
|
||||
import { useChatStore } from '@/stores/chat'
|
||||
@@ -237,6 +272,7 @@ 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 type { Contact, ContactGroup } from '@/types/api'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -245,161 +281,305 @@ const chatStore = useChatStore()
|
||||
const toastStore = useToastStore()
|
||||
const { showContextMenu } = useContextMenu()
|
||||
|
||||
// 状态
|
||||
const searchKeyword = ref('')
|
||||
const groups = ref<ContactGroup[]>([])
|
||||
const collapsedGroupIds = ref<number[]>([])
|
||||
|
||||
// 过滤后的联系人(根据搜索关键字)
|
||||
// 模态框状态
|
||||
const showGroupModal = ref(false)
|
||||
const groupModalType = ref<'create' | 'edit'>('create')
|
||||
const groupModalName = ref('')
|
||||
const currentGroupId = ref<number | null>(null)
|
||||
const groupInputRef = ref<HTMLInputElement | null>(null)
|
||||
const showDeleteGroupConfirm = ref(false)
|
||||
|
||||
const showRemarkModal = ref(false)
|
||||
const tempRemark = ref('')
|
||||
const showMoveGroupModal = ref(false)
|
||||
const targetContact = ref<Contact | null>(null)
|
||||
|
||||
// 导航配置
|
||||
const navModes = computed(() => [
|
||||
{ key: 'friend-manager', label: '联系人', icon: 'fas fa-user-friends', badge: 0 },
|
||||
{ key: 'friend-notify', label: '新朋友', icon: 'fas fa-user-plus', badge: contactStore.friendRequests.length },
|
||||
{ key: 'group-notify', label: '群通知', icon: 'fas fa-bell', badge: 0 }
|
||||
])
|
||||
|
||||
// 核心数据计算
|
||||
const filteredContacts = computed(() => {
|
||||
const contacts = chatStore.contacts
|
||||
const contacts = chatStore.contacts || []
|
||||
const keyword = searchKeyword.value.toLowerCase().trim()
|
||||
if (!keyword) return contacts
|
||||
|
||||
return contacts.filter((c) => {
|
||||
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)
|
||||
return name.includes(keyword) || c.user_id?.toString().includes(keyword)
|
||||
})
|
||||
})
|
||||
|
||||
// 分组 -> 联系人列表
|
||||
const groupedContacts = computed<Record<number, Contact[]>>(() => {
|
||||
const map: Record<number, Contact[]> = {}
|
||||
for (const c of filteredContacts.value) {
|
||||
const groupedContacts = computed(() => {
|
||||
const map: Record<number, Contact[]> = { 0: [] } // Init default group
|
||||
groups.value.forEach(g => map[g.id] = [])
|
||||
|
||||
filteredContacts.value.forEach(c => {
|
||||
const gid = c.group_id || 0
|
||||
if (!map[gid]) map[gid] = []
|
||||
if (!map[gid]) map[gid] = [] // Fallback if group deleted
|
||||
map[gid].push(c)
|
||||
}
|
||||
})
|
||||
return map
|
||||
})
|
||||
|
||||
// --- API & Actions ---
|
||||
|
||||
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 || '加载分组失败')
|
||||
const res = await contactApi.getGroups()
|
||||
groups.value = res
|
||||
contactStore.setGroups(res)
|
||||
} catch (e) {
|
||||
console.error('Load groups failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
function toggleGroupCollapse(groupId: number) {
|
||||
const idx = collapsedGroupIds.value.indexOf(groupId)
|
||||
if (idx === -1) {
|
||||
collapsedGroupIds.value.push(groupId)
|
||||
} else {
|
||||
collapsedGroupIds.value.splice(idx, 1)
|
||||
}
|
||||
function toggleGroupCollapse(gid: number) {
|
||||
const idx = collapsedGroupIds.value.indexOf(gid)
|
||||
idx === -1 ? collapsedGroupIds.value.push(gid) : collapsedGroupIds.value.splice(idx, 1)
|
||||
}
|
||||
|
||||
function handleContactClick(contact: Contact) {
|
||||
console.log('点击联系人:', contact)
|
||||
contactStore.setSelectedContact(contact)
|
||||
// 保持路由同步,防止刷新丢失状态
|
||||
// router.push(`/contacts/detail/${contact.id}`).catch(() => {})
|
||||
}
|
||||
|
||||
function showContactMenu(event: MouseEvent, contact: Contact) {
|
||||
showContextMenu(event, [
|
||||
// --- Context Menus ---
|
||||
|
||||
function handleBlankContextMenu(e: MouseEvent) {
|
||||
showContextMenu(e, [
|
||||
{
|
||||
label: '查看资料',
|
||||
icon: 'fas fa-user',
|
||||
label: '新建分组',
|
||||
icon: 'fas fa-folder-plus',
|
||||
action: openCreateGroupModal
|
||||
},
|
||||
{
|
||||
label: '刷新列表',
|
||||
icon: 'fas fa-sync-alt',
|
||||
action: () => {
|
||||
contactStore.setSelectedContact(contact)
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '修改备注',
|
||||
icon: 'fas fa-edit',
|
||||
action: () => {
|
||||
// TODO: 实现修改备注
|
||||
toastStore.info('修改备注功能开发中')
|
||||
},
|
||||
},
|
||||
{
|
||||
label: contact.is_top ? '取消置顶' : '置顶',
|
||||
icon: 'fas fa-thumbtack',
|
||||
action: async () => {
|
||||
try {
|
||||
await contactApi.updateContact(contact.id, { is_top: !contact.is_top })
|
||||
contact.is_top = !contact.is_top
|
||||
toastStore.success(contact.is_top ? '已置顶' : '已取消置顶')
|
||||
} catch (error: any) {
|
||||
toastStore.error(error.message || '操作失败')
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: contact.is_muted ? '取消免打扰' : '免打扰',
|
||||
icon: 'fas fa-bell-slash',
|
||||
action: async () => {
|
||||
try {
|
||||
await contactApi.updateContact(contact.id, { is_muted: !contact.is_muted })
|
||||
contact.is_muted = !contact.is_muted
|
||||
toastStore.success(contact.is_muted ? '已开启免打扰' : '已关闭免打扰')
|
||||
} catch (error: any) {
|
||||
toastStore.error(error.message || '操作失败')
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
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',
|
||||
danger: true,
|
||||
action: async () => {
|
||||
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)
|
||||
}
|
||||
toastStore.success('好友已删除')
|
||||
} catch (error: any) {
|
||||
toastStore.error(error.message || '删除失败')
|
||||
}
|
||||
},
|
||||
},
|
||||
loadGroups()
|
||||
contactApi.getContacts().then(res => chatStore.setContacts(res))
|
||||
toastStore.success('列表已刷新')
|
||||
}
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadGroups()
|
||||
function showGroupMenu(e: MouseEvent, group: ContactGroup) {
|
||||
const isDefault = group.id === 0
|
||||
const menuItems = [
|
||||
{
|
||||
label: '新建分组',
|
||||
icon: 'fas fa-folder-plus',
|
||||
action: openCreateGroupModal
|
||||
}
|
||||
]
|
||||
|
||||
if (!isDefault) {
|
||||
menuItems.push(
|
||||
{
|
||||
label: '重命名分组',
|
||||
icon: 'fas fa-edit',
|
||||
action: () => openEditGroupModal(group)
|
||||
},
|
||||
{
|
||||
label: '删除分组',
|
||||
icon: 'fas fa-trash',
|
||||
danger: true,
|
||||
action: () => {
|
||||
currentGroupId.value = group.id
|
||||
showDeleteGroupConfirm.value = true
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
showContextMenu(e, menuItems)
|
||||
}
|
||||
|
||||
function showContactMenu(e: MouseEvent, contact: Contact) {
|
||||
targetContact.value = contact
|
||||
showContextMenu(e, [
|
||||
{
|
||||
label: '发送消息',
|
||||
icon: 'fas fa-comment-dots',
|
||||
action: () => {
|
||||
chatStore.setCurrentTarget(contact)
|
||||
router.push('/chat')
|
||||
}
|
||||
},
|
||||
{
|
||||
label: '查看资料',
|
||||
icon: 'fas fa-address-card',
|
||||
action: () => contactStore.setSelectedContact(contact)
|
||||
},
|
||||
{ type: 'divider' } as any,
|
||||
{
|
||||
label: '修改备注',
|
||||
icon: 'fas fa-pen',
|
||||
action: () => {
|
||||
tempRemark.value = contact.remark_name || ''
|
||||
showRemarkModal.value = true
|
||||
}
|
||||
},
|
||||
{
|
||||
label: '移动至分组',
|
||||
icon: 'fas fa-folder',
|
||||
action: () => {
|
||||
showMoveGroupModal.value = true
|
||||
}
|
||||
},
|
||||
{ type: 'divider' } as any,
|
||||
{
|
||||
label: contact.is_top ? '取消置顶' : '置顶会话',
|
||||
icon: 'fas fa-thumbtack',
|
||||
action: async () => {
|
||||
await contactApi.updateContact(contact.id, { is_top: !contact.is_top })
|
||||
contact.is_top = !contact.is_top
|
||||
toastStore.success(contact.is_top ? '已置顶' : '已取消置顶')
|
||||
}
|
||||
},
|
||||
{
|
||||
label: contact.is_muted ? '开启提醒' : '消息免打扰',
|
||||
icon: contact.is_muted ? 'fas fa-bell' : 'fas fa-bell-slash',
|
||||
action: async () => {
|
||||
await contactApi.updateContact(contact.id, { is_muted: !contact.is_muted })
|
||||
contact.is_muted = !contact.is_muted
|
||||
toastStore.success(contact.is_muted ? '已开启免打扰' : '已关闭免打扰')
|
||||
}
|
||||
},
|
||||
{ type: 'divider' } as any,
|
||||
{
|
||||
label: '删除好友',
|
||||
icon: 'fas fa-trash-alt',
|
||||
danger: true,
|
||||
action: async () => {
|
||||
try {
|
||||
if(confirm(`确定删除好友 "${contact.remark_name || contact.user?.name}" 吗?`)) {
|
||||
await contactApi.deleteContact(contact.id)
|
||||
chatStore.contacts = chatStore.contacts.filter(c => c.id !== contact.id)
|
||||
if(contactStore.selectedContact?.id === contact.id) contactStore.setSelectedContact(null)
|
||||
toastStore.success('已删除')
|
||||
}
|
||||
} catch(e: any) {
|
||||
toastStore.error(e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
// --- 分组模态框逻辑 ---
|
||||
|
||||
function openCreateGroupModal() {
|
||||
groupModalType.value = 'create'
|
||||
groupModalName.value = ''
|
||||
showGroupModal.value = true
|
||||
nextTick(() => groupInputRef.value?.focus())
|
||||
}
|
||||
|
||||
function openEditGroupModal(group: ContactGroup) {
|
||||
groupModalType.value = 'edit'
|
||||
groupModalName.value = group.group_name
|
||||
currentGroupId.value = group.id
|
||||
showGroupModal.value = true
|
||||
nextTick(() => groupInputRef.value?.focus())
|
||||
}
|
||||
|
||||
function closeGroupModal() {
|
||||
showGroupModal.value = false
|
||||
currentGroupId.value = null
|
||||
}
|
||||
|
||||
async function handleGroupModalSubmit() {
|
||||
const name = groupModalName.value.trim()
|
||||
if (!name) return toastStore.warning('请输入分组名称')
|
||||
|
||||
try {
|
||||
if (groupModalType.value === 'create') {
|
||||
const newGroup = await contactApi.createGroup({ group_name: name })
|
||||
groups.value.push(newGroup)
|
||||
toastStore.success('分组创建成功')
|
||||
} else {
|
||||
if (currentGroupId.value) {
|
||||
await contactApi.updateGroup(currentGroupId.value, { group_name: name })
|
||||
const g = groups.value.find(x => x.id === currentGroupId.value)
|
||||
if (g) g.group_name = name
|
||||
toastStore.success('分组重命名成功')
|
||||
}
|
||||
}
|
||||
closeGroupModal()
|
||||
} catch (error: any) {
|
||||
toastStore.error(error.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteGroup() {
|
||||
if (!currentGroupId.value) return
|
||||
try {
|
||||
await contactApi.deleteGroup(currentGroupId.value)
|
||||
chatStore.contacts.forEach(c => {
|
||||
if (c.group_id === currentGroupId.value) c.group_id = 0
|
||||
})
|
||||
groups.value = groups.value.filter(g => g.id !== currentGroupId.value)
|
||||
toastStore.success('分组已删除')
|
||||
showDeleteGroupConfirm.value = false
|
||||
} catch (error: any) {
|
||||
toastStore.error(error.message || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
// --- 辅助功能 ---
|
||||
|
||||
async function submitRemark() {
|
||||
if (!targetContact.value) return
|
||||
try {
|
||||
await contactApi.updateContact(targetContact.value.id, { remark_name: tempRemark.value })
|
||||
targetContact.value.remark_name = tempRemark.value
|
||||
toastStore.success('备注已更新')
|
||||
showRemarkModal.value = false
|
||||
} catch (e: any) {
|
||||
toastStore.error('更新失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function submitMoveGroup(groupId: number) {
|
||||
if (!targetContact.value) return
|
||||
try {
|
||||
await contactApi.updateContact(targetContact.value.id, { group_id: groupId })
|
||||
targetContact.value.group_id = groupId
|
||||
toastStore.success('已移动至新分组')
|
||||
showMoveGroupModal.value = false
|
||||
} catch (e: any) {
|
||||
toastStore.error('移动失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadGroups()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
@apply bg-gray-700 rounded-full;
|
||||
.custom-scrollbar::-webkit-scrollbar { width: 4px; }
|
||||
.custom-scrollbar::-webkit-scrollbar-track { background: transparent; }
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb { @apply bg-gray-700/50 rounded-full hover:bg-gray-600; }
|
||||
|
||||
.list-enter-active, .list-leave-active { transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); }
|
||||
.list-enter-from, .list-leave-to { opacity: 0; transform: translateY(-10px); height: 0; }
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from { opacity: 0; transform: scale(0.95) translateY(10px); }
|
||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
.animate-fade-in-up { animation: fadeInUp 0.2s ease-out forwards; }
|
||||
</style>
|
||||
|
||||
@@ -1,491 +1,411 @@
|
||||
<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 class="flex-1 flex flex-col bg-[#111827] h-full relative overflow-hidden">
|
||||
<!-- 背景装饰 -->
|
||||
<div class="absolute top-0 left-0 right-0 h-48 bg-gradient-to-b from-primary/20 to-transparent pointer-events-none"></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">
|
||||
<!-- 右上角:更多设置 -->
|
||||
<div class="absolute top-5 right-5 z-50">
|
||||
<div class="relative">
|
||||
<button
|
||||
class="w-full py-3 bg-primary hover:bg-indigo-500 text-white rounded-lg transition font-medium"
|
||||
@click="handleSendMessage"
|
||||
class="w-9 h-9 rounded-full bg-black/20 hover:bg-white/10 text-gray-300 hover:text-white flex items-center justify-center transition backdrop-blur-md border border-white/5"
|
||||
@click.stop="showMoreOptions = !showMoreOptions"
|
||||
>
|
||||
<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>更多操作
|
||||
<i class="fas fa-ellipsis-h"></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>
|
||||
<!-- 更多菜单 Popover -->
|
||||
<transition name="popover">
|
||||
<div v-if="showMoreOptions" class="absolute top-full right-0 mt-2 w-48 bg-[#1e293b] border border-gray-700 rounded-xl shadow-2xl p-1.5 z-50 flex flex-col gap-0.5 origin-top-right">
|
||||
<button class="w-full text-left px-3 py-2.5 rounded-lg flex items-center gap-3 text-sm transition-colors text-gray-300 hover:bg-white/10 hover:text-white" @click="openEditRemark">
|
||||
<i class="fas fa-pen w-5 text-center opacity-70"></i><span>修改备注</span>
|
||||
</button>
|
||||
<button class="w-full text-left px-3 py-2.5 rounded-lg flex items-center gap-3 text-sm transition-colors text-gray-300 hover:bg-white/10 hover:text-white" @click="openGroupSelect">
|
||||
<i class="fas fa-folder-open w-5 text-center opacity-70"></i><span>移动分组</span>
|
||||
</button>
|
||||
<div class="h-px bg-gray-700 my-1 mx-2"></div>
|
||||
<button class="w-full text-left px-3 py-2.5 rounded-lg flex items-center gap-3 text-sm transition-colors text-gray-300 hover:bg-white/10 hover:text-white" @click="handleToggleTop">
|
||||
<i class="fas fa-thumbtack w-5 text-center opacity-70" :class="contact?.is_top ? 'text-primary' : ''"></i>
|
||||
<span>{{ contact?.is_top ? '取消置顶' : '置顶聊天' }}</span>
|
||||
</button>
|
||||
<button class="w-full text-left px-3 py-2.5 rounded-lg flex items-center gap-3 text-sm transition-colors text-gray-300 hover:bg-white/10 hover:text-white" @click="handleToggleMuted">
|
||||
<i class="fas fa-bell w-5 text-center opacity-70" :class="contact?.is_muted ? 'text-yellow-500' : ''"></i>
|
||||
<span>{{ contact?.is_muted ? '开启提醒' : '消息免打扰' }}</span>
|
||||
</button>
|
||||
<button class="w-full text-left px-3 py-2.5 rounded-lg flex items-center gap-3 text-sm transition-colors text-gray-300 hover:bg-white/10 hover:text-white" @click="handleToggleSpecialCare">
|
||||
<i class="far fa-star w-5 text-center opacity-70" :class="contact?.is_special_care ? 'text-yellow-400 fas' : ''"></i>
|
||||
<span>{{ contact?.is_special_care ? '取消特别关心' : '特别关心' }}</span>
|
||||
</button>
|
||||
<div class="h-px bg-gray-700 my-1 mx-2"></div>
|
||||
<button class="w-full text-left px-3 py-2.5 rounded-lg flex items-center gap-3 text-sm transition-colors text-red-400 hover:bg-red-500/10" @click="showDeleteConfirm = true">
|
||||
<i class="fas fa-trash-alt w-5 text-center opacity-70"></i><span>删除好友</span>
|
||||
</button>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<!-- 遮罩 -->
|
||||
<div v-if="showMoreOptions" class="fixed inset-0 z-40" @click="showMoreOptions = false"></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 v-if="loading && !contact" class="flex-1 flex flex-col items-center justify-center text-gray-400 gap-3">
|
||||
<i class="fas fa-circle-notch fa-spin text-2xl text-primary"></i>
|
||||
<span class="text-sm">读取资料中...</span>
|
||||
</div>
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<div v-else-if="contact" class="flex-1 overflow-y-auto custom-scrollbar relative z-10 flex flex-col">
|
||||
<!-- 头部:头像与核心信息 -->
|
||||
<div class="pt-14 pb-8 px-6 text-center flex flex-col items-center animate-fade-in">
|
||||
<div class="relative group cursor-pointer" @click="viewAvatar">
|
||||
<Avatar
|
||||
:name="contact.user?.name || contact.remark_name"
|
||||
:avatar="contact.user?.avatar"
|
||||
:color="contact.color"
|
||||
size="xl"
|
||||
rounded="full"
|
||||
class="border-[6px] border-[#111827] shadow-2xl relative z-10 transition-transform duration-300 group-hover:scale-105"
|
||||
/>
|
||||
<div v-if="contact.user?.is_online" class="absolute bottom-1 right-1 w-5 h-5 bg-emerald-500 rounded-full border-4 border-[#111827] z-20" title="在线"></div>
|
||||
</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"
|
||||
|
||||
<h3 class="text-2xl font-bold text-white mt-5 flex items-center gap-2 justify-center select-text">
|
||||
{{ contact.remark_name || contact.user?.name }}
|
||||
<span v-if="contact.user?.gender" class="w-5 h-5 rounded-full flex items-center justify-center text-[10px]" :class="contact.user.gender === 1 ? 'bg-blue-500/20 text-blue-400' : 'bg-pink-500/20 text-pink-400'">
|
||||
<i :class="contact.user.gender === 1 ? 'fas fa-mars' : 'fas fa-venus'"></i>
|
||||
</span>
|
||||
</h3>
|
||||
|
||||
<p class="text-gray-400 text-sm mt-2 max-w-md truncate px-4 opacity-80">{{ contact.user?.desc || '暂无签名' }}</p>
|
||||
|
||||
<div class="flex gap-3 mt-4 text-xs font-medium text-gray-400">
|
||||
<span class="bg-white/5 px-2.5 py-1 rounded-md border border-white/5 select-text">ID: {{ contact.user_id || contact.id }}</span>
|
||||
<span v-if="contact.user?.region" class="bg-white/5 px-2.5 py-1 rounded-md border border-white/5">{{ contact.user.region }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 详细信息列表 -->
|
||||
<div class="px-6 py-2 space-y-3 max-w-xl mx-auto w-full animate-slide-up">
|
||||
<div class="bg-white/5 rounded-2xl border border-white/5 overflow-hidden">
|
||||
|
||||
<!-- 网名 -->
|
||||
<div class="flex items-center p-4 hover:bg-white/5 transition-colors group relative border-b border-white/5 last:border-0">
|
||||
<div class="w-9 h-9 rounded-lg bg-white/5 flex items-center justify-center text-gray-500 mr-4 shrink-0">
|
||||
<i class="far fa-user"></i>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0 mr-8">
|
||||
<div class="text-[10px] text-gray-500 uppercase tracking-wider mb-0.5 font-bold">网名</div>
|
||||
<div class="text-sm text-gray-200 font-medium truncate select-text">{{ contact.user?.name || '未知' }}</div>
|
||||
</div>
|
||||
<button class="absolute right-4 w-8 h-8 rounded-lg hover:bg-white/10 text-gray-500 hover:text-primary transition flex items-center justify-center opacity-0 group-hover:opacity-100"
|
||||
@click="copyText(contact.user?.name)" title="复制">
|
||||
<i class="far fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 备注 -->
|
||||
<div class="flex items-center p-4 hover:bg-white/5 transition-colors group relative border-b border-white/5 last:border-0 cursor-pointer" @click="openEditRemark">
|
||||
<div class="w-9 h-9 rounded-lg bg-white/5 flex items-center justify-center text-gray-500 mr-4 shrink-0">
|
||||
<i class="fas fa-tag"></i>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0 mr-8">
|
||||
<div class="text-[10px] text-gray-500 uppercase tracking-wider mb-0.5 font-bold">备注名</div>
|
||||
<div class="text-sm text-gray-200 font-medium truncate select-text">{{ contact.remark_name || '未设置' }}</div>
|
||||
</div>
|
||||
<!-- 备注既可以编辑也可以复制,这里优先给复制按钮,整行点击可编辑 -->
|
||||
<button class="absolute right-4 w-8 h-8 rounded-lg hover:bg-white/10 text-gray-500 hover:text-primary transition flex items-center justify-center opacity-0 group-hover:opacity-100"
|
||||
@click.stop="copyText(contact.remark_name)" title="复制">
|
||||
<i class="far fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 分组 -->
|
||||
<div class="flex items-center p-4 hover:bg-white/5 transition-colors group relative border-b border-white/5 last:border-0 cursor-pointer" @click="openGroupSelect">
|
||||
<div class="w-9 h-9 rounded-lg bg-white/5 flex items-center justify-center text-gray-500 mr-4 shrink-0">
|
||||
<i class="far fa-folder"></i>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-[10px] text-gray-500 uppercase tracking-wider mb-0.5 font-bold">分组</div>
|
||||
<div class="text-sm text-gray-200 font-medium truncate">{{ currentGroup?.group_name || '未分组' }}</div>
|
||||
</div>
|
||||
<i class="fas fa-chevron-right text-xs text-gray-600 absolute right-6 group-hover:text-gray-400 transition-colors"></i>
|
||||
</div>
|
||||
|
||||
<!-- 电话 -->
|
||||
<div v-if="contact.user?.phone" class="flex items-center p-4 hover:bg-white/5 transition-colors group relative border-b border-white/5 last:border-0">
|
||||
<div class="w-9 h-9 rounded-lg bg-white/5 flex items-center justify-center text-gray-500 mr-4 shrink-0">
|
||||
<i class="fas fa-phone-alt"></i>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0 mr-8">
|
||||
<div class="text-[10px] text-gray-500 uppercase tracking-wider mb-0.5 font-bold">手机号</div>
|
||||
<div class="text-sm text-gray-200 font-medium truncate select-text font-mono">{{ contact.user.phone }}</div>
|
||||
</div>
|
||||
<button class="absolute right-4 w-8 h-8 rounded-lg hover:bg-white/10 text-gray-500 hover:text-primary transition flex items-center justify-center opacity-0 group-hover:opacity-100"
|
||||
@click="copyText(contact.user.phone)" title="复制">
|
||||
<i class="far fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 邮箱 -->
|
||||
<div v-if="contact.user?.email" class="flex items-center p-4 hover:bg-white/5 transition-colors group relative border-b border-white/5 last:border-0">
|
||||
<div class="w-9 h-9 rounded-lg bg-white/5 flex items-center justify-center text-gray-500 mr-4 shrink-0">
|
||||
<i class="far fa-envelope"></i>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0 mr-8">
|
||||
<div class="text-[10px] text-gray-500 uppercase tracking-wider mb-0.5 font-bold">邮箱</div>
|
||||
<div class="text-sm text-gray-200 font-medium truncate select-text font-mono">{{ contact.user.email }}</div>
|
||||
</div>
|
||||
<button class="absolute right-4 w-8 h-8 rounded-lg hover:bg-white/10 text-gray-500 hover:text-primary transition flex items-center justify-center opacity-0 group-hover:opacity-100"
|
||||
@click="copyText(contact.user.email)" title="复制">
|
||||
<i class="far fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部快捷操作栏 (3列布局) -->
|
||||
<div class="mt-auto p-6 bg-gradient-to-t from-[#0f172a] via-[#0f172a]/80 to-transparent">
|
||||
<div class="grid grid-cols-3 gap-4 max-w-lg mx-auto w-full">
|
||||
<!-- 发消息 -->
|
||||
<button class="flex flex-col items-center justify-center gap-1.5 py-3.5 px-2 rounded-2xl transition-all duration-200 active:scale-95 group bg-primary text-white shadow-lg shadow-primary/25 hover:bg-primary-hover hover:-translate-y-0.5"
|
||||
@click="handleSendMessage"
|
||||
>
|
||||
取消
|
||||
<i class="fas fa-comment-dots text-xl"></i>
|
||||
<span class="text-xs font-bold">发消息</span>
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 py-3 text-primary font-bold hover:bg-gray-700 transition border-l border-gray-700"
|
||||
@click="handleUpdateRemark"
|
||||
|
||||
<!-- 语音通话 -->
|
||||
<button class="flex flex-col items-center justify-center gap-1.5 py-3.5 px-2 rounded-2xl transition-all duration-200 active:scale-95 group bg-white/5 text-gray-300 hover:bg-white/10 hover:text-white border border-white/5 hover:border-white/10"
|
||||
@click="handleAudioCall"
|
||||
>
|
||||
确认
|
||||
<i class="fas fa-phone text-xl"></i>
|
||||
<span class="text-xs font-medium">语音通话</span>
|
||||
</button>
|
||||
|
||||
<!-- 视频通话 -->
|
||||
<button class="flex flex-col items-center justify-center gap-1.5 py-3.5 px-2 rounded-2xl transition-all duration-200 active:scale-95 group bg-white/5 text-gray-300 hover:bg-white/10 hover:text-white border border-white/5 hover:border-white/10"
|
||||
@click="handleVideoCall"
|
||||
>
|
||||
<i class="fas fa-video text-xl"></i>
|
||||
<span class="text-xs font-medium">视频通话</span>
|
||||
</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 v-else class="flex-1 flex flex-col items-center justify-center text-gray-500/40 select-none">
|
||||
<div class="w-32 h-32 rounded-full bg-white/5 flex items-center justify-center mb-6">
|
||||
<i class="fas fa-user-astronaut text-5xl"></i>
|
||||
</div>
|
||||
<p class="text-lg font-medium">选择左侧好友查看详情</p>
|
||||
</div>
|
||||
|
||||
<!-- 模态框组件 -->
|
||||
<div v-if="showEditRemarkModal" class="fixed inset-0 bg-black/70 backdrop-blur-sm z-[100] flex items-center justify-center p-4" @click.self="showEditRemarkModal = false">
|
||||
<div class="bg-panel w-80 rounded-2xl border border-gray-700 p-5 shadow-2xl animate-scale-in">
|
||||
<h3 class="text-lg font-bold text-white mb-4">修改备注</h3>
|
||||
<input v-model="remarkName" type="text" class="w-full bg-black/30 text-white p-3 rounded-xl border border-gray-600 focus:border-primary outline-none" autoFocus @keyup.enter="handleUpdateRemark" />
|
||||
<div class="flex gap-3 mt-6">
|
||||
<button class="flex-1 py-2.5 rounded-lg text-gray-400 hover:bg-white/5 transition" @click="showEditRemarkModal = false">取消</button>
|
||||
<button class="flex-1 py-2.5 rounded-lg bg-primary text-white font-bold hover:bg-primary-hover transition" @click="handleUpdateRemark">保存</button>
|
||||
</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 v-if="showGroupSelectModal" class="fixed inset-0 bg-black/70 backdrop-blur-sm z-[100] flex items-center justify-center p-4" @click.self="showGroupSelectModal = false">
|
||||
<div class="bg-panel w-80 rounded-2xl border border-gray-700 shadow-2xl animate-scale-in max-h-[500px] flex flex-col">
|
||||
<div class="p-4 border-b border-gray-700 font-bold text-white">选择分组</div>
|
||||
<div class="flex-1 overflow-y-auto p-2">
|
||||
<div v-for="g in allGroups" :key="g.id"
|
||||
class="p-3 rounded-lg hover:bg-white/5 cursor-pointer text-gray-300 flex justify-between items-center transition"
|
||||
:class="{'text-primary bg-primary/10': contact?.group_id === g.id}"
|
||||
@click="handleSelectGroup(g.id)">
|
||||
{{ g.group_name }}
|
||||
<i v-if="contact?.group_id === g.id" class="fas fa-check"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 删除确认模态框 -->
|
||||
<ConfirmModal
|
||||
:show="showDeleteConfirm"
|
||||
title="确认删除"
|
||||
message="确定要删除这个好友吗?删除后将无法接收对方的消息。"
|
||||
type="danger"
|
||||
confirm-text="删除"
|
||||
@confirm="handleDeleteContact"
|
||||
@cancel="showDeleteConfirm = false"
|
||||
/>
|
||||
|
||||
<!-- 房间ID获取失败提示 -->
|
||||
<ConfirmModal
|
||||
:show="showRoomIdErrorModal"
|
||||
title="通话失败"
|
||||
message="获取房间ID失败,无法发起通话"
|
||||
type="danger"
|
||||
confirm-text="确定"
|
||||
:cancel-text="''"
|
||||
@confirm="showRoomIdErrorModal = false"
|
||||
@cancel="showRoomIdErrorModal = false"
|
||||
/>
|
||||
<ConfirmModal :show="showDeleteConfirm" title="删除好友" message="确定要彻底删除该好友吗?此操作不可恢复。" type="danger" confirm-text="确认删除" @confirm="handleDeleteContact" @cancel="showDeleteConfirm = false" />
|
||||
<ConfirmModal :show="showRoomIdErrorModal" title="无法通话" message="无法获取通话线路 (RoomID missing)。" type="warning" :cancel-text="''" @confirm="showRoomIdErrorModal = false" />
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useChatStore } from '@/stores/chat'
|
||||
import { useContactStore } from '@/stores/contact'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import { useWebRTCStore } from '@/stores/webrtc'
|
||||
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 emit = defineEmits<{ switchToChat: [] }>()
|
||||
const chatStore = useChatStore()
|
||||
const contactStore = useContactStore()
|
||||
const toastStore = useToastStore()
|
||||
const authStore = useAuthStore()
|
||||
const webrtcStore = useWebRTCStore()
|
||||
const webrtc = webrtcStore.webrtc
|
||||
const webrtc = useWebRTCStore().webrtc
|
||||
|
||||
const contact = ref<Contact | null>(null)
|
||||
const groups = ref<ContactGroup[]>([])
|
||||
const showRoomIdErrorModal = ref(false)
|
||||
const loading = ref(false)
|
||||
const showMoreOptions = ref(false)
|
||||
const showEditRemarkModal = ref(false)
|
||||
const showGroupSelectModal = ref(false)
|
||||
const showDeleteConfirm = ref(false)
|
||||
const showRoomIdErrorModal = 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
|
||||
})
|
||||
const currentGroup = computed(() => groups.value.find(g => g.id === contact.value?.group_id))
|
||||
const allGroups = computed(() => [{id: 0, group_name: '未分组'}, ...groups.value])
|
||||
|
||||
async function loadContactDetail() {
|
||||
if (!contactStore.selectedContact) {
|
||||
// --- 核心逻辑:即时响应 ---
|
||||
watch(() => contactStore.selectedContact, async (newVal) => {
|
||||
if (!newVal) {
|
||||
contact.value = null
|
||||
return
|
||||
}
|
||||
|
||||
// 1. 立即显示 Store 中的数据(不用等API),解决“请选择好友”的延迟问题
|
||||
contact.value = { ...newVal }
|
||||
remarkName.value = newVal.remark_name || ''
|
||||
|
||||
// 2. 后台静默加载完整详情(获取手机号、Region等可能不在列表中的数据)
|
||||
loading.value = true
|
||||
try {
|
||||
const detail = await contactApi.getContactDetail(contactStore.selectedContact.id)
|
||||
console.log('ContactDetailCard loaded detail:', detail)
|
||||
contact.value = detail
|
||||
remarkName.value = detail.remark_name || ''
|
||||
// 确保 room_id 被正确设置(如果 detail 没有,尝试从 selectedContact 或 chatStore.contacts 获取)
|
||||
if (!contact.value.room_id) {
|
||||
if (contactStore.selectedContact.room_id) {
|
||||
contact.value.room_id = contactStore.selectedContact.room_id
|
||||
} else {
|
||||
// 从 chatStore.contacts 中查找
|
||||
const existingContact = chatStore.contacts.find(c =>
|
||||
c.id === contactStore.selectedContact.id ||
|
||||
c.user_id === contactStore.selectedContact.id ||
|
||||
c.contact_id === contactStore.selectedContact.id ||
|
||||
c.contact_user_id === contactStore.selectedContact.id
|
||||
)
|
||||
if (existingContact?.room_id) {
|
||||
contact.value.room_id = existingContact.room_id
|
||||
console.log('Using room_id from chatStore.contacts:', existingContact.room_id)
|
||||
}
|
||||
}
|
||||
const [detail, groupList] = await Promise.all([
|
||||
contactApi.getContactDetail(newVal.id),
|
||||
contactApi.getGroups() // 同时刷新分组列表
|
||||
])
|
||||
|
||||
// 3. 更新为完整数据
|
||||
contact.value = { ...newVal, ...detail }
|
||||
groups.value = groupList
|
||||
|
||||
// RoomID 容错
|
||||
if (!contact.value.room_id && newVal.room_id) {
|
||||
contact.value.room_id = newVal.room_id
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Failed to load contact detail:', error)
|
||||
toastStore.error(error.message || '加载失败')
|
||||
} catch (e: any) {
|
||||
console.error('Fetch detail failed, using basic info', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
async function loadGroups() {
|
||||
try {
|
||||
const groupsList = await contactApi.getGroups()
|
||||
groups.value = groupsList
|
||||
} catch (error: any) {
|
||||
console.error('Failed to load groups:', error)
|
||||
}
|
||||
// Actions
|
||||
function copyText(text?: string) {
|
||||
if (!text) return
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
toastStore.success('复制成功')
|
||||
}).catch(() => {
|
||||
toastStore.error('复制失败')
|
||||
})
|
||||
}
|
||||
|
||||
function handleSendMessage() {
|
||||
if (contact.value) {
|
||||
chatStore.setCurrentTarget(contact.value)
|
||||
// 通知父组件切换到 Chat Tab
|
||||
emit('switchToChat')
|
||||
}
|
||||
if (!contact.value) return
|
||||
chatStore.setCurrentTarget(contact.value)
|
||||
emit('switchToChat')
|
||||
}
|
||||
|
||||
async function handleAudioCall() {
|
||||
if (contact.value) {
|
||||
// contact_id 或 contact_user_id 是联系人的用户ID
|
||||
const receiverUserId = contact.value.contact_user_id || contact.value.contact_id || contact.value.user_id || contact.value.id
|
||||
// 直接传入 room_id
|
||||
const roomId = contact.value.room_id
|
||||
console.log('ContactDetailCard handleAudioCall:', {
|
||||
contact: contact.value,
|
||||
room_id: roomId,
|
||||
receiverUserId
|
||||
})
|
||||
const result = await webrtc.startCall('audio', receiverUserId, roomId, contact.value)
|
||||
if (!result) {
|
||||
// 显示模态框提示
|
||||
showRoomIdErrorModal.value = true
|
||||
}
|
||||
}
|
||||
async function initiateCall(type: 'audio'|'video') {
|
||||
if (!contact.value) return
|
||||
const targetId = contact.value.contact_user_id || contact.value.user_id || contact.value.id
|
||||
const result = await webrtc.startCall(type, targetId, contact.value.room_id, contact.value)
|
||||
if (!result) showRoomIdErrorModal.value = true
|
||||
}
|
||||
const handleAudioCall = () => initiateCall('audio')
|
||||
const handleVideoCall = () => initiateCall('video')
|
||||
|
||||
function viewAvatar() {
|
||||
// 可以添加查看大图逻辑,这里暂时不做
|
||||
}
|
||||
|
||||
async function handleVideoCall() {
|
||||
if (contact.value) {
|
||||
// contact_id 或 contact_user_id 是联系人的用户ID
|
||||
const receiverUserId = contact.value.contact_user_id || contact.value.contact_id || contact.value.user_id || contact.value.id
|
||||
// 直接传入 room_id
|
||||
const roomId = contact.value.room_id
|
||||
console.log('ContactDetailCard handleVideoCall:', {
|
||||
contact: contact.value,
|
||||
room_id: roomId,
|
||||
receiverUserId
|
||||
})
|
||||
const result = await webrtc.startCall('video', receiverUserId, roomId, contact.value)
|
||||
if (!result) {
|
||||
// 显示模态框提示
|
||||
showRoomIdErrorModal.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
function openEditRemark() { showEditRemarkModal.value = true; showMoreOptions.value = false }
|
||||
function openGroupSelect() { showGroupSelectModal.value = true; showMoreOptions.value = false }
|
||||
|
||||
async function handleUpdateRemark() {
|
||||
if (!contact.value || !remarkName.value.trim()) {
|
||||
toastStore.warning('请输入备注名称')
|
||||
return
|
||||
}
|
||||
|
||||
if(!contact.value) 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
|
||||
}
|
||||
// 同步更新 Store (左侧列表也会变)
|
||||
const listContact = chatStore.contacts.find(c => c.id === contact.value!.id)
|
||||
if(listContact) listContact.remark_name = remarkName.value
|
||||
showEditRemarkModal.value = false
|
||||
toastStore.success('备注更新成功')
|
||||
} catch (error: any) {
|
||||
toastStore.error(error.message || '更新失败')
|
||||
}
|
||||
toastStore.success('备注已修改')
|
||||
} catch(e) { toastStore.error('修改失败') }
|
||||
}
|
||||
|
||||
async function handleSelectGroup(groupId: number) {
|
||||
if (!contact.value) return
|
||||
|
||||
async function handleSelectGroup(gid: 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
|
||||
}
|
||||
await contactApi.updateContact(contact.value.id, { group_id: gid })
|
||||
contact.value.group_id = gid
|
||||
// 同步 Store
|
||||
const listContact = chatStore.contacts.find(c => c.id === contact.value!.id)
|
||||
if(listContact) listContact.group_id = gid
|
||||
showGroupSelectModal.value = false
|
||||
toastStore.success('分组设置成功')
|
||||
} catch (error: any) {
|
||||
toastStore.error(error.message || '设置失败')
|
||||
}
|
||||
toastStore.success('分组已修改')
|
||||
} catch(e) { toastStore.error('设置失败') }
|
||||
}
|
||||
|
||||
async function handleToggleTop() {
|
||||
if (!contact.value) return
|
||||
|
||||
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 || '操作失败')
|
||||
}
|
||||
const newVal = !contact.value.is_top
|
||||
await contactApi.updateContact(contact.value.id, { is_top: newVal })
|
||||
contact.value.is_top = newVal
|
||||
showMoreOptions.value = false
|
||||
} catch(e) { toastStore.error('操作失败') }
|
||||
}
|
||||
|
||||
async function handleToggleMuted() {
|
||||
if (!contact.value) return
|
||||
|
||||
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 || '操作失败')
|
||||
}
|
||||
const newVal = !contact.value.is_muted
|
||||
await contactApi.updateContact(contact.value.id, { is_muted: newVal })
|
||||
contact.value.is_muted = newVal
|
||||
showMoreOptions.value = false
|
||||
} catch(e) { toastStore.error('操作失败') }
|
||||
}
|
||||
|
||||
async function handleToggleSpecialCare() {
|
||||
if (!contact.value) return
|
||||
|
||||
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 || '操作失败')
|
||||
}
|
||||
const newVal = !contact.value.is_special_care
|
||||
await contactApi.updateContact(contact.value.id, { is_special_care: newVal })
|
||||
contact.value.is_special_care = newVal
|
||||
showMoreOptions.value = false
|
||||
} catch(e) { toastStore.error('操作失败') }
|
||||
}
|
||||
|
||||
async function handleDeleteContact() {
|
||||
if (!contact.value) return
|
||||
|
||||
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)
|
||||
}
|
||||
chatStore.contacts = chatStore.contacts.filter(c => c.id !== contact.value!.id)
|
||||
contactStore.setSelectedContact(null)
|
||||
toastStore.success('好友已删除')
|
||||
} catch (error: any) {
|
||||
toastStore.error(error.message || '删除失败')
|
||||
}
|
||||
} catch(e: any) { toastStore.error(e.message) }
|
||||
}
|
||||
|
||||
// 监听选中联系人变化
|
||||
watch(() => contactStore.selectedContact, () => {
|
||||
loadContactDetail()
|
||||
}, { immediate: true })
|
||||
|
||||
onMounted(async () => {
|
||||
await loadGroups()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.custom-scrollbar::-webkit-scrollbar { width: 6px; }
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb { @apply bg-white/10 rounded-full hover:bg-white/20; }
|
||||
.popover-enter-active, .popover-leave-active { transition: all 0.2s ease; }
|
||||
.popover-enter-from, .popover-leave-to { opacity: 0; transform: translateY(10px) scale(0.95); }
|
||||
.animate-scale-in { animation: scaleIn 0.2s cubic-bezier(0.16, 1, 0.3, 1); }
|
||||
.animate-fade-in { animation: fadeIn 0.4s ease-out; }
|
||||
.animate-slide-up { animation: slideUp 0.4s ease-out; }
|
||||
@keyframes scaleIn { from { transform: scale(0.9); opacity: 0; } to { transform: scale(1); opacity: 1; } }
|
||||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||
@keyframes slideUp { from { transform: translateY(20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
|
||||
</style>
|
||||
|
||||
@@ -1,222 +1,144 @@
|
||||
<template>
|
||||
<div class="flex-1 flex flex-col bg-panel h-full relative overflow-hidden">
|
||||
<!-- 装饰背景 -->
|
||||
<div class="absolute top-0 right-0 w-64 h-64 bg-primary/5 rounded-full blur-3xl -z-0 pointer-events-none"></div>
|
||||
<div class="absolute top-0 right-0 w-96 h-96 bg-primary/10 rounded-full blur-[100px] -z-0 pointer-events-none translate-x-1/3 -translate-y-1/3"></div>
|
||||
|
||||
<div class="p-6 border-b border-gray-800 z-10 flex justify-between items-center">
|
||||
<h2 class="text-2xl font-bold text-white tracking-wide">联系人</h2>
|
||||
<button class="w-10 h-10 rounded-full bg-input hover:bg-white/10 flex items-center justify-center transition" title="添加好友">
|
||||
<i class="fas fa-user-plus text-gray-400 hover:text-white"></i>
|
||||
</button>
|
||||
<div class="p-8 border-b border-gray-800/50 z-10 flex justify-between items-end">
|
||||
<div>
|
||||
<h2 class="text-3xl font-bold text-white tracking-tight">添加好友</h2>
|
||||
<p class="text-gray-400 text-sm mt-1">搜索 ID、邮箱或手机号来结识新朋友</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-6 z-10 custom-scrollbar">
|
||||
<div class="space-y-6">
|
||||
<!-- 搜索用户 -->
|
||||
<div class="bg-black/20 p-4 rounded-3xl border border-white/5 shadow-lg backdrop-blur-sm">
|
||||
<div class="relative mb-4 group">
|
||||
<i class="fas fa-search absolute left-4 top-3.5 text-gray-500 group-focus-within:text-primary transition"></i>
|
||||
<input
|
||||
v-model="searchKeyword"
|
||||
type="text"
|
||||
class="w-full bg-input rounded-2xl py-3 pl-11 pr-4 text-sm text-white focus:ring-2 ring-primary outline-none transition placeholder-gray-500"
|
||||
placeholder="搜索用户ID、邮箱或手机号..."
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
<div class="flex-1 overflow-y-auto p-8 z-10 custom-scrollbar">
|
||||
<div class="max-w-4xl mx-auto space-y-8">
|
||||
|
||||
<!-- 搜索区域 -->
|
||||
<div class="bg-gradient-to-br from-gray-800/50 to-gray-900/50 p-1 rounded-3xl shadow-2xl backdrop-blur-sm border border-white/5">
|
||||
<div class="bg-[#111827] rounded-[20px] p-6">
|
||||
<div class="flex gap-4">
|
||||
<div class="relative flex-1 group">
|
||||
<i class="fas fa-search absolute left-4 top-1/2 -translate-y-1/2 text-gray-500 group-focus-within:text-primary transition-colors"></i>
|
||||
<input
|
||||
v-model="searchKeyword"
|
||||
type="text"
|
||||
class="w-full bg-gray-800/50 hover:bg-gray-800 focus:bg-gray-800 rounded-xl py-4 pl-12 pr-4 text-base text-white focus:ring-2 ring-primary/50 outline-none transition-all placeholder-gray-500"
|
||||
placeholder="输入关键词搜索..."
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
class="px-8 py-4 bg-primary hover:bg-primary-hover text-white rounded-xl transition-all shadow-lg shadow-primary/25 font-bold text-sm tracking-wide active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
@click="handleSearch"
|
||||
:disabled="isSearching"
|
||||
>
|
||||
<i v-if="isSearching" class="fas fa-circle-notch fa-spin mr-2"></i>
|
||||
{{ isSearching ? '搜索中' : '查找' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="w-full py-3 bg-gradient-to-r from-primary to-indigo-600 hover:from-indigo-500 hover:to-primary text-white rounded-2xl transition shadow-lg shadow-indigo-500/20 font-medium active:scale-95"
|
||||
@click="handleSearch"
|
||||
>
|
||||
查找用户
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 搜索结果 -->
|
||||
<TransitionGroup name="list" tag="div" v-if="searchResults.length > 0" class="space-y-3">
|
||||
<h3 class="text-xs font-bold text-gray-400 uppercase tracking-wider pl-2" key="title">搜索结果</h3>
|
||||
<TransitionGroup
|
||||
name="staggered-list"
|
||||
tag="div"
|
||||
v-if="searchResults.length > 0"
|
||||
class="grid grid-cols-1 md:grid-cols-2 gap-4"
|
||||
>
|
||||
<div class="col-span-full text-xs font-bold text-gray-500 uppercase tracking-wider mb-2 pl-1">搜索结果</div>
|
||||
|
||||
<div
|
||||
v-for="user in searchResults"
|
||||
:key="user.id"
|
||||
class="flex items-center p-4 bg-input rounded-2xl hover:bg-white/5 transition border border-transparent hover:border-white/10 group"
|
||||
class="flex items-center p-4 bg-[#1f2937] rounded-2xl hover:bg-[#374151] border border-gray-700/50 hover:border-primary/30 transition-all group"
|
||||
>
|
||||
<Avatar :name="user.name" :avatar="user.avatar" size="md" rounded="xl" class="mr-4 shadow-md" />
|
||||
<Avatar :name="user.name" :avatar="user.avatar" size="lg" rounded="xl" class="mr-4 shadow-lg" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-bold text-white text-base truncate">{{ user.name }}</div>
|
||||
<div class="text-xs text-gray-400 truncate mt-1">{{ user.email || user.phone }}</div>
|
||||
<div class="font-bold text-white text-lg truncate">{{ user.name }}</div>
|
||||
<div class="text-xs text-gray-400 truncate mt-0.5">{{ user.email || user.phone || 'ID: ' + user.id }}</div>
|
||||
</div>
|
||||
<button
|
||||
class="px-5 py-2 bg-white/10 hover:bg-primary text-white rounded-xl transition text-sm font-medium backdrop-blur-md"
|
||||
class="ml-3 w-10 h-10 rounded-full bg-white/10 hover:bg-primary text-white flex items-center justify-center transition-all hover:scale-110 active:scale-90"
|
||||
@click="handleAddFriend(user)"
|
||||
title="添加好友"
|
||||
>
|
||||
<i class="fas fa-plus mr-1"></i>添加
|
||||
<i class="fas fa-user-plus"></i>
|
||||
</button>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
|
||||
<!-- 好友申请 -->
|
||||
<TransitionGroup name="list" tag="div" v-if="friendRequests.length > 0" class="space-y-3">
|
||||
<h3 class="text-xs font-bold text-gray-400 uppercase tracking-wider pl-2 flex items-center gap-2" key="req-title">
|
||||
新的朋友 <span class="bg-red-500 text-white text-[10px] px-1.5 py-0.5 rounded-full">{{ friendRequests.length }}</span>
|
||||
</h3>
|
||||
<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>
|
||||
<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>
|
||||
</TransitionGroup>
|
||||
|
||||
<!-- 空状态提示 -->
|
||||
<div v-if="searchResults.length === 0 && friendRequests.length === 0" class="text-center py-20 opacity-30">
|
||||
<i class="fas fa-user-astronaut text-6xl mb-4"></i>
|
||||
<p>探索更多好友</p>
|
||||
<!-- 空状态/引导 -->
|
||||
<div v-if="!hasSearched && searchResults.length === 0" class="text-center py-20 opacity-30 select-none">
|
||||
<i class="fas fa-globe-asia text-7xl mb-6 text-primary animate-pulse"></i>
|
||||
<p class="text-lg">连接世界,从这里开始</p>
|
||||
</div>
|
||||
|
||||
<div v-if="hasSearched && searchResults.length === 0" class="text-center py-10 text-gray-500">
|
||||
<p>未找到匹配的用户</p>
|
||||
</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 { useConversationStore } from '@/stores/conversation'
|
||||
import { ref } from 'vue'
|
||||
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)
|
||||
}
|
||||
}
|
||||
const isSearching = ref(false)
|
||||
const hasSearched = ref(false)
|
||||
|
||||
async function handleSearch() {
|
||||
if (!searchKeyword.value.trim()) return
|
||||
isSearching.value = true
|
||||
hasSearched.value = true
|
||||
searchResults.value = [] // Reset
|
||||
|
||||
try {
|
||||
const results = await contactApi.searchUsers(searchKeyword.value)
|
||||
// 模拟一点延迟感,让UI更自然
|
||||
const [results] = await Promise.all([
|
||||
contactApi.searchUsers(searchKeyword.value),
|
||||
new Promise(resolve => setTimeout(resolve, 300))
|
||||
])
|
||||
searchResults.value = results
|
||||
if (results.length === 0) toastStore.info('未找到相关用户')
|
||||
} catch (error: any) {
|
||||
toastStore.error(error.message || '搜索失败')
|
||||
} finally {
|
||||
isSearching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddFriend(user: User) {
|
||||
try {
|
||||
await contactApi.addFriend({ to_user_id: user.id, message: '你好,我想加你为好友' })
|
||||
await contactApi.addFriend({ to_user_id: user.id, message: '你好,我是' + user.name })
|
||||
toastStore.success('好友申请已发送')
|
||||
// 从搜索结果中移除已发送申请的用户(可选)
|
||||
// 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.filter((r) => r.id !== requestId)
|
||||
toastStore.success('已接受好友申请')
|
||||
|
||||
// 刷新联系人列表和会话列表
|
||||
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.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 () => {
|
||||
try {
|
||||
const requests = await contactApi.getFriendRequests()
|
||||
contactStore.setFriendRequests(requests)
|
||||
friendRequests.value = requests
|
||||
} catch (error) {
|
||||
console.error('Failed to load friend requests:', error)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.custom-scrollbar::-webkit-scrollbar { width: 4px; }
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb { @apply bg-gray-700 rounded-full; }
|
||||
|
||||
.list-enter-active,
|
||||
.list-leave-active {
|
||||
.staggered-list-enter-active,
|
||||
.staggered-list-leave-active {
|
||||
transition: all 0.4s ease;
|
||||
}
|
||||
.list-enter-from,
|
||||
.list-leave-to {
|
||||
.staggered-list-enter-from,
|
||||
.staggered-list-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
transform: translateY(20px);
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar { width: 6px; }
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb { @apply bg-gray-700/50 rounded-full; }
|
||||
</style>
|
||||
|
||||
@@ -1,71 +1,68 @@
|
||||
<template>
|
||||
<div class="flex-1 flex flex-col bg-panel h-full">
|
||||
<div class="p-4 border-b border-gray-800">
|
||||
<div class="p-6 border-b border-gray-800/50 flex justify-between items-center">
|
||||
<h2 class="text-xl font-bold text-white">好友通知</h2>
|
||||
<button @click="loadFriendRequests" class="text-gray-400 hover:text-white transition">
|
||||
<i class="fas fa-sync-alt" :class="{ 'fa-spin': loading }"></i>
|
||||
</button>
|
||||
</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 class="flex-1 overflow-y-auto p-4 custom-scrollbar">
|
||||
<TransitionGroup name="list" tag="div" class="space-y-3 max-w-3xl mx-auto">
|
||||
<div
|
||||
v-for="request in friendRequests"
|
||||
:key="request.id"
|
||||
class="flex items-center p-4 bg-input/50 rounded-2xl border transition relative overflow-hidden"
|
||||
:class="request.status === 'rejected'
|
||||
? 'border-danger/20 hover:border-danger/50'
|
||||
: 'border-primary/20 hover:border-primary/50'"
|
||||
v-for="request in friendRequests"
|
||||
:key="request.id"
|
||||
class="flex items-center p-4 bg-[#1f2937] rounded-xl border-l-4 shadow-md transition-all hover:bg-[#374151]"
|
||||
:class="getStatusColor(request.status)"
|
||||
>
|
||||
<div
|
||||
class="absolute left-0 top-0 bottom-0 w-1"
|
||||
:class="request.status === 'rejected' ? 'bg-danger' : 'bg-primary'"
|
||||
></div>
|
||||
<Avatar
|
||||
:name="request.from_user?.name"
|
||||
:avatar="request.from_user?.avatar"
|
||||
size="md"
|
||||
rounded="xl"
|
||||
class="mr-4"
|
||||
:name="request.from_user?.name"
|
||||
:avatar="request.from_user?.avatar"
|
||||
size="md"
|
||||
rounded="xl"
|
||||
class="mr-4 shrink-0"
|
||||
/>
|
||||
<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>
|
||||
<span v-if="request.status === 'rejected'">
|
||||
对方拒绝了你的好友申请
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ request.message || '请求添加你为好友' }}
|
||||
</span>
|
||||
|
||||
<div class="flex-1 min-w-0 mr-4">
|
||||
<div class="flex justify-between items-start">
|
||||
<span class="font-bold text-white text-base">{{ request.from_user?.name }}</span>
|
||||
<span class="text-xs text-gray-500">{{ formatTime(new Date(request.created_at).getTime()) }}</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 mt-1">
|
||||
{{ formatTime(new Date(request.created_at).getTime()) }}
|
||||
|
||||
<div class="mt-1 text-sm text-gray-300 flex items-center gap-2">
|
||||
<span class="bg-black/20 px-2 py-0.5 rounded text-xs text-gray-400">附言</span>
|
||||
<span class="truncate">{{ request.message || '请求添加你为好友' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="request.status === 'pending'" class="flex gap-2">
|
||||
|
||||
<!-- 操作区 -->
|
||||
<div class="flex items-center gap-3 shrink-0" v-if="request.status === 'pending'">
|
||||
<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="接受"
|
||||
class="w-8 h-8 rounded-full bg-gray-700 hover:bg-emerald-500 text-gray-300 hover:text-white transition flex items-center justify-center tooltip-trigger"
|
||||
@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="拒绝"
|
||||
class="w-8 h-8 rounded-full bg-gray-700 hover:bg-red-500 text-gray-300 hover:text-white transition flex items-center justify-center"
|
||||
@click="handleRejectRequest(request.id)"
|
||||
title="拒绝"
|
||||
>
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="flex items-center gap-2 text-danger text-sm">
|
||||
<i class="fas fa-times-circle"></i>
|
||||
<span>已拒绝</span>
|
||||
|
||||
<div v-else class="text-sm font-medium px-3 py-1 rounded bg-black/20" :class="request.status === 'rejected' ? 'text-red-400' : 'text-emerald-400'">
|
||||
{{ request.status === 'rejected' ? '已拒绝' : '已添加' }}
|
||||
</div>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-if="!loading && friendRequests.length === 0" class="text-center py-20 text-gray-600">
|
||||
<i class="far fa-bell-slash text-5xl mb-4"></i>
|
||||
<p class="text-sm">暂无好友通知</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -86,15 +83,27 @@ const chatStore = useChatStore()
|
||||
const toastStore = useToastStore()
|
||||
|
||||
const friendRequests = ref<FriendRequest[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
function getStatusColor(status: string) {
|
||||
switch(status) {
|
||||
case 'pending': return 'border-primary'
|
||||
case 'rejected': return 'border-red-500 opacity-60'
|
||||
case 'accepted': return 'border-emerald-500 opacity-60'
|
||||
default: return 'border-gray-500'
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFriendRequests() {
|
||||
loading.value = true
|
||||
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 || '加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,16 +111,15 @@ async function handleAcceptRequest(requestId: number) {
|
||||
try {
|
||||
await contactApi.acceptFriendRequest(requestId)
|
||||
contactStore.removeFriendRequest(requestId)
|
||||
friendRequests.value = friendRequests.value.filter((r) => r.id !== requestId)
|
||||
toastStore.success('已接受好友申请')
|
||||
|
||||
// 乐观更新UI状态,不直接删除,而是变更为已接受,体验更好
|
||||
const item = friendRequests.value.find(r => r.id === requestId)
|
||||
if(item) item.status = 'accepted'
|
||||
|
||||
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 || '操作失败')
|
||||
}
|
||||
@@ -121,20 +129,34 @@ async function handleRejectRequest(requestId: number) {
|
||||
try {
|
||||
await contactApi.rejectFriendRequest(requestId)
|
||||
contactStore.removeFriendRequest(requestId)
|
||||
friendRequests.value = friendRequests.value.filter((r) => r.id !== requestId)
|
||||
toastStore.success('已拒绝好友申请')
|
||||
|
||||
const item = friendRequests.value.find(r => r.id === requestId)
|
||||
if(item) item.status = 'rejected'
|
||||
|
||||
toastStore.info('已拒绝')
|
||||
} catch (error: any) {
|
||||
toastStore.error(error.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 监听好友申请列表变化
|
||||
watch(() => contactStore.friendRequests, (newRequests) => {
|
||||
friendRequests.value = newRequests
|
||||
}, { deep: true })
|
||||
|
||||
onMounted(async () => {
|
||||
await loadFriendRequests()
|
||||
})
|
||||
onMounted(loadFriendRequests)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.custom-scrollbar::-webkit-scrollbar { width: 4px; }
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb { @apply bg-gray-700 rounded-full; }
|
||||
|
||||
.list-enter-active,
|
||||
.list-leave-active {
|
||||
transition: all 0.4s ease;
|
||||
}
|
||||
.list-enter-from,
|
||||
.list-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user