联系人UI升级

This commit is contained in:
2025-12-04 13:28:08 +08:00
parent 68bf1d9796
commit 452ac7329e
4 changed files with 980 additions and 936 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>