bug修复

This commit is contained in:
2025-12-14 12:19:47 +08:00
parent 5eb361f44d
commit 1d7be69b87
5 changed files with 545 additions and 10 deletions

58
src/api/modules/search.ts Normal file
View File

@@ -0,0 +1,58 @@
import request from '../request'
import type { User } from '@/types/api'
/**
* 搜索相关API
*/
// 联系人搜索结果
export interface ContactSearchResult {
user: User
remark_name: string
room_id: string
}
// 群聊搜索结果
export interface GroupSearchResult {
room_id: string
room_name: string
room_avatar: string
owner_id: string
member_count: number
}
// 消息搜索结果
export interface MessageSearchResult {
id: number
room_id: string
room_name: string
content: string
message_type: number
created_at: string
sender: User
is_group_chat: boolean
match_content: string
}
// 聚合搜索结果
export interface GlobalSearchResult {
contacts: ContactSearchResult[]
groups: GroupSearchResult[]
messages: MessageSearchResult[]
}
// 搜索类型
export type SearchType = 'all' | 'contacts' | 'groups' | 'messages'
/**
* 聚合搜索
* @param keyword 搜索关键词
* @param type 搜索类型all默认、contacts、groups、messages
* @param limit 每类结果的最大数量默认20
*/
export function globalSearch(keyword: string, type: SearchType = 'all', limit: number = 20) {
return request.get<GlobalSearchResult>('/search', {
params: { keyword, type, limit }
})
}

View File

@@ -0,0 +1,220 @@
<template>
<div
v-if="show"
class="absolute top-full left-0 right-0 mt-1 bg-[#1e2329] border border-gray-700 rounded-xl shadow-2xl z-50 max-h-[70vh] overflow-hidden flex flex-col"
>
<!-- 加载状态 -->
<div v-if="loading" class="flex items-center justify-center py-8">
<i class="fas fa-circle-notch fa-spin text-indigo-400 text-xl"></i>
<span class="ml-2 text-gray-400 text-sm">搜索中...</span>
</div>
<!-- 空结果 -->
<div v-else-if="isEmpty" class="flex flex-col items-center justify-center py-8 text-gray-500">
<i class="fas fa-search text-3xl mb-2 opacity-50"></i>
<span class="text-sm">未找到相关结果</span>
<span class="text-xs mt-1 opacity-70">换个关键词试试</span>
</div>
<!-- 搜索结果 -->
<div v-else class="overflow-y-auto custom-scrollbar flex-1">
<!-- 联系人 -->
<div v-if="safeResult.contacts.length > 0" class="border-b border-gray-700/50">
<div class="px-4 py-2 text-xs font-bold text-gray-500 uppercase tracking-wider bg-black/20 flex items-center gap-2">
<i class="fas fa-user-friends"></i>
<span>联系人</span>
<span class="ml-auto text-gray-600">{{ safeResult.contacts.length }}</span>
</div>
<div class="py-1">
<div
v-for="contact in safeResult.contacts"
:key="contact.user?.id || Math.random()"
class="flex items-center gap-3 px-4 py-2.5 hover:bg-white/5 cursor-pointer transition-colors"
@click="handleContactClick(contact)"
>
<Avatar
:name="contact.remark_name || contact.user?.name"
:avatar="contact.user?.avatar"
size="sm"
rounded="full"
/>
<div class="flex-1 min-w-0">
<div class="text-sm text-white truncate" v-html="highlightKeyword(contact.remark_name || contact.user?.name || '')"></div>
<div class="text-xs text-gray-500 truncate" v-html="highlightKeyword(contact.user?.email || contact.user?.phone || '')"></div>
</div>
</div>
</div>
</div>
<!-- 群聊 -->
<div v-if="safeResult.groups.length > 0" class="border-b border-gray-700/50">
<div class="px-4 py-2 text-xs font-bold text-gray-500 uppercase tracking-wider bg-black/20 flex items-center gap-2">
<i class="fas fa-users"></i>
<span>群聊</span>
<span class="ml-auto text-gray-600">{{ safeResult.groups.length }}</span>
</div>
<div class="py-1">
<div
v-for="group in safeResult.groups"
:key="group.room_id"
class="flex items-center gap-3 px-4 py-2.5 hover:bg-white/5 cursor-pointer transition-colors"
@click="handleGroupClick(group)"
>
<Avatar
:name="group.room_name"
:avatar="group.room_avatar"
size="sm"
rounded="lg"
/>
<div class="flex-1 min-w-0">
<div class="text-sm text-white truncate" v-html="highlightKeyword(group.room_name || '')"></div>
<div class="text-xs text-gray-500">{{ group.member_count || 0 }} </div>
</div>
</div>
</div>
</div>
<!-- 聊天记录 -->
<div v-if="safeResult.messages.length > 0">
<div class="px-4 py-2 text-xs font-bold text-gray-500 uppercase tracking-wider bg-black/20 flex items-center gap-2">
<i class="fas fa-comment-dots"></i>
<span>聊天记录</span>
<span class="ml-auto text-gray-600">{{ safeResult.messages.length }}</span>
</div>
<div class="py-1">
<div
v-for="msg in safeResult.messages"
:key="msg.id"
class="flex items-start gap-3 px-4 py-2.5 hover:bg-white/5 cursor-pointer transition-colors"
@click="handleMessageClick(msg)"
>
<Avatar
:name="msg.sender?.name"
:avatar="msg.sender?.avatar"
size="sm"
rounded="full"
/>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-0.5">
<span class="text-sm text-white truncate">{{ msg.sender?.name || '未知用户' }}</span>
<span v-if="msg.is_group_chat" class="text-xs text-indigo-400 bg-indigo-500/20 px-1.5 rounded">
{{ msg.room_name || '群聊' }}
</span>
<span class="text-xs text-gray-600 ml-auto shrink-0">{{ formatTime(msg.created_at) }}</span>
</div>
<div class="text-xs text-gray-400 truncate leading-relaxed" v-html="highlightKeyword(msg.match_content || '')"></div>
</div>
</div>
</div>
</div>
</div>
<!-- 底部操作 -->
<div v-if="!loading && !isEmpty" class="px-4 py-2 border-t border-gray-700/50 bg-black/20 text-center">
<span class="text-xs text-gray-500">
<kbd class="px-1.5 py-0.5 bg-gray-700 rounded text-gray-300 mx-0.5">ESC</kbd> 关闭
</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import Avatar from '@/components/common/Avatar.vue'
import type { GlobalSearchResult, ContactSearchResult, GroupSearchResult, MessageSearchResult } from '@/api/modules/search'
interface Props {
show: boolean
loading: boolean
keyword: string
result: GlobalSearchResult
}
const props = withDefaults(defineProps<Props>(), {
show: false,
loading: false,
keyword: '',
result: () => ({ contacts: [], groups: [], messages: [] })
})
const emit = defineEmits<{
'select-contact': [contact: ContactSearchResult]
'select-group': [group: GroupSearchResult]
'select-message': [message: MessageSearchResult]
'close': []
}>()
// 安全获取结果数据
const safeResult = computed(() => ({
contacts: props.result?.contacts ?? [],
groups: props.result?.groups ?? [],
messages: props.result?.messages ?? []
}))
// 是否为空结果
const isEmpty = computed(() => {
return !props.loading &&
safeResult.value.contacts.length === 0 &&
safeResult.value.groups.length === 0 &&
safeResult.value.messages.length === 0
})
// 高亮关键词
function highlightKeyword(text: string): string {
if (!text || !props.keyword) return text || ''
const regex = new RegExp(`(${escapeRegExp(props.keyword)})`, 'gi')
return text.replace(regex, '<span class="text-indigo-400 font-medium">$1</span>')
}
// 转义正则特殊字符
function escapeRegExp(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
// 格式化时间
function formatTime(timeStr: string): string {
if (!timeStr) return ''
const date = new Date(timeStr)
const now = new Date()
const diff = now.getTime() - date.getTime()
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
if (days === 0) {
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
} else if (days === 1) {
return '昨天'
} else if (days < 7) {
return `${days}天前`
} else {
return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' })
}
}
// 点击联系人
function handleContactClick(contact: ContactSearchResult) {
emit('select-contact', contact)
}
// 点击群聊
function handleGroupClick(group: GroupSearchResult) {
emit('select-group', group)
}
// 点击消息
function handleMessageClick(msg: MessageSearchResult) {
emit('select-message', msg)
}
</script>
<style scoped>
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
@apply bg-gray-700 rounded-full;
}
.custom-scrollbar::-webkit-scrollbar-track {
@apply bg-transparent;
}
</style>

View File

@@ -171,20 +171,23 @@ export function useWebRTC(
const roomId = getSafeRoomId(currentReceiverUserId)
if (!roomId) return
// 通话类型说明
const callTypeText = call.type === 'video' ? '[视频通话]' : '[语音通话]'
// 根据原因生成系统消息内容
let content = ''
switch (reason) {
case 'connected':
content = `通话时长 ${formatDuration(call.duration)}`
content = `${callTypeText} 通话时长 ${formatDuration(call.duration)}`
break
case 'cancelled':
content = '已取消'
content = `${callTypeText} 已取消`
break
case 'rejected':
content = '对方未接听'
content = `${callTypeText} 对方未接听`
break
case 'busy':
content = '对方忙'
content = `${callTypeText} 对方忙`
break
}
@@ -402,17 +405,15 @@ export function useWebRTC(
function endCall() {
stopRingtone()
// 主动挂断时发送通话结束系统消息
// 只有发起方发送通话结束系统消息,避免重复
if (isCaller.value) {
if (call.status === 'outgoing') {
sendSummaryMessage('cancelled') // 呼出状态主动取消
} else if (call.status === 'connected') {
sendSummaryMessage('connected') // 通话中主动挂断
}
} else if (call.status === 'connected') {
// 被叫方在通话中挂断也发送消息
sendSummaryMessage('connected')
}
// 被叫方不发送消息,由发起方统一发送
sendSignal('hangup')
closeCall()
}

View File

@@ -111,12 +111,26 @@
</button>
</div>
<div class="relative group">
<i class="fas fa-search absolute left-4 top-3 text-gray-500 group-focus-within:text-primary transition"></i>
<i class="fas fa-search absolute left-4 top-3 text-gray-500 group-focus-within:text-primary transition z-10"></i>
<input
v-model="searchQuery"
type="text"
class="w-full bg-input rounded-2xl py-2.5 pl-10 pr-4 text-sm text-white focus:ring-2 ring-primary outline-none transition placeholder-gray-500 shadow-inner"
placeholder="搜索会话或联系人..."
@input="handleSearchInput"
@focus="handleSearchFocus"
@keydown.esc="closeSearchResults"
/>
<!-- 聚合搜索结果 -->
<SearchResults
:show="showSearchResults"
:loading="searchLoading"
:keyword="searchQuery"
:result="searchResult"
@select-contact="handleSearchContactSelect"
@select-group="handleSearchGroupSelect"
@select-message="handleSearchMessageSelect"
@close="closeSearchResults"
/>
</div>
</div>
@@ -712,8 +726,11 @@ import GroupChatPanel from '@/components/chat/GroupChatPanel.vue'
import GroupCallBanner from '@/components/chat/GroupCallBanner.vue'
import UserInfoCard from '@/components/common/UserInfoCard.vue'
import MomentPanel from '@/components/moment/MomentPanel.vue'
import SearchResults from '@/components/common/SearchResults.vue'
import * as groupApi from '@/api/modules/room'
import * as systemApi from '@/api/modules/system'
import * as searchApi from '@/api/modules/search'
import type { GlobalSearchResult, ContactSearchResult, GroupSearchResult, MessageSearchResult } from '@/api/modules/search'
const router = useRouter()
const authStore = useAuthStore()
@@ -737,6 +754,12 @@ let connectionCheckInterval: number | null = null
const currentTab = ref<'chat' | 'contact' | 'moment'>(storage.getCurrentTab() as 'chat' | 'contact' | 'moment')
const searchQuery = ref('')
const inputText = ref('')
// 聚合搜索状态
const showSearchResults = ref(false)
const searchLoading = ref(false)
const searchResult = ref<GlobalSearchResult>({ contacts: [], groups: [], messages: [] })
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null
const chatVisible = ref(false)
const isMobile = ref(window.innerWidth < 768)
const showProfileModal = ref(false)
@@ -1926,6 +1949,114 @@ async function handleGroupMemberRemoved() {
await conversationStore.loadConversations()
}
// ========== 聚合搜索相关函数 ==========
// 搜索输入处理(带防抖)
function handleSearchInput() {
if (searchDebounceTimer) {
clearTimeout(searchDebounceTimer)
}
const query = searchQuery.value.trim()
if (!query) {
showSearchResults.value = false
searchResult.value = { contacts: [], groups: [], messages: [] }
return
}
showSearchResults.value = true
searchLoading.value = true
searchDebounceTimer = setTimeout(async () => {
try {
const result = await searchApi.globalSearch(query, 'all', 20)
searchResult.value = result
} catch (error) {
console.error('搜索失败:', error)
searchResult.value = { contacts: [], groups: [], messages: [] }
} finally {
searchLoading.value = false
}
}, 300)
}
// 搜索框获得焦点
function handleSearchFocus() {
if (searchQuery.value.trim()) {
showSearchResults.value = true
}
}
// 关闭搜索结果
function closeSearchResults() {
showSearchResults.value = false
}
// 选择搜索结果中的联系人
function handleSearchContactSelect(contact: ContactSearchResult) {
closeSearchResults()
searchQuery.value = ''
// 构造 Contact 对象并选中
const targetContact: Contact = {
id: contact.user.id,
user_id: contact.user.id,
contact_user_id: contact.user.id,
room_id: contact.room_id,
room_type: 'p2p',
is_group: false,
remark_name: contact.remark_name,
is_top: false,
is_muted: false,
user: contact.user
}
chatStore.setCurrentTarget(targetContact)
}
// 选择搜索结果中的群聊
function handleSearchGroupSelect(group: GroupSearchResult) {
closeSearchResults()
searchQuery.value = ''
// 构造群聊 Contact 对象并选中
const targetGroup: Contact = {
id: group.room_id,
user_id: group.room_id,
contact_user_id: group.room_id,
room_id: group.room_id,
room_type: 'group',
is_group: true,
remark_name: group.room_name,
member_count: group.member_count,
owner_id: group.owner_id,
is_top: false,
is_muted: false
}
chatStore.setCurrentTarget(targetGroup)
}
// 选择搜索结果中的消息
function handleSearchMessageSelect(message: MessageSearchResult) {
closeSearchResults()
searchQuery.value = ''
// 根据消息的 room_id 跳转到对应会话
const isGroup = message.is_group_chat
const targetContact: Contact = {
id: message.room_id,
user_id: isGroup ? message.room_id : (message.sender?.id || message.room_id),
contact_user_id: isGroup ? message.room_id : (message.sender?.id || message.room_id),
room_id: message.room_id,
room_type: isGroup ? 'group' : 'p2p',
is_group: isGroup,
remark_name: message.room_name || message.sender?.name || '',
is_top: false,
is_muted: false,
user: message.sender
}
chatStore.setCurrentTarget(targetContact)
}
// 刷新会话列表
async function refreshConversations() {
if (refreshingConversations.value) return

View File

@@ -4,12 +4,26 @@
<div class="p-4 border-b border-gray-800/50">
<div class="flex gap-2">
<div class="relative group flex-1">
<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>
<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 z-10"></i>
<input
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="搜索好友、群组..."
@input="handleSearchInput"
@focus="handleSearchFocus"
@keydown.esc="closeSearchResults"
/>
<!-- 聚合搜索结果 -->
<SearchResults
:show="showSearchResults"
:loading="searchLoading"
:keyword="searchKeyword"
:result="searchResult"
@select-contact="handleSearchContactSelect"
@select-group="handleSearchGroupSelect"
@select-message="handleSearchMessageSelect"
@close="closeSearchResults"
/>
</div>
<!-- 加号按钮 -->
@@ -470,7 +484,10 @@ import ContextMenu from '@/components/common/ContextMenu.vue'
import ConfirmModal from '@/components/common/ConfirmModal.vue'
import SelectContactsModal from '@/components/chat/SelectContactsModal.vue'
import GroupChatCard from '@/components/contact/GroupChatCard.vue'
import SearchResults from '@/components/common/SearchResults.vue'
import * as searchApi from '@/api/modules/search'
import type { Contact, ContactGroup } from '@/types/api'
import type { GlobalSearchResult, ContactSearchResult, GroupSearchResult, MessageSearchResult } from '@/api/modules/search'
const router = useRouter()
const contactStore = useContactStore()
@@ -487,6 +504,12 @@ const showActionMenu = ref(false)
const showCreateGroupModal = ref(false)
const showJoinGroupModal = ref(false)
// 聚合搜索状态
const showSearchResults = ref(false)
const searchLoading = ref(false)
const searchResult = ref<GlobalSearchResult>({ contacts: [], groups: [], messages: [] })
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null
// 群聊列表状态
const groupChats = ref<Array<{
room_id: string
@@ -581,6 +604,108 @@ const filteredGroupChats = computed(() => {
})
})
// --- 聚合搜索相关函数 ---
function handleSearchInput() {
if (searchDebounceTimer) {
clearTimeout(searchDebounceTimer)
}
const query = searchKeyword.value.trim()
if (!query) {
showSearchResults.value = false
searchResult.value = { contacts: [], groups: [], messages: [] }
return
}
showSearchResults.value = true
searchLoading.value = true
searchDebounceTimer = setTimeout(async () => {
try {
const result = await searchApi.globalSearch(query, 'all', 20)
searchResult.value = result
} catch (error) {
console.error('搜索失败:', error)
searchResult.value = { contacts: [], groups: [], messages: [] }
} finally {
searchLoading.value = false
}
}, 300)
}
function handleSearchFocus() {
if (searchKeyword.value.trim()) {
showSearchResults.value = true
}
}
function closeSearchResults() {
showSearchResults.value = false
}
function handleSearchContactSelect(contact: ContactSearchResult) {
closeSearchResults()
searchKeyword.value = ''
const targetContact: Contact = {
id: contact.user.id,
user_id: contact.user.id,
contact_user_id: contact.user.id,
room_id: contact.room_id,
room_type: 'p2p',
is_group: false,
remark_name: contact.remark_name,
is_top: false,
is_muted: false,
user: contact.user
}
chatStore.setCurrentTarget(targetContact)
router.push('/chat')
}
function handleSearchGroupSelect(group: GroupSearchResult) {
closeSearchResults()
searchKeyword.value = ''
const targetGroup: Contact = {
id: group.room_id,
user_id: group.room_id,
contact_user_id: group.room_id,
room_id: group.room_id,
room_type: 'group',
is_group: true,
remark_name: group.room_name,
member_count: group.member_count,
owner_id: group.owner_id,
is_top: false,
is_muted: false
}
chatStore.setCurrentTarget(targetGroup)
router.push('/chat')
}
function handleSearchMessageSelect(message: MessageSearchResult) {
closeSearchResults()
searchKeyword.value = ''
const isGroup = message.is_group_chat
const targetContact: Contact = {
id: message.room_id,
user_id: isGroup ? message.room_id : (message.sender?.id || message.room_id),
contact_user_id: isGroup ? message.room_id : (message.sender?.id || message.room_id),
room_id: message.room_id,
room_type: isGroup ? 'group' : 'p2p',
is_group: isGroup,
remark_name: message.room_name || message.sender?.name || '',
is_top: false,
is_muted: false,
user: message.sender
}
chatStore.setCurrentTarget(targetContact)
router.push('/chat')
}
// --- API & Actions ---
async function loadGroups() {