Skip to content

feat: search pannel #145

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
May 10, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/frontend/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@ export {}
declare module 'vue' {
export interface GlobalComponents {
AlertCard: typeof import('./src/components/ui/AlertCard.vue')['default']
Avatar: typeof import('./src/components/ui/Avatar.vue')['default']
Button: typeof import('./src/components/ui/Button/Button.vue')['default']
ChatsCollapse: typeof import('./src/components/layout/ChatsCollapse.vue')['default']
ChatSelector: typeof import('./src/components/ChatSelector.vue')['default']
CheckboxGroup: typeof import('./src/components/ui/CheckboxGroup.vue')['default']
Dialog: typeof import('./src/components/ui/Dialog.vue')['default']
DropdownMenu: typeof import('./src/components/ui/DropdownMenu.vue')['default']
GlobalSearch: typeof import('./src/components/GlobalSearch.vue')['default']
HighlightText: typeof import('./src/components/ui/HighlightText.vue')['default']
MessageBubble: typeof import('./src/components/messages/MessageBubble.vue')['default']
Pagination: typeof import('./src/components/ui/Pagination.vue')['default']
Expand Down
169 changes: 169 additions & 0 deletions apps/frontend/src/components/GlobalSearch.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
<script setup lang="ts">
import type { CoreMessage, CoreRetrivalMessages } from '@tg-search/core'

import { useClipboard, useDebounce } from '@vueuse/core'
import { ref, watch } from 'vue'

import { useWebsocketStore } from '../store/useWebsocket'
import Avatar from './ui/Avatar.vue'

const props = defineProps<{
chatId?: string
}>()

const isOpen = defineModel<boolean>('open', { required: true })
const isLoading = ref(false)

const showSettings = ref(false)
const hoveredMessage = ref<CoreMessage | null>(null)
const { copy, copied } = useClipboard()

const keyword = ref<string>('')
const keywordDebounced = useDebounce(keyword, 1000)

function highlightKeyword(text: string, keyword: string) {
if (!keyword)
return text
const regex = new RegExp(`(${keyword})`, 'gi')
return text.replace(regex, '<span class="bg-yellow-200 dark:bg-yellow-800">$1</span>')
}

function copyMessage(message: CoreMessage) {
copy(message.content)
}

const websocketStore = useWebsocketStore()
const searchResult = ref<CoreRetrivalMessages[]>([])

// TODO: Infinite scroll
watch(keywordDebounced, (newKeyword) => {
if (newKeyword.length === 0) {
searchResult.value = []
return
}

isLoading.value = true
websocketStore.sendEvent('storage:search:messages', {
chatId: props.chatId,
content: newKeyword,
useVector: true,
pagination: {
limit: 10,
offset: 0,
},
})

websocketStore.waitForEvent('storage:search:messages:data').then(({ messages }) => {
searchResult.value = messages
isLoading.value = false
})
})
</script>

<template>
<div v-if="isOpen" class="flex items-center justify-center">
<div class="w-[45%] bg-card rounded-xl shadow-lg">
<!-- 搜索输入框 -->
<div class="px-4 py-3 border-b flex items-center gap-2">
<input
v-model="keyword"
class="w-full outline-none text-foreground"
>
<button
class="h-8 w-8 flex items-center justify-center rounded-md p-1 text-foreground hover:bg-muted"
@click="showSettings = !showSettings"
>
<span class="i-lucide-chevron-down h-4 w-4 transition-transform" :class="{ 'rotate-180': showSettings }" />
</button>
</div>

<!-- 设置栏 -->
<div v-if="showSettings" class="px-4 py-3 border-b">
<slot name="settings" />
</div>

<!-- 搜索结果 -->
<div
v-show="keywordDebounced"
class="p-4 min-h-[200px] transition-all duration-300 ease-in-out"
:class="{ 'opacity-0': !keywordDebounced, 'opacity-100': keywordDebounced }"
>
<template v-if="searchResult.length > 0">
<ul class="flex flex-col max-h-[540px] overflow-y-auto scrollbar-thin scrollbar-thumb-secondary scrollbar-track-transparent animate-fade-in">
<li
v-for="item in searchResult"
:key="item.uuid"
class="flex items-center gap-2 p-2 border-b bord last:border-b-0 hover:bg-muted/50 transition-all duration-200 ease-in-out animate-slide-in relative group cursor-pointer"
tabindex="0"
@mouseenter="hoveredMessage = item"
@mouseleave="hoveredMessage = null"
@keydown.enter="copyMessage(item)"
>
<Avatar
:name="item.fromName"
size="sm"
/>
<div class="flex-1 min-w-0">
<div class="text-sm font-semibold text-foreground truncate">
{{ item.fromName }}
</div>
<div class="text-sm text-muted-foreground break-words" v-html="highlightKeyword(item.content, keyword)" />
</div>
<div
v-if="hoveredMessage === item"
class="absolute bottom-0.5 right-0.5 text-[10px] text-muted-foreground flex items-center gap-0.5 opacity-50 bg-background/50 px-1 py-0.5 rounded"
>
<span>{{ copied ? '已复制' : '按下' }}</span>
<span v-if="!copied" class="i-lucide-corner-down-left h-2.5 w-2.5" />
<span v-else class="i-lucide-check h-2.5 w-2.5" />
<span v-if="!copied">复制</span>
</div>
</li>
</ul>
</template>
<template v-else-if="isLoading">
<div class="flex flex-col items-center justify-center py-12 text-muted-foreground opacity-70">
<span class="i-lucide-loader-circle text-3xl mb-2 animate-spin" />
<span>搜索中...</span>
</div>
</template>
<template v-else-if="searchResult.length === 0">
<div class="flex flex-col items-center justify-center py-12 text-muted-foreground opacity-70">
<span class="i-lucide-search text-3xl mb-2" />
<span>没有找到相关消息</span>
</div>
</template>
</div>
</div>
</div>
</template>

<style scoped>
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}

@keyframes slide-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

.animate-fade-in {
animation: fade-in 0.3s ease-out;
}

.animate-slide-in {
animation: slide-in 0.3s ease-out;
}
</style>
15 changes: 8 additions & 7 deletions apps/frontend/src/components/layout/ChatsCollapse.vue
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
<script setup lang="ts">
import type { CoreDialog } from '@tg-search/core'
import type { CoreDialog, DialogType } from '@tg-search/core'

import { useRoute, useRouter } from 'vue-router'

import Avatar from '../ui/Avatar.vue'

defineProps<{
type: 'user' | 'group' | 'channel'
type: DialogType
icon: string
name: string

Expand Down Expand Up @@ -59,11 +61,10 @@ function toggleActive() {
class="py-2 cursor-pointer hover:bg-muted flex flex-row justify-start items-center gap-2 px-6 transition-all duration-200 hover:-translate-y-0.5"
@click="router.push(`/chat/${chat.id}`)"
>
<img
:alt="`User ${chat.id}`"
:src="`https://api.dicebear.com/6.x/bottts/svg?seed=${chat.name}`"
class="h-6 w-6 rounded-full"
>
<Avatar
:name="chat.name"
size="sm"
/>
<div class="flex flex-col overflow-hidden">
<span class="truncate">
{{ chat.name }}
Expand Down
9 changes: 7 additions & 2 deletions apps/frontend/src/components/messages/MessageBubble.vue
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
<script setup lang="ts">
import type { CoreMessage } from '@tg-search/core'

import Avatar from '../ui/Avatar.vue'

defineProps<{
message: CoreMessage
}>()
</script>

<template>
<div class="flex items-start gap-4 rounded-lg p-3 transition-all duration-200 hover:bg-muted">
<div class="mt-1 h-9 w-9 flex items-center justify-center overflow-hidden">
<img :src="`https://api.dicebear.com/6.x/bottts/svg?seed=${message.fromId}`" alt="User" class="h-full w-full object-cover">
<div class="mt-1">
<Avatar
:name="message.fromName"
size="md"
/>
</div>
<div class="flex-1">
<div class="mb-1 flex items-center gap-2">
Expand Down
78 changes: 78 additions & 0 deletions apps/frontend/src/components/ui/Avatar.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<script setup lang="ts">
import { computed } from 'vue'

interface Props {
src?: string
name?: string
size?: 'sm' | 'md' | 'lg'
isOnline?: boolean
}

const props = withDefaults(defineProps<Props>(), {
size: 'md',
isOnline: false,
})

const sizeMap = {
sm: 'h-6 w-6',
md: 'h-10 w-10',
lg: 'h-12 w-12',
}

const avatarSize = computed(() => sizeMap[props.size])

const initials = computed(() => {
if (!props.name)
return ''
return props.name
.split(' ')
.map(word => word[0])
.join('')
.toUpperCase()
.slice(0, 1)
})

const backgroundColor = computed(() => {
if (!props.name)
return 'bg-muted'
// 根据名字生成固定的背景色
const colors = [
'bg-red-500',
'bg-pink-500',
'bg-purple-500',
'bg-indigo-500',
'bg-blue-500',
'bg-cyan-500',
'bg-teal-500',
'bg-green-500',
'bg-lime-500',
'bg-yellow-500',
'bg-orange-500',
]
const index = props.name.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0)
return colors[index % colors.length]
})
</script>

<template>
<div class="relative inline-block">
<div
class="relative rounded-full overflow-hidden flex items-center justify-center text-white font-medium" :class="[
avatarSize,
backgroundColor,
]"
>
<img
v-if="src"
:src="src"
:alt="name"
class="h-full w-full object-cover"
>
<span v-else class="text-sm">{{ initials }}</span>
</div>
<div
v-if="isOnline"
class="absolute bottom-0 right-0 h-3 w-3 rounded-full border-2 border-background bg-green-500"
/>
</div>
</template>
3 changes: 3 additions & 0 deletions apps/frontend/src/event-handlers/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,7 @@ export function registerStorageEventHandlers(
registerEventHandler('storage:messages', ({ messages }) => {
messagesStore.pushMessages(messages)
})

// Wait for result event
registerEventHandler('storage:search:messages:data', ({ messages: _messages }) => {})
}
14 changes: 8 additions & 6 deletions apps/frontend/src/layouts/default.vue
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
<script lang="ts" setup>
import type { DialogType } from '@tg-search/core'

import { useDark } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, ref, watch } from 'vue'
Expand Down Expand Up @@ -27,7 +29,7 @@ const chatsFiltered = computed(() => {
return chats.value.filter(chat => chat.name.toLowerCase().includes(searchParams.value.toLowerCase()))
})

type ChatGroup = 'user' | 'group' | 'channel' | ''
type ChatGroup = DialogType | ''
const activeChatGroup = ref<ChatGroup>('user')

watch(theme, (newTheme) => {
Expand All @@ -40,7 +42,7 @@ function toggleSettingsDialog() {

function toggleActiveChatGroup(group: ChatGroup) {
if (activeChatGroup.value === group)
activeChatGroup.value = ''
activeChatGroup.value = 'user'
else
activeChatGroup.value = group
}
Expand Down Expand Up @@ -127,10 +129,10 @@ function toggleActiveChatGroup(group: ChatGroup) {
<div class="flex items-center justify-between p-4">
<div class="flex items-center gap-3">
<div class="h-8 w-8 flex items-center justify-center overflow-hidden rounded-full bg-muted">
<img
alt="Me" src="https://api.dicebear.com/6.x/bottts/svg?seed=RainbowBird"
class="h-full w-full object-cover"
>
<Avatar
:name="sessionStore.getActiveSession()?.me?.username"
size="sm"
/>
</div>
<div class="flex flex-col">
<span class="text-sm text-foreground font-medium">{{ sessionStore.getActiveSession()?.me?.username }}</span>
Expand Down
Loading